@eintrek/erp-shell 0.1.51 → 0.1.52

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
@@ -13,8 +13,8 @@ var navigation = require('next/navigation');
13
13
  var Link = require('next/link');
14
14
  var reactDom = require('react-dom');
15
15
  var nextThemes = require('next-themes');
16
- var reactQueryDevtools = require('@tanstack/react-query-devtools');
17
16
  var axios = require('axios');
17
+ var reactQueryDevtools = require('@tanstack/react-query-devtools');
18
18
 
19
19
  function _interopNamespaceDefault(e) {
20
20
  var n = Object.create(null);
@@ -822,6 +822,64 @@ function isRecordID(segment) {
822
822
  /^[0-9a-f]{24,}$/i.test(segment));
823
823
  }
824
824
 
825
+ let open = false;
826
+ let hosts = 0;
827
+ let declaredApp = null;
828
+ const listeners = new Set();
829
+ function emit() {
830
+ listeners.forEach(l => l());
831
+ }
832
+ function subscribe(l) {
833
+ listeners.add(l);
834
+ return () => {
835
+ listeners.delete(l);
836
+ };
837
+ }
838
+ /** Opens the dialog — a no-op when no layout has mounted one. */
839
+ function openHelp() {
840
+ if (!open) {
841
+ open = true;
842
+ emit();
843
+ }
844
+ }
845
+ function closeHelp() {
846
+ if (open) {
847
+ open = false;
848
+ emit();
849
+ }
850
+ }
851
+ /** Called by the dialog host on mount; returns its unmount. */
852
+ function registerHelpHost() {
853
+ hosts += 1;
854
+ emit();
855
+ return () => {
856
+ hosts -= 1;
857
+ if (hosts <= 0) {
858
+ hosts = 0;
859
+ open = false;
860
+ }
861
+ emit();
862
+ };
863
+ }
864
+ function useHelpOpen() {
865
+ return React.useSyncExternalStore(subscribe, () => open, () => false);
866
+ }
867
+ /** True while a dialog host is mounted, i.e. the ช่วยเหลือ entry works. */
868
+ function useHelpAvailable() {
869
+ return React.useSyncExternalStore(subscribe, () => hosts > 0, () => false);
870
+ }
871
+ /**
872
+ * Every app names itself when it builds its app-switcher links
873
+ * (buildSystemLinks({ activeApp })); the dialog reads that to say which app
874
+ * a message came from without each app passing it again.
875
+ */
876
+ function declareHelpApp(app) {
877
+ declaredApp = app;
878
+ }
879
+ function declaredHelpApp() {
880
+ return declaredApp;
881
+ }
882
+
825
883
  function isExternalHref(href) {
826
884
  return /^https?:\/\//i.test(href) || href.startsWith("//");
827
885
  }
@@ -836,6 +894,8 @@ function NavUser({ organization, avatarSrc, profileHref, selectOrganizationHref
836
894
  const [showAppsMenu, setShowAppsMenu] = React.useState(false);
837
895
  const buttonRef = React.useRef(null);
838
896
  const [position, setPosition] = React.useState({ top: 0, left: 0 });
897
+ // Shown where a layout has mounted the help dialog (AppLayoutShell).
898
+ const helpAvailable = useHelpAvailable();
839
899
  const user = session?.user
840
900
  ? {
841
901
  name: session.user.name || "ผู้ใช้",
@@ -947,7 +1007,10 @@ function NavUser({ organization, avatarSrc, profileHref, selectOrganizationHref
947
1007
  setShowAppsMenu(!showAppsMenu);
948
1008
  setShowThemeMenu(false);
949
1009
  setShowLanguageMenu(false);
950
- }, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx(lucideReact.LayoutGrid, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E41\u0E2D\u0E1B\u0E1E\u0E25\u0E34\u0E40\u0E04\u0E0A\u0E31\u0E19" }), jsxRuntime.jsx(lucideReact.ChevronUp, { className: `h-4 w-4 transition-transform ${showAppsMenu ? "rotate-180" : ""}` })] }), showAppsMenu && (jsxRuntime.jsx("div", { className: "mt-1 ml-4 space-y-1", children: visibleSystemLinks.map(link => (jsxRuntime.jsxs("button", { type: "button", onClick: () => handleSystemLink(link.href), className: "flex w-full items-center gap-3 px-3 py-2 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [link.icon ?? jsxRuntime.jsx(lucideReact.ExternalLink, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: link.label }), link.active && (jsxRuntime.jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, `${link.label}:${link.href}`))) }))] })), jsxRuntime.jsx("button", { onClick: handleSignOut, disabled: isSigningOut, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed mt-1", children: isSigningOut ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(lucideReact.Loader2, { className: "h-4 w-4 animate-spin" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E23\u0E30\u0E1A\u0E1A..." })] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(lucideReact.LogOut, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E23\u0E30\u0E1A\u0E1A" })] })) })] })] })) : null;
1010
+ }, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx(lucideReact.LayoutGrid, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E41\u0E2D\u0E1B\u0E1E\u0E25\u0E34\u0E40\u0E04\u0E0A\u0E31\u0E19" }), jsxRuntime.jsx(lucideReact.ChevronUp, { className: `h-4 w-4 transition-transform ${showAppsMenu ? "rotate-180" : ""}` })] }), showAppsMenu && (jsxRuntime.jsx("div", { className: "mt-1 ml-4 space-y-1", children: visibleSystemLinks.map(link => (jsxRuntime.jsxs("button", { type: "button", onClick: () => handleSystemLink(link.href), className: "flex w-full items-center gap-3 px-3 py-2 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [link.icon ?? jsxRuntime.jsx(lucideReact.ExternalLink, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: link.label }), link.active && (jsxRuntime.jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, `${link.label}:${link.href}`))) }))] })), helpAvailable && (jsxRuntime.jsxs("button", { type: "button", onClick: () => {
1011
+ setIsOpen(false);
1012
+ openHelp();
1013
+ }, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx(lucideReact.CircleHelp, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D" })] })), jsxRuntime.jsx("button", { onClick: handleSignOut, disabled: isSigningOut, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed mt-1", children: isSigningOut ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(lucideReact.Loader2, { className: "h-4 w-4 animate-spin" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E23\u0E30\u0E1A\u0E1A..." })] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(lucideReact.LogOut, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E23\u0E30\u0E1A\u0E1A" })] })) })] })] })) : null;
951
1014
  if (variant === "workspace") {
952
1015
  return (jsxRuntime.jsx("div", { className: "mt-3.5 border-t border-sidebar-border pt-3", children: jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsxs("button", { ref: buttonRef, onClick: () => setIsOpen(!isOpen), "aria-expanded": isOpen, className: "flex w-full items-center gap-[11px] rounded-lg px-3 py-1.5 text-left transition-colors hover:bg-sidebar-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring", children: [jsxRuntime.jsxs(erpTheme.Avatar, { className: "size-[34px] shrink-0 rounded-full border-[3px] border-sidebar-accent", children: [user.avatar ? (jsxRuntime.jsx(erpTheme.AvatarImage, { src: user.avatar, alt: user.name })) : null, jsxRuntime.jsx(erpTheme.AvatarFallback, { className: "rounded-full bg-sidebar-accent text-sm font-medium text-sidebar-accent-foreground", children: getInitials(user.name) })] }), jsxRuntime.jsxs("span", { className: "grid min-w-0 flex-1 leading-tight", children: [jsxRuntime.jsx("strong", { className: "truncate text-[13px] font-semibold", title: user.name, children: user.name }), jsxRuntime.jsx("small", { className: "truncate text-[11px] text-muted-foreground", title: user.email, children: user.email })] }), jsxRuntime.jsx(lucideReact.ChevronsUpDown, { className: "size-3.5 shrink-0 text-muted-foreground" })] }), typeof window !== "undefined" &&
953
1016
  reactDom.createPortal(dropdownContent, document.body)] }) }));
@@ -1070,6 +1133,9 @@ const APPS = [
1070
1133
  * disagree with the screen.
1071
1134
  */
1072
1135
  function buildSystemLinks({ orgCode, activeApp, features, }) {
1136
+ // The app names itself here; the help dialog says which app a message
1137
+ // came from by it.
1138
+ declareHelpApp(activeApp);
1073
1139
  if (!orgCode)
1074
1140
  return [];
1075
1141
  return APPS.filter(app => app.id === activeApp ||
@@ -1341,993 +1407,1359 @@ const WORKSPACE_LIGHT_HEADER_VARS = {
1341
1407
  };
1342
1408
 
1343
1409
  /**
1344
- * Top-level app chrome: sticky header, sidebar slot, content gutter and the
1345
- * session/org-context plumbing that both Accounting and HR need. Apps wrap
1346
- * it inside their `SidebarProvider` (server-rendered with the cookie-backed
1347
- * `defaultOpen`) and their `ERPProvider`, then pass app-specific bits as
1348
- * props.
1349
- *
1350
- * The padding contract is deliberately owned here so future style tweaks
1351
- * (e.g. the recent left/right/bottom gutter pass) propagate to both apps via
1352
- * an erp-shell bump — domain components must NOT add their own root padding
1353
- * or content gets indented twice.
1410
+ * The ช่วยเหลือ button in the top bar. Drawn in the header's ink (the
1411
+ * --header-* variables of the workspace chrome, white on the brand chrome).
1354
1412
  */
1355
- function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permissionProvider, organizationId, setOrganizationId, organizations, signInUrl, selectOrganizationUrl, chrome = "brand", workspaceHeader = "brand", headerEnd, mobileBrand, }) {
1356
- const { data: session, status } = react.useSession();
1357
- const { open, setOpen } = useSidebar();
1358
- const router = navigation.useRouter();
1359
- const params = navigation.useParams();
1360
- const code = params?.code;
1361
- const extendedSession = session;
1362
- // Track once-authenticated so a transient accessToken drop during the
1363
- // periodic session refresh (SessionRefreshTrigger every 4 min) doesn't
1364
- // blank the whole page. The initial-load gate below still handles cold
1365
- // start; once we've seen a real token we render children optimistically
1366
- // and let the API layer surface a real 401 if the token actually died.
1367
- const hasSeenAccessTokenRef = React.useRef(false);
1368
- React.useEffect(() => {
1369
- if (extendedSession?.accessToken)
1370
- hasSeenAccessTokenRef.current = true;
1371
- }, [extendedSession?.accessToken]);
1372
- // Find the org from the URL `[code]` so we can detect a context mismatch
1373
- // (the user bookmarked a different org than the cookie remembers).
1374
- const orgFromCode = React__namespace.useMemo(() => {
1375
- if (!code || !organizations.length)
1376
- return null;
1377
- return (organizations.find(org => org.code === code || String(org.id) === code) ?? null);
1378
- }, [code, organizations]);
1379
- // Sync state: when the URL code points at a different org than the cookie,
1380
- // trust the URL and update the cookie. Otherwise do nothing — the user
1381
- // wins ties via the in-app org switcher.
1382
- React.useEffect(() => {
1383
- if (!code)
1384
- return;
1385
- if (!organizations.length)
1386
- return;
1387
- if (!orgFromCode)
1388
- return;
1389
- if (String(organizationId) !== String(orgFromCode.id)) {
1390
- void setOrganizationId(orgFromCode.id);
1391
- }
1392
- }, [
1393
- orgFromCode,
1394
- organizationId,
1395
- setOrganizationId,
1396
- code,
1397
- organizations.length,
1398
- ]);
1399
- // Key for the content subtree. It exists so switching organizations throws
1400
- // away the previous org's component state — but it must react ONLY to a real
1401
- // switch. organizationId legitimately passes through null while the context
1402
- // re-settles (session resolving, cookie write in flight), and keying on that
1403
- // unmounted and remounted the entire page: forms lost their loaded values,
1404
- // in-flight edits vanished, and settings pickers snapped back to defaults.
1405
- //
1406
- // Holding the last known id makes a transient null a no-op while a genuine
1407
- // org-to-org switch still changes the key.
1408
- const lastOrgKeyRef = React__namespace.useRef("no-org");
1409
- const currentOrgKey = organizationId === null ||
1410
- organizationId === undefined ||
1411
- String(organizationId).trim() === ""
1412
- ? null
1413
- : String(organizationId);
1414
- if (currentOrgKey !== null) {
1415
- lastOrgKeyRef.current = currentOrgKey;
1416
- }
1417
- const contentOrgKey = lastOrgKeyRef.current;
1418
- // Hold the page back until the active organisation is the one the URL names.
1419
- //
1420
- // The sync effect above writes the cookie through a server action, which is
1421
- // a round trip. Until it lands, getOrgFromCookie() still answers with the
1422
- // previous organisation and every request the page fires on mount carries
1423
- // it — so /O26000021/work-record asked about O26000033 and was correctly
1424
- // told there was nothing there. An empty table is the mild version: the
1425
- // create form posts to the same header, so a record could be filed against
1426
- // a company the user was not looking at.
1427
- //
1428
- // Only while the two genuinely disagree. A code naming no organisation of
1429
- // the caller's is left to the app to refuse, and a page with no code in its
1430
- // path is unaffected.
1431
- const orgContextIsStale = !!orgFromCode &&
1432
- organizationId !== null &&
1433
- organizationId !== undefined &&
1434
- String(organizationId) !== String(orgFromCode.id);
1435
- // ≤1024px: sidebar renders as a modal overlay (backdrop dims content). >1024px:
1436
- // sidebar is docked and the content gets a left margin to clear it.
1437
- const [isSidebarModalLayout, setIsSidebarModalLayout] = React__namespace.useState(false);
1438
- React.useEffect(() => {
1439
- const mq = window.matchMedia("(max-width: 1024px)");
1440
- const sync = () => setIsSidebarModalLayout(mq.matches);
1441
- sync();
1442
- mq.addEventListener("change", sync);
1443
- return () => mq.removeEventListener("change", sync);
1444
- }, []);
1445
- // Wait for session to load (initial load only). When refetching/updating we
1446
- // already have a session — don't flash the full-screen loader.
1447
- if (status === "loading" && !session) {
1448
- return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent });
1449
- }
1450
- // Session loaded but no accessToken AND we've never seen one → real auth
1451
- // failure (token refresh gave nothing back on cold start), bounce through
1452
- // signin. If we HAVE seen one before, the drop is almost certainly the
1453
- // in-flight periodic refresh (SessionRefreshTrigger). Don't redirect on
1454
- // that transient — the JWT callback will settle in a moment and API
1455
- // calls will keep working with the still-valid cookie.
1456
- if (status === "authenticated" &&
1457
- !extendedSession?.accessToken &&
1458
- !hasSeenAccessTokenRef.current) {
1459
- if (typeof window !== "undefined") {
1460
- const url = new URL(signInUrl, window.location.origin);
1461
- url.searchParams.set("error", "NoAccessToken");
1462
- url.searchParams.set("callbackUrl", window.location.pathname);
1463
- window.location.href = url.toString();
1464
- }
1465
- return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent });
1466
- }
1467
- // Only blank the page when we've never seen a token — otherwise the
1468
- // periodic session refresh would visibly flash the loader every 4 min.
1469
- if (!extendedSession?.accessToken && !hasSeenAccessTokenRef.current) {
1470
- return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent });
1471
- }
1472
- if (!code) {
1473
- router.push(selectOrganizationUrl);
1474
- return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent });
1475
- }
1476
- // When the sidebar is docked-open it consumes 21.5rem on the left; otherwise
1477
- // the content takes the full width and we add a left gutter inside.
1478
- const workspace = chrome === "workspace";
1479
- const contentMargin = open && !isSidebarModalLayout
1480
- ? workspace
1481
- ? "ml-[244px]"
1482
- : "ml-[21.5rem]"
1483
- : "";
1484
- const needsLeftGutter = workspace || !(open && !isSidebarModalLayout);
1485
- const wrap = permissionProvider ?? ((c) => c);
1486
- 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" &&
1487
- "border-b border-border bg-card/85 backdrop-blur-sm"), style: workspaceHeader === "light"
1488
- ? WORKSPACE_LIGHT_HEADER_VARS
1489
- : WORKSPACE_HEADER_VARS, children: [workspaceHeader === "light" ? null : (jsxRuntime.jsx(HeaderBackground, { className: "rounded-none" })), jsxRuntime.jsxs("header", { className: cx("relative z-10 flex w-full items-center gap-3 text-[var(--header-fg)]", workspaceHeader === "light"
1490
- ? "min-h-[66px] px-3 py-2 sm:min-h-[72px] sm:px-[22px] lg:min-h-[82px] lg:px-8"
1491
- : "min-h-[72px] px-[18px] py-3 sm:px-6 lg:min-h-[88px] lg:px-10"), children: [isSidebarModalLayout ? (
1492
- // As in the design: the mark, then a menu button, and the
1493
- // organisation on the right — no trail, no title, so the
1494
- // organisation's name has the room it needs on a phone.
1495
- 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 ? (jsxRuntime.jsx("div", { className: "flex shrink-0 items-center gap-3 sm:gap-5", children: headerEnd })) : 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 ? (jsxRuntime.jsx("div", { className: "flex shrink-0 items-center gap-3", children: headerEnd })) : 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
1496
- ? "relative z-10 px-[18px] pt-[26px] pb-4 sm:px-6 lg:px-10 lg:pt-[38px]"
1497
- : cx("relative z-10 pr-6", needsLeftGutter && "pl-6"), children: orgContextIsStale ? loadingComponent : wrap(children) }, `org-${contentOrgKey}`) })] }) })] }));
1498
- }
1499
- /** Local tiny classnames helper — avoids pulling clsx/tailwind-merge into
1500
- * the shell just for the layout file. Falsy values are skipped. */
1501
- function cx(...parts) {
1502
- return parts.filter(Boolean).join(" ");
1413
+ function HelpButton({ className }) {
1414
+ return (jsxRuntime.jsx("button", { type: "button", onClick: openHelp, "aria-label": "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D", title: "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D", className: "grid size-[34px] shrink-0 place-items-center rounded-lg text-[var(--header-fg-muted,#ffffffc7)] transition-colors hover:bg-[var(--header-hover,#ffffff1f)] hover:text-[var(--header-fg,#ffffff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--header-fg-muted,#ffffffc7)]" +
1415
+ (className ? ` ${className}` : ""), children: jsxRuntime.jsx(lucideReact.CircleHelp, { className: "size-5", strokeWidth: 1.65 }) }));
1503
1416
  }
1504
1417
 
1505
1418
  /**
1506
- * The product mark at the top of the workspace sidebar: the app icon, the
1507
- * name, and a spaced caption beneath it.
1419
+ * This package's version, attached to help messages so the back office can
1420
+ * tell which shell a report came from. Keep in step with package.json.
1508
1421
  */
1509
- function WorkspaceBrand({ name, caption, mark, href, compact = false, className, }) {
1510
- const body = (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("span", { className: erpTheme.cn("grid shrink-0 place-items-center overflow-hidden rounded-[14px] [&>img]:size-full [&>img]:object-cover", compact ? "size-9 rounded-[11px]" : "size-11"), children: mark }), compact ? (jsxRuntime.jsx("span", { className: "sr-only", children: name })) : (jsxRuntime.jsxs("span", { className: "min-w-0", children: [jsxRuntime.jsx("span", { className: "block truncate text-[25px] leading-tight font-extrabold tracking-[-0.05em]", children: name }), caption ? (jsxRuntime.jsx("small", { className: "-mt-0.5 block truncate text-[9px] font-semibold tracking-[0.29em] text-muted-foreground", children: caption })) : null] }))] }));
1511
- const cls = erpTheme.cn("flex items-center gap-[11px] rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", !compact && "px-4", className);
1512
- return href ? (jsxRuntime.jsx(Link, { href: href, className: cls, "aria-label": compact ? name : undefined, children: body })) : (jsxRuntime.jsx("div", { className: cls, children: body }));
1513
- }
1422
+ const SHELL_VERSION = "0.1.52";
1514
1423
 
1515
- function isExternal(href) {
1516
- return /^https?:\/\//i.test(href);
1424
+ const HELP_APP_LABEL = {
1425
+ platform: "แพลตฟอร์ม",
1426
+ accounx: "Accounx",
1427
+ peoplx: "Peoplx",
1428
+ plannex: "Plannex",
1429
+ invenx: "Invenx",
1430
+ other: "อื่น ๆ",
1431
+ };
1432
+ function originOf(value) {
1433
+ if (!value)
1434
+ return null;
1435
+ try {
1436
+ return new URL(value).origin;
1437
+ }
1438
+ catch {
1439
+ return null;
1440
+ }
1517
1441
  }
1518
1442
  /**
1519
- * The card at the foot of the workspace sidebar that points someone who
1520
- * does not know where to start at the place that will tell them.
1443
+ * Which app this page is: what the host said, what the app declared to the
1444
+ * app switcher, then the production sub-domain, then the app URLs the build
1445
+ * was given (local development). "other" when none of them tells.
1521
1446
  */
1522
- function SidebarHelpCard({ title, description, actionLabel, href, icon, className, }) {
1523
- const external = isExternal(href);
1524
- const action = (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [actionLabel, jsxRuntime.jsx(lucideReact.ArrowRight, { className: "size-[15px]" })] }));
1525
- const actionClass = "mt-3 flex w-full items-center justify-between text-xs font-semibold text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded";
1526
- return (jsxRuntime.jsxs("div", { className: erpTheme.cn("mx-1.5 mb-5 rounded-[13px] border border-primary/10 bg-gradient-to-br from-primary/[0.06] to-primary/[0.02] px-3.5 py-4", className), children: [jsxRuntime.jsx("span", { className: "mb-[7px] block text-primary/70", children: icon ?? jsxRuntime.jsx(lucideReact.Compass, { className: "size-6", strokeWidth: 1.6 }) }), jsxRuntime.jsx("strong", { className: "block text-xs font-semibold", children: title }), description ? (jsxRuntime.jsx("p", { className: "mt-[7px] text-xs leading-[1.7] text-muted-foreground", children: description })) : null, external ? (jsxRuntime.jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: actionClass, children: action })) : (jsxRuntime.jsx(Link, { href: href, className: actionClass, children: action }))] }));
1527
- }
1528
- /** A quiet row under the help card — the manual, the support chat. */
1529
- function SidebarHelpLink({ label, href, icon, className }) {
1530
- const external = isExternal(href);
1531
- const cls = erpTheme.cn("flex w-full items-center gap-3 rounded-[10px] px-3.5 py-[11px] text-xs font-medium text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", className);
1532
- const body = (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [icon ? jsxRuntime.jsx("span", { className: "shrink-0 [&>svg]:size-[18px]", children: icon }) : null, jsxRuntime.jsx("span", { className: "flex-1 truncate", children: label }), external ? jsxRuntime.jsx(lucideReact.ArrowUpRight, { className: "size-4 shrink-0" }) : null] }));
1533
- 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 }));
1447
+ function detectHelpApp(explicit) {
1448
+ if (explicit)
1449
+ return explicit;
1450
+ const declared = declaredHelpApp();
1451
+ if (declared)
1452
+ return declared;
1453
+ if (typeof window === "undefined")
1454
+ return "other";
1455
+ const host = window.location.hostname.toLowerCase();
1456
+ if (/^(accounx|erp|www\.erp)\./.test(host))
1457
+ return "accounx";
1458
+ if (/^(peoplx|hr)\./.test(host))
1459
+ return "peoplx";
1460
+ if (host.startsWith("plannex."))
1461
+ return "plannex";
1462
+ if (host.startsWith("invenx."))
1463
+ return "invenx";
1464
+ if (host.startsWith("platform.") || host === "antniti.com" || host === "www.antniti.com") {
1465
+ return "platform";
1466
+ }
1467
+ const here = window.location.origin;
1468
+ const configured = [
1469
+ ["platform", process.env.NEXT_PUBLIC_PLATFORM_URL],
1470
+ ["accounx", process.env.NEXT_PUBLIC_ACCOUNTING_URL],
1471
+ ["invenx", process.env.NEXT_PUBLIC_INVENX_URL],
1472
+ ["plannex", process.env.NEXT_PUBLIC_PLANNEX_URL],
1473
+ ["peoplx", process.env.NEXT_PUBLIC_HR_URL],
1474
+ ];
1475
+ for (const [id, url] of configured) {
1476
+ if (originOf(url) === here)
1477
+ return id;
1478
+ }
1479
+ return "other";
1480
+ }
1481
+ /** "Chrome 141" and "macOS" from a user agent — enough to reproduce. */
1482
+ function describeBrowser(ua) {
1483
+ const pick = (re) => ua.match(re)?.[1] ?? "";
1484
+ let browser = "";
1485
+ if (/\bLine\//i.test(ua))
1486
+ browser = `LINE ${pick(/\bLine\/([\d.]+)/i)}`;
1487
+ else if (/\bEdg(A|iOS)?\//.test(ua))
1488
+ browser = `Edge ${pick(/\bEdg(?:A|iOS)?\/(\d+)/)}`;
1489
+ else if (/\bOPR\//.test(ua))
1490
+ browser = `Opera ${pick(/\bOPR\/(\d+)/)}`;
1491
+ else if (/SamsungBrowser\//.test(ua))
1492
+ browser = `Samsung Internet ${pick(/SamsungBrowser\/(\d+)/)}`;
1493
+ else if (/\b(Firefox|FxiOS)\//.test(ua))
1494
+ browser = `Firefox ${pick(/\b(?:Firefox|FxiOS)\/(\d+)/)}`;
1495
+ else if (/\bCriOS\//.test(ua))
1496
+ browser = `Chrome ${pick(/\bCriOS\/(\d+)/)}`;
1497
+ else if (/(\b|Headless)Chrome\//.test(ua))
1498
+ browser = `Chrome ${pick(/(?:\b|Headless)Chrome\/(\d+)/)}`;
1499
+ else if (/\bSafari\//.test(ua))
1500
+ browser = `Safari ${pick(/\bVersion\/([\d.]+)/)}`;
1501
+ let platform = "";
1502
+ if (/\b(iPhone|iPad|iPod)\b/.test(ua)) {
1503
+ platform = `iOS ${pick(/\bOS (\d+[_\d]*)/).replace(/_/g, ".")}`;
1504
+ }
1505
+ else if (/\bAndroid\b/.test(ua)) {
1506
+ platform = `Android ${pick(/\bAndroid ([\d.]+)/)}`;
1507
+ }
1508
+ else if (/\bWindows\b/.test(ua)) {
1509
+ platform = "Windows";
1510
+ }
1511
+ else if (/\bMac OS X\b/.test(ua)) {
1512
+ platform = "macOS";
1513
+ }
1514
+ else if (/\bCrOS\b/.test(ua)) {
1515
+ platform = "ChromeOS";
1516
+ }
1517
+ else if (/\bLinux\b/.test(ua)) {
1518
+ platform = "Linux";
1519
+ }
1520
+ return { browser: browser.trim(), platform: platform.trim() };
1534
1521
  }
1535
-
1536
1522
  /**
1537
- * Central "no access" screen shared by every policy check (route-level
1538
- * PermissionProvider and page-level PermissionGateShell). One look everywhere:
1539
- * an illustration, a 403 headline, the page-specific reason, and recovery
1540
- * actions.
1523
+ * The deployed build: NEXT_PUBLIC_APP_VERSION when the app sets one, else
1524
+ * the Next build id in the page's own script paths (the build time, as every
1525
+ * app's generateBuildId writes it).
1541
1526
  */
1542
- function AccessDenied({ title = "ขออภัย, คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (403)", message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ ขออภัยในความไม่สะดวก", homeHref, homeLabel = "กลับไปหน้าหลัก", supportHref, supportLabel = "ติดต่อฝ่ายสนับสนุน", fullScreen = true, className, }) {
1543
- // "/" is not the home page inside an app — it bounces to organization
1544
- // selection, so the button read "กลับไปหน้าหลัก" and dropped the user into a
1545
- // picker for the org they were already in. Send them to that org's home.
1546
- const params = navigation.useParams();
1547
- const code = typeof params?.code === "string" ? params.code : "";
1548
- const homeTarget = homeHref ?? (code ? `/${code}` : "/");
1549
- const go = (href) => {
1550
- if (typeof window !== "undefined")
1551
- window.location.href = href;
1527
+ function readAppVersion() {
1528
+ const declared = process.env.NEXT_PUBLIC_APP_VERSION?.trim();
1529
+ if (declared)
1530
+ return declared;
1531
+ if (typeof document === "undefined")
1532
+ return "";
1533
+ const ignore = new Set(["chunks", "css", "media", "development", "webpack"]);
1534
+ for (const s of Array.from(document.querySelectorAll("script[src]"))) {
1535
+ const m = s.src.match(/\/_next\/static\/([^/]+)\//);
1536
+ if (m && !ignore.has(m[1]))
1537
+ return m[1];
1538
+ }
1539
+ return "";
1540
+ }
1541
+ function pageUrl() {
1542
+ if (typeof window === "undefined")
1543
+ return "";
1544
+ const { origin, pathname, search } = window.location;
1545
+ return `${origin}${pathname}${search}`;
1546
+ }
1547
+ /** Everything attached to a message, read at the moment it is sent. */
1548
+ function collectHelpContext(orgCode) {
1549
+ if (typeof window === "undefined")
1550
+ return { shell_version: SHELL_VERSION };
1551
+ const { browser, platform } = describeBrowser(window.navigator.userAgent || "");
1552
+ let timeZone = "";
1553
+ try {
1554
+ timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
1555
+ }
1556
+ catch {
1557
+ timeZone = "";
1558
+ }
1559
+ const ctx = {
1560
+ page_url: pageUrl(),
1561
+ page_title: document.title || undefined,
1562
+ org_code: orgCode || undefined,
1563
+ app_version: readAppVersion() || undefined,
1564
+ shell_version: SHELL_VERSION,
1565
+ browser: browser || undefined,
1566
+ platform: platform || undefined,
1567
+ screen: `${window.screen.width}x${window.screen.height}`,
1568
+ viewport: `${window.innerWidth}x${window.innerHeight}`,
1569
+ language: window.navigator.language || undefined,
1570
+ time_zone: timeZone || undefined,
1552
1571
  };
1553
- const wrapperClass = [
1554
- "flex min-w-0 flex-1 items-center justify-center p-4",
1555
- fullScreen ? "min-h-[70vh]" : "",
1556
- className ?? "",
1572
+ return ctx;
1573
+ }
1574
+ /** The short line under the form, "ข้อมูลที่แนบ: …". */
1575
+ function describeAttached(app, ctx, email) {
1576
+ let path = "";
1577
+ if (ctx.page_url) {
1578
+ try {
1579
+ path = new URL(ctx.page_url).pathname;
1580
+ }
1581
+ catch {
1582
+ path = ctx.page_url;
1583
+ }
1584
+ }
1585
+ // The organisation code is shown on its own; the path after it says where.
1586
+ if (ctx.org_code && path.startsWith(`/${ctx.org_code}`)) {
1587
+ path = path.slice(ctx.org_code.length + 1);
1588
+ }
1589
+ if (path === "/")
1590
+ path = "";
1591
+ const browser = [ctx.browser, ctx.platform && `(${ctx.platform})`].filter(Boolean).join(" ");
1592
+ return [
1593
+ HELP_APP_LABEL[app],
1594
+ ctx.org_code,
1595
+ path,
1596
+ email || undefined,
1597
+ browser || undefined,
1598
+ ctx.app_version,
1557
1599
  ]
1558
1600
  .filter(Boolean)
1559
- .join(" ");
1560
- 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] })] })] }) }) }));
1601
+ .join(" · ");
1561
1602
  }
1562
1603
 
1563
- const EMPLOYEE_RECORD_REQUIRED = "EMPLOYEE_RECORD_REQUIRED";
1564
- const EMPLOYEE_RECORD_REQUIRED_EVENT = "erp:employee-record-required";
1565
- const EMPLOYEE_RECORD_OK_EVENT = "erp:employee-record-ok";
1566
- /**
1567
- * Shown when the API refuses every request because the signed-in account has
1568
- * no employee record in this organisation.
1569
- *
1570
- * A gate rather than a banner. Leave, attendance, approvals and payslips all
1571
- * identify a person by that record, so once the server started enforcing it
1572
- * every call fails — and without something that says so, the app becomes a
1573
- * screen of error toasts with no explanation and no way forward.
1574
- */
1575
- function EmployeeRequiredGate({ platformBaseUrl, }) {
1576
- const [blocked, setBlocked] = React.useState(false);
1577
- const params = navigation.useParams();
1578
- const code = params?.code ?? "";
1579
- React.useEffect(() => {
1580
- const onBlocked = () => setBlocked(true);
1581
- const onOk = () => setBlocked(false);
1582
- window.addEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, onBlocked);
1583
- window.addEventListener(EMPLOYEE_RECORD_OK_EVENT, onOk);
1584
- return () => {
1585
- window.removeEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, onBlocked);
1586
- window.removeEventListener(EMPLOYEE_RECORD_OK_EVENT, onOk);
1587
- };
1588
- }, []);
1589
- if (!blocked)
1604
+ const TOKEN_TTL_MS = 30000;
1605
+ let cachedToken = null;
1606
+ let cachedAt = 0;
1607
+ let inFlightTokenPromise = null;
1608
+ const readSessionToken = async () => {
1609
+ const response = await fetch("/api/auth/session", {
1610
+ credentials: "include",
1611
+ cache: "no-store",
1612
+ });
1613
+ if (!response.ok)
1590
1614
  return null;
1591
- const base = platformBaseUrl?.replace(/\/$/, "") ?? "";
1592
- const createHref = code ? `${base}/${code}/employee/create` : `${base}/`;
1593
- return (jsxRuntime.jsx("div", { className: "fixed inset-0 z-[100] flex items-center justify-center bg-background/95 p-6 backdrop-blur-sm", children: jsxRuntime.jsxs("div", { className: "w-full max-w-md space-y-5 text-center", children: [jsxRuntime.jsx(lucideReact.ContactRound, { className: "mx-auto h-12 w-12 text-muted-foreground" }), jsxRuntime.jsxs("div", { className: "space-y-2", children: [jsxRuntime.jsx("h1", { className: "text-lg font-semibold tracking-tight", children: "\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E19\u0E35\u0E49\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E1C\u0E39\u0E01\u0E01\u0E31\u0E1A\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E1E\u0E19\u0E31\u0E01\u0E07\u0E32\u0E19" }), jsxRuntime.jsx("p", { className: "text-sm text-muted-foreground", children: "\u0E25\u0E32 \u0E25\u0E07\u0E40\u0E27\u0E25\u0E32 \u0E40\u0E1A\u0E34\u0E01\u0E40\u0E07\u0E34\u0E19 \u0E41\u0E25\u0E30\u0E01\u0E32\u0E23\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34 \u0E23\u0E30\u0E1A\u0E38\u0E15\u0E31\u0E27\u0E04\u0E19\u0E08\u0E32\u0E01\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E1E\u0E19\u0E31\u0E01\u0E07\u0E32\u0E19 \u0E08\u0E36\u0E07\u0E22\u0E31\u0E07\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E43\u0E19\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23\u0E19\u0E35\u0E49\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49" })] }), jsxRuntime.jsxs("div", { className: "flex flex-wrap justify-center gap-2", children: [jsxRuntime.jsx(erpTheme.Button, { onClick: () => window.location.reload(), children: "\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48" }), jsxRuntime.jsx(erpTheme.Button, { variant: "outline", asChild: true, children: jsxRuntime.jsxs("a", { href: createHref, children: [jsxRuntime.jsx(lucideReact.UserPlus, { className: "mr-1 h-4 w-4" }), "\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E1E\u0E19\u0E31\u0E01\u0E07\u0E32\u0E19"] }) })] }), jsxRuntime.jsx("p", { className: "text-xs text-muted-foreground", children: "\u0E16\u0E49\u0E32\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E41\u0E01\u0E49\u0E44\u0E02 \u0E43\u0E2B\u0E49\u0E41\u0E08\u0E49\u0E07\u0E1C\u0E39\u0E49\u0E14\u0E39\u0E41\u0E25\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" })] }) }));
1594
- }
1595
-
1596
- const shellClassName = "flex min-w-0 flex-1 flex-col gap-6 overflow-x-hidden";
1597
- /**
1598
- * Shared layout for “กำลังตรวจสอบสิทธิ์” (skeleton) and “ไม่มีสิทธิ์” (message).
1599
- * Renders `children` only when policy is loaded and access is granted.
1600
- */
1601
- function PermissionGateShell({ policyLoading, hasPolicyAccess, deniedMessage, children, skeletonCount = 6, skeletonGridClassName = "grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3", skeletonVariant = "kpi", }) {
1602
- if (policyLoading) {
1603
- return (jsxRuntime.jsx("div", { className: shellClassName, children: jsxRuntime.jsx("div", { className: skeletonGridClassName, children: Array.from({ length: skeletonCount }).map((_, i) => (jsxRuntime.jsx(erpTheme.Card, { children: skeletonVariant === "kpi" ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs(erpTheme.CardHeader, { className: "flex flex-row items-center justify-between pb-2", children: [jsxRuntime.jsx(erpTheme.Skeleton, { className: "h-4 w-24" }), jsxRuntime.jsx(erpTheme.Skeleton, { className: "h-4 w-4 rounded" })] }), jsxRuntime.jsxs(erpTheme.CardContent, { children: [jsxRuntime.jsx(erpTheme.Skeleton, { className: "h-8 w-32 mb-2" }), jsxRuntime.jsx(erpTheme.Skeleton, { className: "h-3 w-20" })] })] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(erpTheme.CardHeader, { className: "pb-2", children: jsxRuntime.jsx(erpTheme.Skeleton, { className: "h-4 w-24" }) }), jsxRuntime.jsx(erpTheme.CardContent, { children: jsxRuntime.jsx(erpTheme.Skeleton, { className: "h-8 w-16" }) })] })) }, i))) }) }));
1615
+ const session = (await response.json());
1616
+ return session.accessToken ?? null;
1617
+ };
1618
+ const getAccessToken = async (forceRefresh = false) => {
1619
+ const now = Date.now();
1620
+ if (!forceRefresh && cachedToken && now - cachedAt < TOKEN_TTL_MS) {
1621
+ return cachedToken;
1604
1622
  }
1605
- if (!hasPolicyAccess) {
1606
- return (jsxRuntime.jsx("div", { className: shellClassName, children: jsxRuntime.jsx(AccessDenied, { message: deniedMessage }) }));
1623
+ if (!inFlightTokenPromise) {
1624
+ inFlightTokenPromise = readSessionToken()
1625
+ .then(token => {
1626
+ cachedToken = token;
1627
+ cachedAt = Date.now();
1628
+ return token;
1629
+ })
1630
+ .finally(() => {
1631
+ inFlightTokenPromise = null;
1632
+ });
1633
+ }
1634
+ return inFlightTokenPromise;
1635
+ };
1636
+ const clearAccessTokenCache = () => {
1637
+ cachedToken = null;
1638
+ cachedAt = 0;
1639
+ };
1640
+
1641
+ class ApiError extends Error {
1642
+ constructor(message, options) {
1643
+ super(message);
1644
+ Object.defineProperty(this, "status", {
1645
+ enumerable: true,
1646
+ configurable: true,
1647
+ writable: true,
1648
+ value: void 0
1649
+ });
1650
+ Object.defineProperty(this, "code", {
1651
+ enumerable: true,
1652
+ configurable: true,
1653
+ writable: true,
1654
+ value: void 0
1655
+ });
1656
+ Object.defineProperty(this, "field", {
1657
+ enumerable: true,
1658
+ configurable: true,
1659
+ writable: true,
1660
+ value: void 0
1661
+ });
1662
+ Object.defineProperty(this, "details", {
1663
+ enumerable: true,
1664
+ configurable: true,
1665
+ writable: true,
1666
+ value: void 0
1667
+ });
1668
+ this.name = "ApiError";
1669
+ this.status = options?.status;
1670
+ this.code = options?.code;
1671
+ this.field = options?.field;
1672
+ this.details = options?.details;
1607
1673
  }
1608
- return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
1609
1674
  }
1675
+ const toApiError = (error) => {
1676
+ if (error instanceof ApiError)
1677
+ return error;
1678
+ if (axios.isAxiosError(error)) {
1679
+ const axiosErr = error;
1680
+ const message = axiosErr.response?.data?.message ||
1681
+ axiosErr.response?.data?.error ||
1682
+ axiosErr.message ||
1683
+ "Request failed";
1684
+ return new ApiError(message, {
1685
+ status: axiosErr.response?.status,
1686
+ code: axiosErr.response?.data?.code,
1687
+ field: axiosErr.response?.data?.field,
1688
+ details: axiosErr.response?.data?.details,
1689
+ });
1690
+ }
1691
+ if (error instanceof Error) {
1692
+ return new ApiError(error.message);
1693
+ }
1694
+ return new ApiError(String(error));
1695
+ };
1610
1696
 
1611
- const PermissionRulesContext = React.createContext(undefined);
1612
- /**
1613
- * Generic provider — the consumer app passes in `data` (live fetch) and
1614
- * `fallbackPolicies` (static JSON). All apps share the same mapping logic
1615
- * (build a flat map of route_key/policy_key → required child groups).
1616
- */
1617
- function PermissionRulesProvider({ children, data, isLoading = false, isError = false, fallbackPolicies = [], }) {
1618
- const rulesMap = React.useMemo(() => {
1619
- const m = {};
1620
- if (!data?.routes)
1621
- return m;
1622
- for (const row of data.routes) {
1623
- m[row.route_key] = row.required_child_groups ?? [];
1697
+ const isPlainObject = (value) => Object.prototype.toString.call(value) === "[object Object]";
1698
+ const visit = (value, cb) => {
1699
+ if (Array.isArray(value)) {
1700
+ value.forEach(item => visit(item, cb));
1701
+ return;
1702
+ }
1703
+ if (!isPlainObject(value)) {
1704
+ return;
1705
+ }
1706
+ cb(value);
1707
+ Object.values(value).forEach(item => visit(item, cb));
1708
+ };
1709
+ const validateGoodsReceiptChecks = (data) => {
1710
+ let invalidPath = null;
1711
+ const isCheckStatus = (v) => v === undefined || v === null || v === "pass" || v === "fail";
1712
+ const isCompareStatus = (v) => v === undefined || v === null || v === "match" || v === "mismatch";
1713
+ visit(data, node => {
1714
+ if (invalidPath)
1715
+ return;
1716
+ if ("quantity_check" in node && !isCheckStatus(node.quantity_check)) {
1717
+ invalidPath = "quantity_check";
1718
+ return;
1624
1719
  }
1625
- return m;
1626
- }, [data]);
1627
- const policiesMap = React.useMemo(() => {
1628
- const m = {};
1629
- for (const row of fallbackPolicies) {
1630
- m[row.policy_key] = row.required_child_groups ?? [];
1720
+ if ("quality_check" in node && !isCheckStatus(node.quality_check)) {
1721
+ invalidPath = "quality_check";
1722
+ return;
1631
1723
  }
1632
- if (data?.policies?.length) {
1633
- for (const row of data.policies) {
1634
- m[row.policy_key] = row.required_child_groups ?? [];
1635
- }
1724
+ if ("compare_with_reference" in node &&
1725
+ !isCompareStatus(node.compare_with_reference)) {
1726
+ invalidPath = "compare_with_reference";
1636
1727
  }
1637
- return m;
1638
- }, [data, fallbackPolicies]);
1639
- const value = React.useMemo(() => ({
1640
- rulesMap,
1641
- policiesMap,
1642
- isLoading,
1643
- isError,
1644
- updatedAt: data?.updated_at ?? null,
1645
- }), [rulesMap, policiesMap, isLoading, isError, data?.updated_at]);
1646
- return (jsxRuntime.jsx(PermissionRulesContext.Provider, { value: value, children: children }));
1647
- }
1648
- /**
1649
- * Read the permission rules. Outside the provider, report loading so
1650
- * fail-closed consumers (nav / gates) do not treat empty maps as "allow all".
1651
- */
1652
- function usePermissionRules() {
1653
- const ctx = React.useContext(PermissionRulesContext);
1654
- if (!ctx) {
1728
+ });
1729
+ if (invalidPath) {
1655
1730
  return {
1656
- rulesMap: {},
1657
- policiesMap: {},
1658
- isLoading: true,
1659
- isError: false,
1660
- updatedAt: null,
1731
+ valid: false,
1732
+ message: "รูปแบบข้อมูลไม่ถูกต้อง: ฟิลด์ตรวจรับต้องเป็น enum string (quantity/quality: pass|fail, compare: match|mismatch)",
1661
1733
  };
1662
1734
  }
1663
- return ctx;
1664
- }
1665
-
1666
- /**
1667
- * Strip the org `[code]` prefix from a pathname so we can look it up in the
1668
- * permission rules map. `/O12345/setting/foo` → `/setting/foo`. Single-segment
1669
- * paths (e.g. `/select-organization`) keep their leading slash.
1670
- *
1671
- * Also normalizes the user-role subtab aliases — both `purchase-doc` and
1672
- * `sales-doc` are gated by the same `members` rule because the UI shows
1673
- * the same management screen with different filters.
1674
- */
1675
- function normalizePathnameForPermissionRules(pathname) {
1676
- const parts = pathname.split("/").filter(Boolean);
1677
- const path = parts.length <= 1 ? "/" : "/" + parts.slice(1).join("/");
1678
- return path.replace(/\/user-role\/(purchase-doc|sales-doc)$/, "/user-role/members");
1679
- }
1680
- function hasRule(rulesMap, routeKey) {
1681
- return Object.prototype.hasOwnProperty.call(rulesMap, routeKey);
1682
- }
1683
- /**
1684
- * Resolve UI route permissions for a pathname.
1685
- *
1686
- * - Exact / `[param]` pattern match → matched
1687
- * - Parent-prefix inherit only when the parent rule has a **non-empty**
1688
- * role list (restrictive inherit). Explicitly public parents (`[]`) do
1689
- * not open all children.
1690
- * - No match → `{ matched: false }` (fail closed — never treat as public)
1691
- */
1692
- function resolveUiRoutePermission(pathname, rulesMap) {
1693
- const normalized = normalizePathnameForPermissionRules(pathname);
1694
- if (hasRule(rulesMap, normalized)) {
1695
- return { matched: true, roles: rulesMap[normalized] ?? [] };
1735
+ return { valid: true };
1736
+ };
1737
+ const runGlobalRequestPrecheck = (config) => {
1738
+ const method = (config.method || "get").toLowerCase();
1739
+ const isWriteMethod = ["post", "put", "patch"].includes(method);
1740
+ if (!isWriteMethod || config.data === undefined || config.data === null) {
1741
+ return { valid: true };
1696
1742
  }
1697
- const pathSegments = normalized.split("/").filter(Boolean);
1698
- for (const [pattern, roles] of Object.entries(rulesMap)) {
1699
- const patternSegments = pattern.split("/").filter(Boolean);
1700
- if (patternSegments.length !== pathSegments.length)
1701
- continue;
1702
- let matches = true;
1703
- for (let i = 0; i < patternSegments.length; i++) {
1704
- const patternSegment = patternSegments[i];
1705
- const pathSegment = pathSegments[i];
1706
- if (patternSegment.startsWith("[") && patternSegment.endsWith("]")) {
1707
- if (!pathSegment) {
1708
- matches = false;
1709
- break;
1710
- }
1711
- }
1712
- else if (patternSegment !== pathSegment) {
1713
- matches = false;
1714
- break;
1715
- }
1716
- }
1717
- if (matches) {
1718
- return { matched: true, roles: roles ?? [] };
1719
- }
1743
+ if (typeof FormData !== "undefined" && config.data instanceof FormData) {
1744
+ return { valid: true };
1720
1745
  }
1721
- // Parent-prefix: /leave/types/abc → /leave/types (non-empty roles only)
1722
- for (let len = pathSegments.length - 1; len >= 1; len--) {
1723
- const parent = "/" + pathSegments.slice(0, len).join("/");
1724
- if (hasRule(rulesMap, parent) && (rulesMap[parent]?.length ?? 0) > 0) {
1725
- return { matched: true, roles: rulesMap[parent] ?? [] };
1746
+ return validateGoodsReceiptChecks(config.data);
1747
+ };
1748
+
1749
+ const isBrowser = () => typeof window !== "undefined";
1750
+ const isAuthEndpoint = (url) => !!url && url.includes("/api/auth/");
1751
+ const isAuthPage = () => isBrowser() && window.location.pathname.startsWith("/auth/");
1752
+ const getOrgFromCookie = () => {
1753
+ if (!isBrowser())
1754
+ return null;
1755
+ const cookies = document.cookie.split(";").map(cookie => cookie.trim());
1756
+ const orgCookie = cookies.find(cookie => cookie.startsWith("erp.organizationId="));
1757
+ if (!orgCookie)
1758
+ return null;
1759
+ const raw = orgCookie.substring("erp.organizationId=".length);
1760
+ return raw ? decodeURIComponent(raw) : null;
1761
+ };
1762
+ const redirectToLogout = async () => {
1763
+ if (!isBrowser() || isAuthPage())
1764
+ return;
1765
+ localStorage.clear();
1766
+ sessionStorage.clear();
1767
+ setOrganizationId(null);
1768
+ clearAccessTokenCache();
1769
+ await react.signOut({ callbackUrl: "/auth/signin" });
1770
+ };
1771
+ const instance = axios.create({
1772
+ baseURL: process.env.NEXT_PUBLIC_API_URL,
1773
+ headers: { "Content-Type": "application/json" },
1774
+ paramsSerializer: params => {
1775
+ const searchParams = new URLSearchParams();
1776
+ Object.entries(params || {}).forEach(([key, value]) => {
1777
+ if (Array.isArray(value)) {
1778
+ value.forEach(v => searchParams.append(key, String(v)));
1779
+ }
1780
+ else if (value !== undefined && value !== null) {
1781
+ searchParams.append(key, String(value));
1782
+ }
1783
+ });
1784
+ return searchParams.toString();
1785
+ },
1786
+ });
1787
+ instance.interceptors.request.use(async (config) => {
1788
+ if (!config.headers) {
1789
+ config.headers = new axios.AxiosHeaders();
1790
+ }
1791
+ if (config.data instanceof FormData && config.headers) {
1792
+ delete config.headers["Content-Type"];
1793
+ }
1794
+ if (!isBrowser())
1795
+ return config;
1796
+ const token = await getAccessToken(false);
1797
+ if (token) {
1798
+ config.headers.Authorization = `Bearer ${token}`;
1799
+ }
1800
+ // localStorage first, cookie second.
1801
+ //
1802
+ // Both are written by the same state machine, but not at the same moment:
1803
+ // setOrganizationId writes localStorage synchronously and then awaits a
1804
+ // server action for the cookie. Preferring the cookie meant every request
1805
+ // fired during that round trip carried the previous organisation — while
1806
+ // the query params, built from React state, already carried the new one.
1807
+ //
1808
+ // The backend scopes by this header and ignores organization_id in the
1809
+ // query, so those requests asked for organisation A and were handed B, and
1810
+ // the answer landed in the cache under A's key. No amount of cache clearing
1811
+ // reaches that: the entry is wrong, not stale.
1812
+ const organizationId = getOrganizationId() || getOrgFromCookie();
1813
+ if (organizationId) {
1814
+ config.headers["X-Organization-ID"] = organizationId;
1815
+ }
1816
+ const precheck = runGlobalRequestPrecheck(config);
1817
+ if (!precheck.valid) {
1818
+ throw new ApiError(precheck.message || "Invalid request payload");
1819
+ }
1820
+ return config;
1821
+ });
1822
+ let refreshPromise = null;
1823
+ const refreshTokenSingleFlight = async () => {
1824
+ if (!refreshPromise) {
1825
+ refreshPromise = getAccessToken(true).finally(() => {
1826
+ refreshPromise = null;
1827
+ });
1828
+ }
1829
+ return refreshPromise;
1830
+ };
1831
+ instance.interceptors.response.use(response => {
1832
+ // Any successful call means the session can act in this organisation
1833
+ // after all. Without this the gate, once raised, stayed up through a
1834
+ // whole working session — including the seconds between an administrator
1835
+ // adding somebody and the employee record existing.
1836
+ if (typeof window !== "undefined") {
1837
+ window.dispatchEvent(new CustomEvent("erp:employee-record-ok"));
1838
+ }
1839
+ return response;
1840
+ }, async (error) => {
1841
+ const originalRequest = error.config;
1842
+ const requestUrl = originalRequest?.url || error.config?.url;
1843
+ if (error.response?.status === 401 &&
1844
+ originalRequest &&
1845
+ !originalRequest._retry &&
1846
+ !isAuthEndpoint(requestUrl) &&
1847
+ !isAuthPage()) {
1848
+ originalRequest._retry = true;
1849
+ const newToken = await refreshTokenSingleFlight();
1850
+ if (newToken && originalRequest.headers) {
1851
+ originalRequest.headers.Authorization = `Bearer ${newToken}`;
1852
+ return instance(originalRequest);
1726
1853
  }
1854
+ await redirectToLogout();
1855
+ return Promise.reject(toApiError(error));
1727
1856
  }
1728
- return { matched: false, roles: [] };
1729
- }
1730
- /**
1731
- * Returns the required child-group segments for a page pathname.
1732
- *
1733
- * Prefer {@link resolveUiRoutePermission} when you need to distinguish
1734
- * "explicitly public (`[]`)" from "no rule (deny)". This helper returns
1735
- * `[]` for both cases and is kept for backward compatibility.
1736
- */
1737
- function getRequiredChildGroups(pathname, rulesMap) {
1738
- return resolveUiRoutePermission(pathname, rulesMap).roles;
1739
- }
1857
+ // The server refuses every organisation-scoped call when the account has
1858
+ // no employee record. Announce it once so a gate can explain it, instead
1859
+ // of letting each failed query raise its own toast.
1860
+ if (error.response?.status === 403) {
1861
+ const body = error.response.data;
1862
+ if (body?.code === "EMPLOYEE_RECORD_REQUIRED" && typeof window !== "undefined") {
1863
+ window.dispatchEvent(new CustomEvent("erp:employee-record-required"));
1864
+ }
1865
+ }
1866
+ return Promise.reject(toApiError(error));
1867
+ });
1868
+
1869
+ const hasPaginationShape = (payload) => {
1870
+ return ("total" in payload ||
1871
+ "page" in payload ||
1872
+ "per_page" in payload ||
1873
+ "total_pages" in payload ||
1874
+ "has_next" in payload ||
1875
+ "has_prev" in payload ||
1876
+ "pagination" in payload);
1877
+ };
1878
+ const isEnvelope = (payload) => {
1879
+ return ("success" in payload || "message" in payload) && !hasPaginationShape(payload);
1880
+ };
1881
+ const unwrapApiResponse = (payload) => {
1882
+ if (!payload || typeof payload !== "object") {
1883
+ return payload;
1884
+ }
1885
+ const record = payload;
1886
+ if ("data" in record &&
1887
+ record.data !== undefined &&
1888
+ isEnvelope(record)) {
1889
+ return record.data;
1890
+ }
1891
+ return payload;
1892
+ };
1740
1893
 
1741
- function DeniedCard() {
1742
- return (jsxRuntime.jsx(AccessDenied, { message: "\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07\u0E2B\u0E19\u0E49\u0E32\u0E19\u0E35\u0E49 \u0E2B\u0E32\u0E01\u0E04\u0E34\u0E14\u0E27\u0E48\u0E32\u0E04\u0E27\u0E23\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07\u0E44\u0E14\u0E49 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E1C\u0E39\u0E49\u0E14\u0E39\u0E41\u0E25\u0E23\u0E30\u0E1A\u0E1A\u0E02\u0E2D\u0E07\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" }));
1743
- }
1744
1894
  /**
1745
- * Permission gate for a page subtree. Looks up the current pathname in the
1746
- * permission rules map (live rules from `PermissionRulesProvider`, layered
1747
- * over the optional `fallbackRulesMap`), passes the resolved required roles
1748
- * into the host's `usePermission` probe, and renders one of:
1749
- *
1750
- * 1. rules / probe still loading → `null` on FIRST load only (let Suspense /
1751
- * loading.tsx show); once this subtree has been granted access, a later
1752
- * loading state keeps the children mounted — see the note below
1753
- * 2. no rule covers this path → deny (fail closed)
1754
- * 3. rule is explicit public (`[]`) → children
1755
- * 4. probe says no access → deny card
1756
- * 5. otherwise → children
1895
+ * The help channel lives on platform-api (POST /support/messages): every app
1896
+ * already calls it with the user's token and the organisation header, and
1897
+ * its CORS admits every app. The shared axios instance carries both and
1898
+ * refreshes a stale token.
1757
1899
  */
1758
- function PermissionProvider({ children, usePermission, fallbackRulesMap, }) {
1759
- const pathname = navigation.usePathname();
1760
- const { rulesMap, isLoading: rulesLoading } = usePermissionRules();
1761
- const mergedMap = React.useMemo(() => {
1762
- if (!fallbackRulesMap)
1763
- return rulesMap;
1764
- // live rules win over fallback when both define the same key
1765
- return { ...fallbackRulesMap, ...rulesMap };
1766
- }, [rulesMap, fallbackRulesMap]);
1767
- const lookup = React.useMemo(() => resolveUiRoutePermission(pathname, mergedMap), [pathname, mergedMap]);
1768
- // Hook must run unconditionally (rules of hooks).
1769
- const { hasPermission, isLoading } = usePermission({
1770
- requiredRoles: lookup.roles,
1771
- });
1772
- // Returning null unmounts the page. That is fine before anything has been
1773
- // shown, and destructive afterwards: next-auth flips its status back to
1774
- // "loading" on every session update() — one runs every 4 minutes — and a
1775
- // rules hook that reports loading on that transient took the page down with
1776
- // it. Users filling a long form watched everything they had typed vanish,
1777
- // which reads as a spontaneous page refresh.
1778
- //
1779
- // So a loading state only blanks the subtree until access has been granted
1780
- // once. After that the children stay mounted while the check re-settles; the
1781
- // API still enforces access on every request, and a genuine loss of
1782
- // permission surfaces as a denied response rather than a wiped form.
1783
- //
1784
- // The grant is remembered per pathname, not per mount. This provider lives in
1785
- // the layout, so it survives every in-app navigation — a bare boolean would
1786
- // carry one page's grant onto the next route and flash a page the user may
1787
- // not be allowed to see while its own check is still running.
1788
- const grantedForPathRef = React.useRef(null);
1789
- const grantedHere = grantedForPathRef.current === pathname;
1790
- if (rulesLoading && !grantedHere) {
1791
- return null;
1900
+ const DEFAULT_PLATFORM_API_URL = "https://platform-api.antniti.com/api/v1";
1901
+ function helpApiBase(app) {
1902
+ const explicit = process.env.NEXT_PUBLIC_PLATFORM_API_URL?.trim();
1903
+ if (explicit)
1904
+ return explicit.replace(/\/$/, "");
1905
+ // Platform's own API is platform-api.
1906
+ if (app === "platform") {
1907
+ const own = process.env.NEXT_PUBLIC_API_URL?.trim();
1908
+ if (own)
1909
+ return own.replace(/\/$/, "");
1910
+ }
1911
+ return DEFAULT_PLATFORM_API_URL;
1912
+ }
1913
+ /** A failure the dialog shows as is. */
1914
+ class HelpSendError extends Error {
1915
+ constructor(message, status) {
1916
+ super(message);
1917
+ Object.defineProperty(this, "status", {
1918
+ enumerable: true,
1919
+ configurable: true,
1920
+ writable: true,
1921
+ value: status
1922
+ });
1923
+ this.name = "HelpSendError";
1792
1924
  }
1793
- // Unknown route → deny. Only explicit `required_child_groups: []` is public.
1794
- if (!lookup.matched) {
1795
- return jsxRuntime.jsx(DeniedCard, {});
1925
+ }
1926
+ function friendly(err, fallback) {
1927
+ const e = toApiError(err);
1928
+ if (e.status === 429) {
1929
+ return new HelpSendError("ส่งบ่อยเกินไป กรุณาลองใหม่ภายหลัง", 429);
1796
1930
  }
1797
- if (lookup.roles.length === 0) {
1798
- grantedForPathRef.current = pathname;
1799
- return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
1931
+ if (e.status && e.status >= 400 && e.status < 500 && e.message && e.status !== 401 && e.status !== 404) {
1932
+ return new HelpSendError(e.message, e.status);
1800
1933
  }
1801
- if (isLoading && !grantedHere) {
1802
- return null;
1934
+ return new HelpSendError(fallback, e.status);
1935
+ }
1936
+ async function sendHelpMessage(input) {
1937
+ try {
1938
+ const res = await instance.post(`${helpApiBase(input.app)}/support/messages`, {
1939
+ kind: input.kind,
1940
+ message: input.message,
1941
+ app: input.app === "other" ? "" : input.app,
1942
+ context: input.context,
1943
+ });
1944
+ return unwrapApiResponse(res.data);
1803
1945
  }
1804
- if (!hasPermission && !isLoading) {
1805
- return jsxRuntime.jsx(DeniedCard, {});
1946
+ catch (err) {
1947
+ throw friendly(err, "ส่งไม่สำเร็จ");
1948
+ }
1949
+ }
1950
+ async function listMyHelpMessages(app) {
1951
+ try {
1952
+ const res = await instance.get(`${helpApiBase(app)}/support/messages`, {
1953
+ params: { limit: 20 },
1954
+ });
1955
+ const data = unwrapApiResponse(res.data);
1956
+ return data?.messages ?? [];
1957
+ }
1958
+ catch (err) {
1959
+ throw friendly(err, "โหลดข้อมูลไม่สำเร็จ");
1806
1960
  }
1807
- grantedForPathRef.current = pathname;
1808
- return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
1809
1961
  }
1810
1962
 
1811
- /** Cookie name for the cross-subdomain theme choice. */
1812
- const THEME_COOKIE = "erp-theme";
1963
+ const KINDS = [
1964
+ { value: "help", label: "ขอความช่วยเหลือ", placeholder: "ต้องการให้ช่วยเรื่องอะไร" },
1965
+ { value: "problem", label: "แจ้งปัญหา", placeholder: "เกิดอะไรขึ้น และกำลังทำอะไรอยู่" },
1966
+ { value: "feedback", label: "ข้อเสนอแนะ", placeholder: "อยากให้ปรับหรือเพิ่มอะไร" },
1967
+ ];
1968
+ const KIND_LABEL = Object.fromEntries(KINDS.map(k => [k.value, k.label]));
1969
+ const STATUS_LABEL = {
1970
+ new: "รอตอบ",
1971
+ answered: "ตอบแล้ว",
1972
+ closed: "ปิดแล้ว",
1973
+ };
1974
+ const STATUS_TONE = {
1975
+ new: "bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-200",
1976
+ answered: "bg-emerald-100 text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200",
1977
+ closed: "bg-muted text-muted-foreground",
1978
+ };
1979
+ const MAX_MESSAGE = 4000;
1980
+ function when(iso) {
1981
+ const d = new Date(iso);
1982
+ if (Number.isNaN(d.getTime()))
1983
+ return "";
1984
+ return d.toLocaleString("th-TH", {
1985
+ day: "numeric",
1986
+ month: "short",
1987
+ year: "numeric",
1988
+ hour: "2-digit",
1989
+ minute: "2-digit",
1990
+ hour12: false,
1991
+ });
1992
+ }
1813
1993
  /**
1814
- * Parent domain to scope the cookie to, so every `*.antniti.com` app reads the
1815
- * same value. Returns `null` on localhost / bare IPs (host-only cookie).
1994
+ * The ช่วยเหลือ dialog. AppLayoutShell mounts one; the top-bar button and
1995
+ * the user menu open it with openHelp(). It sends to platform-api, which
1996
+ * puts the message in the back office's queue on admin.antniti.com.
1816
1997
  */
1817
- function sharedCookieDomain() {
1818
- if (typeof window === "undefined")
1819
- return null;
1820
- const host = window.location.hostname;
1821
- if (host === "localhost" || /^[0-9.]+$/.test(host))
1822
- return null;
1823
- const parts = host.split(".");
1824
- if (parts.length < 2)
1825
- return null;
1826
- return "." + parts.slice(-2).join(".");
1827
- }
1828
- function readThemeCookie() {
1829
- if (typeof document === "undefined")
1830
- return null;
1831
- const match = document.cookie.match(new RegExp("(?:^|; )" + THEME_COOKIE + "=([^;]*)"));
1832
- return match ? decodeURIComponent(match[1]) : null;
1833
- }
1834
- function writeThemeCookie(value) {
1835
- if (typeof document === "undefined")
1836
- return;
1837
- const domain = sharedCookieDomain();
1838
- const parts = [
1839
- `${THEME_COOKIE}=${encodeURIComponent(value)}`,
1840
- "path=/",
1841
- "max-age=31536000", // 1 year
1842
- "SameSite=Lax",
1843
- ];
1844
- if (domain)
1845
- parts.push(`domain=${domain}`);
1846
- document.cookie = parts.join("; ");
1847
- }
1848
- /**
1849
- * Bridges next-themes (per-origin localStorage) with a cookie scoped to the
1850
- * parent domain, so the light/dark/system choice is shared across the
1851
- * accounting / HR / platform subdomains.
1852
- *
1853
- * Local storage wins when it already has a value (H57): adopting the shared
1854
- * cookie first used to overwrite an explicit choice — including one set via
1855
- * DevTools — and write `theme=light` back on every load whenever the cookie
1856
- * still said light. Push the local value into the cookie instead so sibling
1857
- * apps catch up.
1858
- */
1859
- function ThemeCookieSync() {
1860
- const { theme, setTheme } = nextThemes.useTheme();
1861
- const skipFirstWrite = React__namespace.useRef(true);
1998
+ function HelpDialogHost({ app, orgCode }) {
1999
+ const open = useHelpOpen();
2000
+ const { data: session } = react.useSession();
2001
+ const [view, setView] = React__namespace.useState("form");
2002
+ const [kind, setKind] = React__namespace.useState("help");
2003
+ const [message, setMessage] = React__namespace.useState("");
2004
+ const [sending, setSending] = React__namespace.useState(false);
2005
+ const [error, setError] = React__namespace.useState(null);
2006
+ const [sent, setSent] = React__namespace.useState(null);
2007
+ const [mine, setMine] = React__namespace.useState(null);
2008
+ const [mineError, setMineError] = React__namespace.useState(null);
2009
+ React__namespace.useEffect(() => registerHelpHost(), []);
2010
+ // Read when the dialog opens: the page and the window as they are now.
2011
+ // open is a dependency on purpose: the app is read again each time.
2012
+ const appId = React__namespace.useMemo(() => detectHelpApp(app), [app, open]);
2013
+ const context = React__namespace.useMemo(() => (open ? collectHelpContext(orgCode) : null), [open, orgCode]);
1862
2014
  React__namespace.useEffect(() => {
1863
- const local = typeof window !== "undefined"
1864
- ? window.localStorage.getItem("theme")
1865
- : null;
1866
- const cookie = readThemeCookie();
1867
- if (local) {
1868
- if (local !== theme)
1869
- setTheme(local);
1870
- writeThemeCookie(local);
2015
+ if (open) {
2016
+ setView("form");
2017
+ setError(null);
1871
2018
  }
1872
- else if (cookie) {
1873
- if (cookie !== theme)
1874
- setTheme(cookie);
2019
+ }, [open]);
2020
+ const loadMine = React__namespace.useCallback(() => {
2021
+ setView("mine");
2022
+ setMine(null);
2023
+ setMineError(null);
2024
+ listMyHelpMessages(appId)
2025
+ .then(setMine)
2026
+ .catch((e) => setMineError(e instanceof Error ? e.message : "โหลดข้อมูลไม่สำเร็จ"));
2027
+ }, [appId]);
2028
+ const trimmed = message.trim();
2029
+ const canSend = trimmed.length > 0 && !sending;
2030
+ const send = async (e) => {
2031
+ e?.preventDefault();
2032
+ if (!canSend || !context)
2033
+ return;
2034
+ setSending(true);
2035
+ setError(null);
2036
+ try {
2037
+ const res = await sendHelpMessage({ app: appId, kind, message: trimmed, context });
2038
+ setSent(res);
2039
+ setMessage("");
2040
+ setKind("help");
2041
+ setView("sent");
1875
2042
  }
1876
- else if (theme) {
1877
- writeThemeCookie(theme);
2043
+ catch (err) {
2044
+ setError(err instanceof Error ? err.message : "ส่งไม่สำเร็จ");
1878
2045
  }
1879
- // Run once on mount — `theme` is intentionally not a dependency here.
1880
- // eslint-disable-next-line react-hooks/exhaustive-deps
1881
- }, []);
1882
- React__namespace.useEffect(() => {
1883
- // Skip the initial render; only persist genuine changes (incl. the
1884
- // setTheme() triggered by adopting storage above — harmless re-write).
1885
- if (skipFirstWrite.current) {
1886
- skipFirstWrite.current = false;
1887
- return;
2046
+ finally {
2047
+ setSending(false);
1888
2048
  }
1889
- if (theme)
1890
- writeThemeCookie(theme);
1891
- }, [theme]);
1892
- return null;
1893
- }
1894
- function ThemeProvider({ children, ...props }) {
1895
- return (jsxRuntime.jsxs(nextThemes.ThemeProvider, { ...props, children: [jsxRuntime.jsx(ThemeCookieSync, {}), children] }));
1896
- }
1897
-
1898
- const defaultFlags = {
1899
- enableAdvancedReporting: true,
1900
- enableMultiCurrency: false,
1901
- enableThaiTaxCalculation: true,
1902
- enableProcurementWorkflow: true,
1903
- enableInventoryTracking: true,
1904
- enableAuditTrail: true,
1905
- enableAdvancedFilter: true,
1906
- filterFlag: false,
1907
- setFilterFlag: () => { },
1908
- };
1909
- const FeatureFlagsContext = React.createContext(defaultFlags);
1910
- function FeatureFlagsProvider({ children, flags = {}, }) {
1911
- const [filterFlag, setFilterFlag] = React.useState(false);
1912
- const mergedFlags = {
1913
- ...defaultFlags,
1914
- ...flags,
1915
- filterFlag: flags.filterFlag ?? filterFlag,
1916
- setFilterFlag: (flag) => {
1917
- setFilterFlag(flag);
1918
- if (typeof flags.setFilterFlag === "function") {
1919
- flags.setFilterFlag(flag);
1920
- }
1921
- },
1922
2049
  };
1923
- return (jsxRuntime.jsx(FeatureFlagsContext.Provider, { value: mergedFlags, children: children }));
1924
- }
1925
- function useFeatureFlags() {
1926
- const context = React.useContext(FeatureFlagsContext);
1927
- if (!context) {
1928
- throw new Error("useFeatureFlags must be used within a FeatureFlagsProvider");
1929
- }
1930
- return context;
1931
- }
1932
- function useFeatureFlag(flag) {
1933
- const flags = useFeatureFlags();
1934
- return flags[flag];
1935
- }
1936
-
1937
- const getAxiosErrorMessage$1 = (error) => {
1938
- if (error.response?.data?.message) {
1939
- return error.response.data.message;
1940
- }
1941
- if (error.message) {
1942
- return error.message;
1943
- }
1944
- return "An unknown error occurred";
1945
- };
1946
- const formatError = (handlerName, error) => {
1947
- const axiosError = error;
1948
- const message = getAxiosErrorMessage$1(axiosError);
1949
- const isOrgRequired = message.includes("Organization context is required") ||
1950
- message.includes("organization_id") ||
1951
- message.includes("X-Organization-ID");
1952
- if (isOrgRequired) {
1953
- // Expected when no org selected yet (e.g. select-organization page).
1954
- console.warn(`[React Query] ${handlerName} skipped (no organization context): ${message}`);
1955
- return;
1956
- }
1957
- };
1958
- /**
1959
- * Default React Query config used across ERP apps:
1960
- * - 2 retries on queries / 1 on mutations, exponential backoff
1961
- * - 5 min staleTime, refetchOnWindowFocus
1962
- * - swallows "no org context" errors to silence noise on the
1963
- * select-organization page
1964
- *
1965
- * Apps can compose on top of this — pass the returned config to `new QueryClient(...)`
1966
- * directly or spread + override default options.
1967
- */
1968
- const getQueryClientConfig = () => ({
1969
- defaultOptions: {
1970
- queries: {
1971
- retry: 2,
1972
- retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
1973
- staleTime: 5 * 60 * 1000,
1974
- refetchOnWindowFocus: true,
1975
- },
1976
- mutations: {
1977
- // NEVER auto-retry a mutation. Unlike a query, a mutation is a side
1978
- // effect on a non-idempotent endpoint (submit, create, approve), and
1979
- // React Query cannot tell "the network dropped" from "the server
1980
- // rejected this on business grounds".
1981
- //
1982
- // With retry: 1 every failed mutation fired twice. Two visible
1983
- // consequences: a rejected submit showed up as two failed POSTs in the
1984
- // network tab, and — far worse — a create whose response was lost in
1985
- // flight would be re-sent and duplicate the record server-side.
1986
- //
1987
- // Retrying is the caller's decision, not a global default: the user
1988
- // sees the error and presses the button again.
1989
- retry: 0,
1990
- },
1991
- },
1992
- queryCache: new reactQuery.QueryCache({
1993
- onError: error => formatError("Query", error),
1994
- }),
1995
- mutationCache: new reactQuery.MutationCache({
1996
- onError: error => formatError("Mutation", error),
1997
- }),
1998
- });
1999
-
2000
- function ReactQueryProvider({ children }) {
2001
- // Create a new QueryClient instance for each render to avoid SSR issues.
2002
- const [queryClient] = React.useState(() => new reactQuery.QueryClient(getQueryClientConfig()));
2003
- return (jsxRuntime.jsxs(reactQuery.QueryClientProvider, { client: queryClient, children: [children, jsxRuntime.jsx(reactQueryDevtools.ReactQueryDevtools, { client: queryClient, initialIsOpen: false })] }));
2050
+ const email = session?.user?.email ?? null;
2051
+ const placeholder = KINDS.find(k => k.value === kind)?.placeholder;
2052
+ return (jsxRuntime.jsx(erpTheme.Dialog, { open: open, onOpenChange: o => (o ? openHelp() : closeHelp()), children: jsxRuntime.jsxs(erpTheme.DialogContent, { className: "max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-lg", children: [view === "form" ? (jsxRuntime.jsxs("form", { onSubmit: send, className: "grid gap-4", children: [jsxRuntime.jsx(erpTheme.DialogHeader, { children: jsxRuntime.jsx(erpTheme.DialogTitle, { children: "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D" }) }), jsxRuntime.jsxs("div", { className: "grid gap-2", children: [jsxRuntime.jsx(erpTheme.Label, { id: "erp-help-kind", children: "\u0E1B\u0E23\u0E30\u0E40\u0E20\u0E17" }), jsxRuntime.jsx("div", { role: "radiogroup", "aria-labelledby": "erp-help-kind", className: "flex gap-1 rounded-lg bg-muted p-1", children: KINDS.map(k => (jsxRuntime.jsx("button", { type: "button", role: "radio", "aria-checked": kind === k.value, onClick: () => setKind(k.value), className: "flex-auto whitespace-nowrap rounded-md px-2 py-2 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:text-sm " +
2053
+ (kind === k.value
2054
+ ? "bg-background text-foreground shadow-sm"
2055
+ : "text-muted-foreground hover:text-foreground"), children: k.label }, k.value))) })] }), jsxRuntime.jsxs("div", { className: "grid gap-2", children: [jsxRuntime.jsx(erpTheme.Label, { htmlFor: "erp-help-message", children: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21" }), jsxRuntime.jsx(erpTheme.Textarea, { id: "erp-help-message", rows: 6, maxLength: MAX_MESSAGE, required: true, value: message, placeholder: placeholder, onChange: e => setMessage(e.target.value), className: "min-h-[140px] resize-y" })] }), context ? (jsxRuntime.jsxs("p", { className: "break-words text-xs leading-relaxed text-muted-foreground", children: ["\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E17\u0E35\u0E48\u0E41\u0E19\u0E1A: ", describeAttached(appId, context, email)] })) : null, error ? (jsxRuntime.jsx("p", { role: "alert", className: "text-sm text-destructive", children: error })) : null, jsxRuntime.jsxs(erpTheme.DialogFooter, { className: "flex-row items-center gap-2 sm:justify-between", children: [jsxRuntime.jsx("button", { type: "button", onClick: loadMine, className: "mr-auto text-sm text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded", children: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E17\u0E35\u0E48\u0E2A\u0E48\u0E07\u0E41\u0E25\u0E49\u0E27" }), jsxRuntime.jsxs("div", { className: "flex gap-2", children: [jsxRuntime.jsx(erpTheme.Button, { type: "button", variant: "outline", onClick: closeHelp, children: "\u0E22\u0E01\u0E40\u0E25\u0E34\u0E01" }), jsxRuntime.jsxs(erpTheme.Button, { type: "submit", disabled: !canSend, children: [sending ? jsxRuntime.jsx(lucideReact.Loader2, { className: "mr-1 h-4 w-4 animate-spin" }) : null, "\u0E2A\u0E48\u0E07"] })] })] })] })) : null, view === "sent" && sent ? (jsxRuntime.jsxs("div", { className: "grid gap-4", children: [jsxRuntime.jsxs("div", { className: "flex flex-col items-center gap-2 py-4 text-center", children: [jsxRuntime.jsx(lucideReact.CircleCheck, { className: "h-12 w-12 text-primary", strokeWidth: 1.6 }), jsxRuntime.jsx(erpTheme.DialogTitle, { className: "text-lg font-semibold", children: "\u0E2A\u0E48\u0E07\u0E41\u0E25\u0E49\u0E27" }), jsxRuntime.jsxs("p", { className: "text-sm text-muted-foreground", children: ["\u0E40\u0E25\u0E02\u0E17\u0E35\u0E48\u0E2D\u0E49\u0E32\u0E07\u0E2D\u0E34\u0E07", " ", jsxRuntime.jsx("span", { className: "font-mono text-base font-semibold text-foreground", children: sent.ref })] })] }), jsxRuntime.jsxs(erpTheme.DialogFooter, { className: "flex-row items-center gap-2 sm:justify-between", children: [jsxRuntime.jsx("button", { type: "button", onClick: loadMine, className: "mr-auto text-sm text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded", children: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E17\u0E35\u0E48\u0E2A\u0E48\u0E07\u0E41\u0E25\u0E49\u0E27" }), jsxRuntime.jsx(erpTheme.Button, { type: "button", onClick: closeHelp, children: "\u0E1B\u0E34\u0E14" })] })] })) : null, view === "mine" ? (jsxRuntime.jsxs("div", { className: "grid gap-4", children: [jsxRuntime.jsx(erpTheme.DialogHeader, { children: jsxRuntime.jsxs(erpTheme.DialogTitle, { className: "flex items-center gap-2", children: [jsxRuntime.jsx("button", { type: "button", "aria-label": "\u0E01\u0E25\u0E31\u0E1A", onClick: () => setView("form"), className: "-ml-1 grid size-8 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", children: jsxRuntime.jsx(lucideReact.ArrowLeft, { className: "h-4 w-4" }) }), "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E17\u0E35\u0E48\u0E2A\u0E48\u0E07\u0E41\u0E25\u0E49\u0E27"] }) }), mine === null && !mineError ? (jsxRuntime.jsxs("div", { className: "flex items-center gap-2 py-6 text-sm text-muted-foreground", children: [jsxRuntime.jsx(lucideReact.Loader2, { className: "h-4 w-4 animate-spin" }), "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14"] })) : null, mineError ? (jsxRuntime.jsx("p", { role: "alert", className: "text-sm text-destructive", children: mineError })) : null, mine && mine.length === 0 ? (jsxRuntime.jsx("p", { className: "py-6 text-sm text-muted-foreground", children: "\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21" })) : null, mine && mine.length > 0 ? (jsxRuntime.jsx("ul", { className: "grid gap-3", children: mine.map(m => (jsxRuntime.jsxs("li", { className: "rounded-lg border border-border p-3", children: [jsxRuntime.jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground", children: [jsxRuntime.jsx("span", { className: "font-mono font-semibold text-foreground", children: m.ref }), jsxRuntime.jsx("span", { children: KIND_LABEL[m.kind] ?? m.kind }), jsxRuntime.jsx("span", { children: when(m.created_at) }), jsxRuntime.jsx("span", { className: `ml-auto rounded-full px-2 py-0.5 font-medium ${STATUS_TONE[m.status] ?? ""}`, children: STATUS_LABEL[m.status] ?? m.status })] }), jsxRuntime.jsx("p", { className: "mt-2 line-clamp-4 whitespace-pre-wrap break-words text-sm", children: m.message }), m.replies.map((r, i) => (jsxRuntime.jsxs("div", { className: "mt-2 rounded-md bg-muted px-3 py-2", children: [jsxRuntime.jsxs("p", { className: "text-xs text-muted-foreground", children: ["\u0E17\u0E35\u0E21 AntNiti \u00B7 ", when(r.created_at)] }), jsxRuntime.jsx("p", { className: "mt-1 whitespace-pre-wrap break-words text-sm", children: r.body })] }, i)))] }, m.id))) })) : null] })) : null] }) }));
2004
2056
  }
2005
2057
 
2006
2058
  /**
2007
- * Renders nothing. Runs during render (after SessionProvider, inside
2008
- * QueryClientProvider) to seed the cached profile slot from the JWT-backed
2009
- * session — so `useMyProfile` doesn't fire a redundant GET on initial load.
2059
+ * Top-level app chrome: sticky header, sidebar slot, content gutter and the
2060
+ * session/org-context plumbing that both Accounting and HR need. Apps wrap
2061
+ * it inside their `SidebarProvider` (server-rendered with the cookie-backed
2062
+ * `defaultOpen`) and their `ERPProvider`, then pass app-specific bits as
2063
+ * props.
2010
2064
  *
2011
- * Generic over the profile shape; each app passes its own queryKey + mapper.
2065
+ * The padding contract is deliberately owned here so future style tweaks
2066
+ * (e.g. the recent left/right/bottom gutter pass) propagate to both apps via
2067
+ * an erp-shell bump — domain components must NOT add their own root padding
2068
+ * or content gets indented twice.
2012
2069
  */
2013
- function SessionBackedProfileCacheSeeder({ queryKey, mapper, }) {
2070
+ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permissionProvider, organizationId, setOrganizationId, organizations, signInUrl, selectOrganizationUrl, chrome = "brand", workspaceHeader = "brand", headerEnd, mobileBrand, showHelp = true, helpApp, }) {
2014
2071
  const { data: session, status } = react.useSession();
2015
- const queryClient = reactQuery.useQueryClient();
2016
- if (status === "authenticated" && session) {
2017
- const mapped = mapper(session);
2018
- if (mapped) {
2019
- const existing = queryClient.getQueryData(queryKey);
2020
- if (!existing) {
2021
- // updatedAt: 0 → immediately stale so observers still GET /myprofile
2022
- // for fields the JWT seed omits (avatar/signature paths).
2023
- queryClient.setQueryData(queryKey, mapped, { updatedAt: 0 });
2024
- }
2025
- else {
2026
- // Only overwrite when the mapped id changed — we don't know T's shape
2027
- // so we rely on a shallow id check via JSON tag if present.
2028
- const existingId = existing?.id;
2029
- const mappedId = mapped?.id;
2030
- if (existingId !== mappedId) {
2031
- queryClient.setQueryData(queryKey, mapped, { updatedAt: 0 });
2032
- }
2033
- }
2072
+ const { open, setOpen } = useSidebar();
2073
+ const router = navigation.useRouter();
2074
+ const params = navigation.useParams();
2075
+ const code = params?.code;
2076
+ const extendedSession = session;
2077
+ // Track once-authenticated so a transient accessToken drop during the
2078
+ // periodic session refresh (SessionRefreshTrigger every 4 min) doesn't
2079
+ // blank the whole page. The initial-load gate below still handles cold
2080
+ // start; once we've seen a real token we render children optimistically
2081
+ // and let the API layer surface a real 401 if the token actually died.
2082
+ const hasSeenAccessTokenRef = React.useRef(false);
2083
+ React.useEffect(() => {
2084
+ if (extendedSession?.accessToken)
2085
+ hasSeenAccessTokenRef.current = true;
2086
+ }, [extendedSession?.accessToken]);
2087
+ // Find the org from the URL `[code]` so we can detect a context mismatch
2088
+ // (the user bookmarked a different org than the cookie remembers).
2089
+ const orgFromCode = React__namespace.useMemo(() => {
2090
+ if (!code || !organizations.length)
2091
+ return null;
2092
+ return (organizations.find(org => org.code === code || String(org.id) === code) ?? null);
2093
+ }, [code, organizations]);
2094
+ // Sync state: when the URL code points at a different org than the cookie,
2095
+ // trust the URL and update the cookie. Otherwise do nothing — the user
2096
+ // wins ties via the in-app org switcher.
2097
+ React.useEffect(() => {
2098
+ if (!code)
2099
+ return;
2100
+ if (!organizations.length)
2101
+ return;
2102
+ if (!orgFromCode)
2103
+ return;
2104
+ if (String(organizationId) !== String(orgFromCode.id)) {
2105
+ void setOrganizationId(orgFromCode.id);
2034
2106
  }
2107
+ }, [
2108
+ orgFromCode,
2109
+ organizationId,
2110
+ setOrganizationId,
2111
+ code,
2112
+ organizations.length,
2113
+ ]);
2114
+ // Key for the content subtree. It exists so switching organizations throws
2115
+ // away the previous org's component state — but it must react ONLY to a real
2116
+ // switch. organizationId legitimately passes through null while the context
2117
+ // re-settles (session resolving, cookie write in flight), and keying on that
2118
+ // unmounted and remounted the entire page: forms lost their loaded values,
2119
+ // in-flight edits vanished, and settings pickers snapped back to defaults.
2120
+ //
2121
+ // Holding the last known id makes a transient null a no-op while a genuine
2122
+ // org-to-org switch still changes the key.
2123
+ const lastOrgKeyRef = React__namespace.useRef("no-org");
2124
+ const currentOrgKey = organizationId === null ||
2125
+ organizationId === undefined ||
2126
+ String(organizationId).trim() === ""
2127
+ ? null
2128
+ : String(organizationId);
2129
+ if (currentOrgKey !== null) {
2130
+ lastOrgKeyRef.current = currentOrgKey;
2035
2131
  }
2036
- if (status === "unauthenticated") {
2037
- queryClient.removeQueries({ queryKey });
2038
- }
2039
- return null;
2040
- }
2041
-
2042
- const TOKEN_TTL_MS = 30000;
2043
- let cachedToken = null;
2044
- let cachedAt = 0;
2045
- let inFlightTokenPromise = null;
2046
- const readSessionToken = async () => {
2047
- const response = await fetch("/api/auth/session", {
2048
- credentials: "include",
2049
- cache: "no-store",
2050
- });
2051
- if (!response.ok)
2052
- return null;
2053
- const session = (await response.json());
2054
- return session.accessToken ?? null;
2055
- };
2056
- const getAccessToken = async (forceRefresh = false) => {
2057
- const now = Date.now();
2058
- if (!forceRefresh && cachedToken && now - cachedAt < TOKEN_TTL_MS) {
2059
- return cachedToken;
2060
- }
2061
- if (!inFlightTokenPromise) {
2062
- inFlightTokenPromise = readSessionToken()
2063
- .then(token => {
2064
- cachedToken = token;
2065
- cachedAt = Date.now();
2066
- return token;
2067
- })
2068
- .finally(() => {
2069
- inFlightTokenPromise = null;
2070
- });
2132
+ const contentOrgKey = lastOrgKeyRef.current;
2133
+ // Hold the page back until the active organisation is the one the URL names.
2134
+ //
2135
+ // The sync effect above writes the cookie through a server action, which is
2136
+ // a round trip. Until it lands, getOrgFromCookie() still answers with the
2137
+ // previous organisation and every request the page fires on mount carries
2138
+ // it — so /O26000021/work-record asked about O26000033 and was correctly
2139
+ // told there was nothing there. An empty table is the mild version: the
2140
+ // create form posts to the same header, so a record could be filed against
2141
+ // a company the user was not looking at.
2142
+ //
2143
+ // Only while the two genuinely disagree. A code naming no organisation of
2144
+ // the caller's is left to the app to refuse, and a page with no code in its
2145
+ // path is unaffected.
2146
+ const orgContextIsStale = !!orgFromCode &&
2147
+ organizationId !== null &&
2148
+ organizationId !== undefined &&
2149
+ String(organizationId) !== String(orgFromCode.id);
2150
+ // ≤1024px: sidebar renders as a modal overlay (backdrop dims content). >1024px:
2151
+ // sidebar is docked and the content gets a left margin to clear it.
2152
+ const [isSidebarModalLayout, setIsSidebarModalLayout] = React__namespace.useState(false);
2153
+ React.useEffect(() => {
2154
+ const mq = window.matchMedia("(max-width: 1024px)");
2155
+ const sync = () => setIsSidebarModalLayout(mq.matches);
2156
+ sync();
2157
+ mq.addEventListener("change", sync);
2158
+ return () => mq.removeEventListener("change", sync);
2159
+ }, []);
2160
+ // Help opened from the drawer's user menu: the drawer steps aside so the
2161
+ // dialog is the only thing on a phone's screen.
2162
+ const helpOpen = useHelpOpen();
2163
+ React.useEffect(() => {
2164
+ if (helpOpen && isSidebarModalLayout && open)
2165
+ setOpen(false);
2166
+ }, [helpOpen, isSidebarModalLayout, open, setOpen]);
2167
+ // Wait for session to load (initial load only). When refetching/updating we
2168
+ // already have a session — don't flash the full-screen loader.
2169
+ if (status === "loading" && !session) {
2170
+ return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent });
2071
2171
  }
2072
- return inFlightTokenPromise;
2073
- };
2074
- const clearAccessTokenCache = () => {
2075
- cachedToken = null;
2076
- cachedAt = 0;
2077
- };
2078
-
2079
- class ApiError extends Error {
2080
- constructor(message, options) {
2081
- super(message);
2082
- Object.defineProperty(this, "status", {
2083
- enumerable: true,
2084
- configurable: true,
2085
- writable: true,
2086
- value: void 0
2087
- });
2088
- Object.defineProperty(this, "code", {
2089
- enumerable: true,
2090
- configurable: true,
2091
- writable: true,
2092
- value: void 0
2093
- });
2094
- Object.defineProperty(this, "field", {
2095
- enumerable: true,
2096
- configurable: true,
2097
- writable: true,
2098
- value: void 0
2099
- });
2100
- Object.defineProperty(this, "details", {
2101
- enumerable: true,
2102
- configurable: true,
2103
- writable: true,
2104
- value: void 0
2105
- });
2106
- this.name = "ApiError";
2107
- this.status = options?.status;
2108
- this.code = options?.code;
2109
- this.field = options?.field;
2110
- this.details = options?.details;
2172
+ // Session loaded but no accessToken AND we've never seen one → real auth
2173
+ // failure (token refresh gave nothing back on cold start), bounce through
2174
+ // signin. If we HAVE seen one before, the drop is almost certainly the
2175
+ // in-flight periodic refresh (SessionRefreshTrigger). Don't redirect on
2176
+ // that transient — the JWT callback will settle in a moment and API
2177
+ // calls will keep working with the still-valid cookie.
2178
+ if (status === "authenticated" &&
2179
+ !extendedSession?.accessToken &&
2180
+ !hasSeenAccessTokenRef.current) {
2181
+ if (typeof window !== "undefined") {
2182
+ const url = new URL(signInUrl, window.location.origin);
2183
+ url.searchParams.set("error", "NoAccessToken");
2184
+ url.searchParams.set("callbackUrl", window.location.pathname);
2185
+ window.location.href = url.toString();
2186
+ }
2187
+ return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent });
2188
+ }
2189
+ // Only blank the page when we've never seen a token — otherwise the
2190
+ // periodic session refresh would visibly flash the loader every 4 min.
2191
+ if (!extendedSession?.accessToken && !hasSeenAccessTokenRef.current) {
2192
+ return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent });
2111
2193
  }
2194
+ if (!code) {
2195
+ router.push(selectOrganizationUrl);
2196
+ return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent });
2197
+ }
2198
+ // When the sidebar is docked-open it consumes 21.5rem on the left; otherwise
2199
+ // the content takes the full width and we add a left gutter inside.
2200
+ const workspace = chrome === "workspace";
2201
+ const contentMargin = open && !isSidebarModalLayout
2202
+ ? workspace
2203
+ ? "ml-[244px]"
2204
+ : "ml-[21.5rem]"
2205
+ : "";
2206
+ const needsLeftGutter = workspace || !(open && !isSidebarModalLayout);
2207
+ const wrap = permissionProvider ?? ((c) => c);
2208
+ 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" &&
2209
+ "border-b border-border bg-card/85 backdrop-blur-sm"), style: workspaceHeader === "light"
2210
+ ? WORKSPACE_LIGHT_HEADER_VARS
2211
+ : WORKSPACE_HEADER_VARS, children: [workspaceHeader === "light" ? null : (jsxRuntime.jsx(HeaderBackground, { className: "rounded-none" })), jsxRuntime.jsxs("header", { className: cx("relative z-10 flex w-full items-center gap-3 text-[var(--header-fg)]", workspaceHeader === "light"
2212
+ ? "min-h-[66px] px-3 py-2 sm:min-h-[72px] sm:px-[22px] lg:min-h-[82px] lg:px-8"
2213
+ : "min-h-[72px] px-[18px] py-3 sm:px-6 lg:min-h-[88px] lg:px-10"), children: [isSidebarModalLayout ? (
2214
+ // As in the design: the mark, then a menu button, and the
2215
+ // organisation on the right — no trail, no title, so the
2216
+ // organisation's name has the room it needs on a phone.
2217
+ 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
2218
+ ? "relative z-10 px-[18px] pt-[26px] pb-4 sm:px-6 lg:px-10 lg:pt-[38px]"
2219
+ : 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] }));
2112
2220
  }
2113
- const toApiError = (error) => {
2114
- if (error instanceof ApiError)
2115
- return error;
2116
- if (axios.isAxiosError(error)) {
2117
- const axiosErr = error;
2118
- const message = axiosErr.response?.data?.message ||
2119
- axiosErr.response?.data?.error ||
2120
- axiosErr.message ||
2121
- "Request failed";
2122
- return new ApiError(message, {
2123
- status: axiosErr.response?.status,
2124
- code: axiosErr.response?.data?.code,
2125
- field: axiosErr.response?.data?.field,
2126
- details: axiosErr.response?.data?.details,
2127
- });
2221
+ /** Local tiny classnames helper — avoids pulling clsx/tailwind-merge into
2222
+ * the shell just for the layout file. Falsy values are skipped. */
2223
+ function cx(...parts) {
2224
+ return parts.filter(Boolean).join(" ");
2225
+ }
2226
+
2227
+ /**
2228
+ * The product mark at the top of the workspace sidebar: the app icon, the
2229
+ * name, and a spaced caption beneath it.
2230
+ */
2231
+ function WorkspaceBrand({ name, caption, mark, href, compact = false, className, }) {
2232
+ const body = (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("span", { className: erpTheme.cn("grid shrink-0 place-items-center overflow-hidden rounded-[14px] [&>img]:size-full [&>img]:object-cover", compact ? "size-9 rounded-[11px]" : "size-11"), children: mark }), compact ? (jsxRuntime.jsx("span", { className: "sr-only", children: name })) : (jsxRuntime.jsxs("span", { className: "min-w-0", children: [jsxRuntime.jsx("span", { className: "block truncate text-[25px] leading-tight font-extrabold tracking-[-0.05em]", children: name }), caption ? (jsxRuntime.jsx("small", { className: "-mt-0.5 block truncate text-[9px] font-semibold tracking-[0.29em] text-muted-foreground", children: caption })) : null] }))] }));
2233
+ const cls = erpTheme.cn("flex items-center gap-[11px] rounded-lg focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", !compact && "px-4", className);
2234
+ return href ? (jsxRuntime.jsx(Link, { href: href, className: cls, "aria-label": compact ? name : undefined, children: body })) : (jsxRuntime.jsx("div", { className: cls, children: body }));
2235
+ }
2236
+
2237
+ function isExternal(href) {
2238
+ return /^https?:\/\//i.test(href);
2239
+ }
2240
+ /**
2241
+ * The card at the foot of the workspace sidebar that points someone who
2242
+ * does not know where to start at the place that will tell them.
2243
+ */
2244
+ function SidebarHelpCard({ title, description, actionLabel, href, icon, className, }) {
2245
+ const external = isExternal(href);
2246
+ const action = (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [actionLabel, jsxRuntime.jsx(lucideReact.ArrowRight, { className: "size-[15px]" })] }));
2247
+ const actionClass = "mt-3 flex w-full items-center justify-between text-xs font-semibold text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded";
2248
+ return (jsxRuntime.jsxs("div", { className: erpTheme.cn("mx-1.5 mb-5 rounded-[13px] border border-primary/10 bg-gradient-to-br from-primary/[0.06] to-primary/[0.02] px-3.5 py-4", className), children: [jsxRuntime.jsx("span", { className: "mb-[7px] block text-primary/70", children: icon ?? jsxRuntime.jsx(lucideReact.Compass, { className: "size-6", strokeWidth: 1.6 }) }), jsxRuntime.jsx("strong", { className: "block text-xs font-semibold", children: title }), description ? (jsxRuntime.jsx("p", { className: "mt-[7px] text-xs leading-[1.7] text-muted-foreground", children: description })) : null, external ? (jsxRuntime.jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: actionClass, children: action })) : (jsxRuntime.jsx(Link, { href: href, className: actionClass, children: action }))] }));
2249
+ }
2250
+ /** A quiet row under the help card — the manual, the support chat. */
2251
+ function SidebarHelpLink({ label, href, icon, className }) {
2252
+ const external = isExternal(href);
2253
+ const cls = erpTheme.cn("flex w-full items-center gap-3 rounded-[10px] px-3.5 py-[11px] text-xs font-medium text-sidebar-foreground/70 transition-colors hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", className);
2254
+ const body = (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [icon ? jsxRuntime.jsx("span", { className: "shrink-0 [&>svg]:size-[18px]", children: icon }) : null, jsxRuntime.jsx("span", { className: "flex-1 truncate", children: label }), external ? jsxRuntime.jsx(lucideReact.ArrowUpRight, { className: "size-4 shrink-0" }) : null] }));
2255
+ 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 }));
2256
+ }
2257
+
2258
+ /**
2259
+ * Central "no access" screen shared by every policy check (route-level
2260
+ * PermissionProvider and page-level PermissionGateShell). One look everywhere:
2261
+ * an illustration, a 403 headline, the page-specific reason, and recovery
2262
+ * actions.
2263
+ */
2264
+ function AccessDenied({ title = "ขออภัย, คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (403)", message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ ขออภัยในความไม่สะดวก", homeHref, homeLabel = "กลับไปหน้าหลัก", supportHref, supportLabel = "ติดต่อฝ่ายสนับสนุน", fullScreen = true, className, }) {
2265
+ // "/" is not the home page inside an app — it bounces to organization
2266
+ // selection, so the button read "กลับไปหน้าหลัก" and dropped the user into a
2267
+ // picker for the org they were already in. Send them to that org's home.
2268
+ const params = navigation.useParams();
2269
+ const code = typeof params?.code === "string" ? params.code : "";
2270
+ const homeTarget = homeHref ?? (code ? `/${code}` : "/");
2271
+ const go = (href) => {
2272
+ if (typeof window !== "undefined")
2273
+ window.location.href = href;
2274
+ };
2275
+ const wrapperClass = [
2276
+ "flex min-w-0 flex-1 items-center justify-center p-4",
2277
+ fullScreen ? "min-h-[70vh]" : "",
2278
+ className ?? "",
2279
+ ]
2280
+ .filter(Boolean)
2281
+ .join(" ");
2282
+ 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] })] })] }) }) }));
2283
+ }
2284
+
2285
+ const EMPLOYEE_RECORD_REQUIRED = "EMPLOYEE_RECORD_REQUIRED";
2286
+ const EMPLOYEE_RECORD_REQUIRED_EVENT = "erp:employee-record-required";
2287
+ const EMPLOYEE_RECORD_OK_EVENT = "erp:employee-record-ok";
2288
+ /**
2289
+ * Shown when the API refuses every request because the signed-in account has
2290
+ * no employee record in this organisation.
2291
+ *
2292
+ * A gate rather than a banner. Leave, attendance, approvals and payslips all
2293
+ * identify a person by that record, so once the server started enforcing it
2294
+ * every call fails — and without something that says so, the app becomes a
2295
+ * screen of error toasts with no explanation and no way forward.
2296
+ */
2297
+ function EmployeeRequiredGate({ platformBaseUrl, }) {
2298
+ const [blocked, setBlocked] = React.useState(false);
2299
+ const params = navigation.useParams();
2300
+ const code = params?.code ?? "";
2301
+ React.useEffect(() => {
2302
+ const onBlocked = () => setBlocked(true);
2303
+ const onOk = () => setBlocked(false);
2304
+ window.addEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, onBlocked);
2305
+ window.addEventListener(EMPLOYEE_RECORD_OK_EVENT, onOk);
2306
+ return () => {
2307
+ window.removeEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, onBlocked);
2308
+ window.removeEventListener(EMPLOYEE_RECORD_OK_EVENT, onOk);
2309
+ };
2310
+ }, []);
2311
+ if (!blocked)
2312
+ return null;
2313
+ const base = platformBaseUrl?.replace(/\/$/, "") ?? "";
2314
+ const createHref = code ? `${base}/${code}/employee/create` : `${base}/`;
2315
+ return (jsxRuntime.jsx("div", { className: "fixed inset-0 z-[100] flex items-center justify-center bg-background/95 p-6 backdrop-blur-sm", children: jsxRuntime.jsxs("div", { className: "w-full max-w-md space-y-5 text-center", children: [jsxRuntime.jsx(lucideReact.ContactRound, { className: "mx-auto h-12 w-12 text-muted-foreground" }), jsxRuntime.jsxs("div", { className: "space-y-2", children: [jsxRuntime.jsx("h1", { className: "text-lg font-semibold tracking-tight", children: "\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E19\u0E35\u0E49\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E1C\u0E39\u0E01\u0E01\u0E31\u0E1A\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E1E\u0E19\u0E31\u0E01\u0E07\u0E32\u0E19" }), jsxRuntime.jsx("p", { className: "text-sm text-muted-foreground", children: "\u0E25\u0E32 \u0E25\u0E07\u0E40\u0E27\u0E25\u0E32 \u0E40\u0E1A\u0E34\u0E01\u0E40\u0E07\u0E34\u0E19 \u0E41\u0E25\u0E30\u0E01\u0E32\u0E23\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34 \u0E23\u0E30\u0E1A\u0E38\u0E15\u0E31\u0E27\u0E04\u0E19\u0E08\u0E32\u0E01\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E1E\u0E19\u0E31\u0E01\u0E07\u0E32\u0E19 \u0E08\u0E36\u0E07\u0E22\u0E31\u0E07\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E43\u0E19\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23\u0E19\u0E35\u0E49\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49" })] }), jsxRuntime.jsxs("div", { className: "flex flex-wrap justify-center gap-2", children: [jsxRuntime.jsx(erpTheme.Button, { onClick: () => window.location.reload(), children: "\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48" }), jsxRuntime.jsx(erpTheme.Button, { variant: "outline", asChild: true, children: jsxRuntime.jsxs("a", { href: createHref, children: [jsxRuntime.jsx(lucideReact.UserPlus, { className: "mr-1 h-4 w-4" }), "\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E1E\u0E19\u0E31\u0E01\u0E07\u0E32\u0E19"] }) })] }), jsxRuntime.jsx("p", { className: "text-xs text-muted-foreground", children: "\u0E16\u0E49\u0E32\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E41\u0E01\u0E49\u0E44\u0E02 \u0E43\u0E2B\u0E49\u0E41\u0E08\u0E49\u0E07\u0E1C\u0E39\u0E49\u0E14\u0E39\u0E41\u0E25\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" })] }) }));
2316
+ }
2317
+
2318
+ const shellClassName = "flex min-w-0 flex-1 flex-col gap-6 overflow-x-hidden";
2319
+ /**
2320
+ * Shared layout for “กำลังตรวจสอบสิทธิ์” (skeleton) and “ไม่มีสิทธิ์” (message).
2321
+ * Renders `children` only when policy is loaded and access is granted.
2322
+ */
2323
+ function PermissionGateShell({ policyLoading, hasPolicyAccess, deniedMessage, children, skeletonCount = 6, skeletonGridClassName = "grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3", skeletonVariant = "kpi", }) {
2324
+ if (policyLoading) {
2325
+ return (jsxRuntime.jsx("div", { className: shellClassName, children: jsxRuntime.jsx("div", { className: skeletonGridClassName, children: Array.from({ length: skeletonCount }).map((_, i) => (jsxRuntime.jsx(erpTheme.Card, { children: skeletonVariant === "kpi" ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs(erpTheme.CardHeader, { className: "flex flex-row items-center justify-between pb-2", children: [jsxRuntime.jsx(erpTheme.Skeleton, { className: "h-4 w-24" }), jsxRuntime.jsx(erpTheme.Skeleton, { className: "h-4 w-4 rounded" })] }), jsxRuntime.jsxs(erpTheme.CardContent, { children: [jsxRuntime.jsx(erpTheme.Skeleton, { className: "h-8 w-32 mb-2" }), jsxRuntime.jsx(erpTheme.Skeleton, { className: "h-3 w-20" })] })] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(erpTheme.CardHeader, { className: "pb-2", children: jsxRuntime.jsx(erpTheme.Skeleton, { className: "h-4 w-24" }) }), jsxRuntime.jsx(erpTheme.CardContent, { children: jsxRuntime.jsx(erpTheme.Skeleton, { className: "h-8 w-16" }) })] })) }, i))) }) }));
2128
2326
  }
2129
- if (error instanceof Error) {
2130
- return new ApiError(error.message);
2327
+ if (!hasPolicyAccess) {
2328
+ return (jsxRuntime.jsx("div", { className: shellClassName, children: jsxRuntime.jsx(AccessDenied, { message: deniedMessage }) }));
2131
2329
  }
2132
- return new ApiError(String(error));
2133
- };
2330
+ return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
2331
+ }
2134
2332
 
2135
- const isPlainObject = (value) => Object.prototype.toString.call(value) === "[object Object]";
2136
- const visit = (value, cb) => {
2137
- if (Array.isArray(value)) {
2138
- value.forEach(item => visit(item, cb));
2139
- return;
2333
+ const PermissionRulesContext = React.createContext(undefined);
2334
+ /**
2335
+ * Generic provider — the consumer app passes in `data` (live fetch) and
2336
+ * `fallbackPolicies` (static JSON). All apps share the same mapping logic
2337
+ * (build a flat map of route_key/policy_key → required child groups).
2338
+ */
2339
+ function PermissionRulesProvider({ children, data, isLoading = false, isError = false, fallbackPolicies = [], }) {
2340
+ const rulesMap = React.useMemo(() => {
2341
+ const m = {};
2342
+ if (!data?.routes)
2343
+ return m;
2344
+ for (const row of data.routes) {
2345
+ m[row.route_key] = row.required_child_groups ?? [];
2346
+ }
2347
+ return m;
2348
+ }, [data]);
2349
+ const policiesMap = React.useMemo(() => {
2350
+ const m = {};
2351
+ for (const row of fallbackPolicies) {
2352
+ m[row.policy_key] = row.required_child_groups ?? [];
2353
+ }
2354
+ if (data?.policies?.length) {
2355
+ for (const row of data.policies) {
2356
+ m[row.policy_key] = row.required_child_groups ?? [];
2357
+ }
2358
+ }
2359
+ return m;
2360
+ }, [data, fallbackPolicies]);
2361
+ const value = React.useMemo(() => ({
2362
+ rulesMap,
2363
+ policiesMap,
2364
+ isLoading,
2365
+ isError,
2366
+ updatedAt: data?.updated_at ?? null,
2367
+ }), [rulesMap, policiesMap, isLoading, isError, data?.updated_at]);
2368
+ return (jsxRuntime.jsx(PermissionRulesContext.Provider, { value: value, children: children }));
2369
+ }
2370
+ /**
2371
+ * Read the permission rules. Outside the provider, report loading so
2372
+ * fail-closed consumers (nav / gates) do not treat empty maps as "allow all".
2373
+ */
2374
+ function usePermissionRules() {
2375
+ const ctx = React.useContext(PermissionRulesContext);
2376
+ if (!ctx) {
2377
+ return {
2378
+ rulesMap: {},
2379
+ policiesMap: {},
2380
+ isLoading: true,
2381
+ isError: false,
2382
+ updatedAt: null,
2383
+ };
2140
2384
  }
2141
- if (!isPlainObject(value)) {
2142
- return;
2385
+ return ctx;
2386
+ }
2387
+
2388
+ /**
2389
+ * Strip the org `[code]` prefix from a pathname so we can look it up in the
2390
+ * permission rules map. `/O12345/setting/foo` → `/setting/foo`. Single-segment
2391
+ * paths (e.g. `/select-organization`) keep their leading slash.
2392
+ *
2393
+ * Also normalizes the user-role subtab aliases — both `purchase-doc` and
2394
+ * `sales-doc` are gated by the same `members` rule because the UI shows
2395
+ * the same management screen with different filters.
2396
+ */
2397
+ function normalizePathnameForPermissionRules(pathname) {
2398
+ const parts = pathname.split("/").filter(Boolean);
2399
+ const path = parts.length <= 1 ? "/" : "/" + parts.slice(1).join("/");
2400
+ return path.replace(/\/user-role\/(purchase-doc|sales-doc)$/, "/user-role/members");
2401
+ }
2402
+ function hasRule(rulesMap, routeKey) {
2403
+ return Object.prototype.hasOwnProperty.call(rulesMap, routeKey);
2404
+ }
2405
+ /**
2406
+ * Resolve UI route permissions for a pathname.
2407
+ *
2408
+ * - Exact / `[param]` pattern match → matched
2409
+ * - Parent-prefix inherit only when the parent rule has a **non-empty**
2410
+ * role list (restrictive inherit). Explicitly public parents (`[]`) do
2411
+ * not open all children.
2412
+ * - No match → `{ matched: false }` (fail closed — never treat as public)
2413
+ */
2414
+ function resolveUiRoutePermission(pathname, rulesMap) {
2415
+ const normalized = normalizePathnameForPermissionRules(pathname);
2416
+ if (hasRule(rulesMap, normalized)) {
2417
+ return { matched: true, roles: rulesMap[normalized] ?? [] };
2143
2418
  }
2144
- cb(value);
2145
- Object.values(value).forEach(item => visit(item, cb));
2146
- };
2147
- const validateGoodsReceiptChecks = (data) => {
2148
- let invalidPath = null;
2149
- const isCheckStatus = (v) => v === undefined || v === null || v === "pass" || v === "fail";
2150
- const isCompareStatus = (v) => v === undefined || v === null || v === "match" || v === "mismatch";
2151
- visit(data, node => {
2152
- if (invalidPath)
2153
- return;
2154
- if ("quantity_check" in node && !isCheckStatus(node.quantity_check)) {
2155
- invalidPath = "quantity_check";
2156
- return;
2419
+ const pathSegments = normalized.split("/").filter(Boolean);
2420
+ for (const [pattern, roles] of Object.entries(rulesMap)) {
2421
+ const patternSegments = pattern.split("/").filter(Boolean);
2422
+ if (patternSegments.length !== pathSegments.length)
2423
+ continue;
2424
+ let matches = true;
2425
+ for (let i = 0; i < patternSegments.length; i++) {
2426
+ const patternSegment = patternSegments[i];
2427
+ const pathSegment = pathSegments[i];
2428
+ if (patternSegment.startsWith("[") && patternSegment.endsWith("]")) {
2429
+ if (!pathSegment) {
2430
+ matches = false;
2431
+ break;
2432
+ }
2433
+ }
2434
+ else if (patternSegment !== pathSegment) {
2435
+ matches = false;
2436
+ break;
2437
+ }
2157
2438
  }
2158
- if ("quality_check" in node && !isCheckStatus(node.quality_check)) {
2159
- invalidPath = "quality_check";
2160
- return;
2439
+ if (matches) {
2440
+ return { matched: true, roles: roles ?? [] };
2161
2441
  }
2162
- if ("compare_with_reference" in node &&
2163
- !isCompareStatus(node.compare_with_reference)) {
2164
- invalidPath = "compare_with_reference";
2442
+ }
2443
+ // Parent-prefix: /leave/types/abc → /leave/types (non-empty roles only)
2444
+ for (let len = pathSegments.length - 1; len >= 1; len--) {
2445
+ const parent = "/" + pathSegments.slice(0, len).join("/");
2446
+ if (hasRule(rulesMap, parent) && (rulesMap[parent]?.length ?? 0) > 0) {
2447
+ return { matched: true, roles: rulesMap[parent] ?? [] };
2165
2448
  }
2449
+ }
2450
+ return { matched: false, roles: [] };
2451
+ }
2452
+ /**
2453
+ * Returns the required child-group segments for a page pathname.
2454
+ *
2455
+ * Prefer {@link resolveUiRoutePermission} when you need to distinguish
2456
+ * "explicitly public (`[]`)" from "no rule (deny)". This helper returns
2457
+ * `[]` for both cases and is kept for backward compatibility.
2458
+ */
2459
+ function getRequiredChildGroups(pathname, rulesMap) {
2460
+ return resolveUiRoutePermission(pathname, rulesMap).roles;
2461
+ }
2462
+
2463
+ function DeniedCard() {
2464
+ return (jsxRuntime.jsx(AccessDenied, { message: "\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07\u0E2B\u0E19\u0E49\u0E32\u0E19\u0E35\u0E49 \u0E2B\u0E32\u0E01\u0E04\u0E34\u0E14\u0E27\u0E48\u0E32\u0E04\u0E27\u0E23\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07\u0E44\u0E14\u0E49 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E1C\u0E39\u0E49\u0E14\u0E39\u0E41\u0E25\u0E23\u0E30\u0E1A\u0E1A\u0E02\u0E2D\u0E07\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" }));
2465
+ }
2466
+ /**
2467
+ * Permission gate for a page subtree. Looks up the current pathname in the
2468
+ * permission rules map (live rules from `PermissionRulesProvider`, layered
2469
+ * over the optional `fallbackRulesMap`), passes the resolved required roles
2470
+ * into the host's `usePermission` probe, and renders one of:
2471
+ *
2472
+ * 1. rules / probe still loading → `null` on FIRST load only (let Suspense /
2473
+ * loading.tsx show); once this subtree has been granted access, a later
2474
+ * loading state keeps the children mounted — see the note below
2475
+ * 2. no rule covers this path → deny (fail closed)
2476
+ * 3. rule is explicit public (`[]`) → children
2477
+ * 4. probe says no access → deny card
2478
+ * 5. otherwise → children
2479
+ */
2480
+ function PermissionProvider({ children, usePermission, fallbackRulesMap, }) {
2481
+ const pathname = navigation.usePathname();
2482
+ const { rulesMap, isLoading: rulesLoading } = usePermissionRules();
2483
+ const mergedMap = React.useMemo(() => {
2484
+ if (!fallbackRulesMap)
2485
+ return rulesMap;
2486
+ // live rules win over fallback when both define the same key
2487
+ return { ...fallbackRulesMap, ...rulesMap };
2488
+ }, [rulesMap, fallbackRulesMap]);
2489
+ const lookup = React.useMemo(() => resolveUiRoutePermission(pathname, mergedMap), [pathname, mergedMap]);
2490
+ // Hook must run unconditionally (rules of hooks).
2491
+ const { hasPermission, isLoading } = usePermission({
2492
+ requiredRoles: lookup.roles,
2166
2493
  });
2167
- if (invalidPath) {
2168
- return {
2169
- valid: false,
2170
- message: "รูปแบบข้อมูลไม่ถูกต้อง: ฟิลด์ตรวจรับต้องเป็น enum string (quantity/quality: pass|fail, compare: match|mismatch)",
2171
- };
2494
+ // Returning null unmounts the page. That is fine before anything has been
2495
+ // shown, and destructive afterwards: next-auth flips its status back to
2496
+ // "loading" on every session update() — one runs every 4 minutes — and a
2497
+ // rules hook that reports loading on that transient took the page down with
2498
+ // it. Users filling a long form watched everything they had typed vanish,
2499
+ // which reads as a spontaneous page refresh.
2500
+ //
2501
+ // So a loading state only blanks the subtree until access has been granted
2502
+ // once. After that the children stay mounted while the check re-settles; the
2503
+ // API still enforces access on every request, and a genuine loss of
2504
+ // permission surfaces as a denied response rather than a wiped form.
2505
+ //
2506
+ // The grant is remembered per pathname, not per mount. This provider lives in
2507
+ // the layout, so it survives every in-app navigation — a bare boolean would
2508
+ // carry one page's grant onto the next route and flash a page the user may
2509
+ // not be allowed to see while its own check is still running.
2510
+ const grantedForPathRef = React.useRef(null);
2511
+ const grantedHere = grantedForPathRef.current === pathname;
2512
+ if (rulesLoading && !grantedHere) {
2513
+ return null;
2172
2514
  }
2173
- return { valid: true };
2174
- };
2175
- const runGlobalRequestPrecheck = (config) => {
2176
- const method = (config.method || "get").toLowerCase();
2177
- const isWriteMethod = ["post", "put", "patch"].includes(method);
2178
- if (!isWriteMethod || config.data === undefined || config.data === null) {
2179
- return { valid: true };
2515
+ // Unknown route → deny. Only explicit `required_child_groups: []` is public.
2516
+ if (!lookup.matched) {
2517
+ return jsxRuntime.jsx(DeniedCard, {});
2180
2518
  }
2181
- if (typeof FormData !== "undefined" && config.data instanceof FormData) {
2182
- return { valid: true };
2519
+ if (lookup.roles.length === 0) {
2520
+ grantedForPathRef.current = pathname;
2521
+ return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
2183
2522
  }
2184
- return validateGoodsReceiptChecks(config.data);
2185
- };
2523
+ if (isLoading && !grantedHere) {
2524
+ return null;
2525
+ }
2526
+ if (!hasPermission && !isLoading) {
2527
+ return jsxRuntime.jsx(DeniedCard, {});
2528
+ }
2529
+ grantedForPathRef.current = pathname;
2530
+ return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
2531
+ }
2186
2532
 
2187
- const isBrowser = () => typeof window !== "undefined";
2188
- const isAuthEndpoint = (url) => !!url && url.includes("/api/auth/");
2189
- const isAuthPage = () => isBrowser() && window.location.pathname.startsWith("/auth/");
2190
- const getOrgFromCookie = () => {
2191
- if (!isBrowser())
2533
+ /** Cookie name for the cross-subdomain theme choice. */
2534
+ const THEME_COOKIE = "erp-theme";
2535
+ /**
2536
+ * Parent domain to scope the cookie to, so every `*.antniti.com` app reads the
2537
+ * same value. Returns `null` on localhost / bare IPs (host-only cookie).
2538
+ */
2539
+ function sharedCookieDomain() {
2540
+ if (typeof window === "undefined")
2192
2541
  return null;
2193
- const cookies = document.cookie.split(";").map(cookie => cookie.trim());
2194
- const orgCookie = cookies.find(cookie => cookie.startsWith("erp.organizationId="));
2195
- if (!orgCookie)
2542
+ const host = window.location.hostname;
2543
+ if (host === "localhost" || /^[0-9.]+$/.test(host))
2196
2544
  return null;
2197
- const raw = orgCookie.substring("erp.organizationId=".length);
2198
- return raw ? decodeURIComponent(raw) : null;
2199
- };
2200
- const redirectToLogout = async () => {
2201
- if (!isBrowser() || isAuthPage())
2545
+ const parts = host.split(".");
2546
+ if (parts.length < 2)
2547
+ return null;
2548
+ return "." + parts.slice(-2).join(".");
2549
+ }
2550
+ function readThemeCookie() {
2551
+ if (typeof document === "undefined")
2552
+ return null;
2553
+ const match = document.cookie.match(new RegExp("(?:^|; )" + THEME_COOKIE + "=([^;]*)"));
2554
+ return match ? decodeURIComponent(match[1]) : null;
2555
+ }
2556
+ function writeThemeCookie(value) {
2557
+ if (typeof document === "undefined")
2202
2558
  return;
2203
- localStorage.clear();
2204
- sessionStorage.clear();
2205
- setOrganizationId(null);
2206
- clearAccessTokenCache();
2207
- await react.signOut({ callbackUrl: "/auth/signin" });
2559
+ const domain = sharedCookieDomain();
2560
+ const parts = [
2561
+ `${THEME_COOKIE}=${encodeURIComponent(value)}`,
2562
+ "path=/",
2563
+ "max-age=31536000", // 1 year
2564
+ "SameSite=Lax",
2565
+ ];
2566
+ if (domain)
2567
+ parts.push(`domain=${domain}`);
2568
+ document.cookie = parts.join("; ");
2569
+ }
2570
+ /**
2571
+ * Bridges next-themes (per-origin localStorage) with a cookie scoped to the
2572
+ * parent domain, so the light/dark/system choice is shared across the
2573
+ * accounting / HR / platform subdomains.
2574
+ *
2575
+ * Local storage wins when it already has a value (H57): adopting the shared
2576
+ * cookie first used to overwrite an explicit choice — including one set via
2577
+ * DevTools — and write `theme=light` back on every load whenever the cookie
2578
+ * still said light. Push the local value into the cookie instead so sibling
2579
+ * apps catch up.
2580
+ */
2581
+ function ThemeCookieSync() {
2582
+ const { theme, setTheme } = nextThemes.useTheme();
2583
+ const skipFirstWrite = React__namespace.useRef(true);
2584
+ React__namespace.useEffect(() => {
2585
+ const local = typeof window !== "undefined"
2586
+ ? window.localStorage.getItem("theme")
2587
+ : null;
2588
+ const cookie = readThemeCookie();
2589
+ if (local) {
2590
+ if (local !== theme)
2591
+ setTheme(local);
2592
+ writeThemeCookie(local);
2593
+ }
2594
+ else if (cookie) {
2595
+ if (cookie !== theme)
2596
+ setTheme(cookie);
2597
+ }
2598
+ else if (theme) {
2599
+ writeThemeCookie(theme);
2600
+ }
2601
+ // Run once on mount — `theme` is intentionally not a dependency here.
2602
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2603
+ }, []);
2604
+ React__namespace.useEffect(() => {
2605
+ // Skip the initial render; only persist genuine changes (incl. the
2606
+ // setTheme() triggered by adopting storage above — harmless re-write).
2607
+ if (skipFirstWrite.current) {
2608
+ skipFirstWrite.current = false;
2609
+ return;
2610
+ }
2611
+ if (theme)
2612
+ writeThemeCookie(theme);
2613
+ }, [theme]);
2614
+ return null;
2615
+ }
2616
+ function ThemeProvider({ children, ...props }) {
2617
+ return (jsxRuntime.jsxs(nextThemes.ThemeProvider, { ...props, children: [jsxRuntime.jsx(ThemeCookieSync, {}), children] }));
2618
+ }
2619
+
2620
+ const defaultFlags = {
2621
+ enableAdvancedReporting: true,
2622
+ enableMultiCurrency: false,
2623
+ enableThaiTaxCalculation: true,
2624
+ enableProcurementWorkflow: true,
2625
+ enableInventoryTracking: true,
2626
+ enableAuditTrail: true,
2627
+ enableAdvancedFilter: true,
2628
+ filterFlag: false,
2629
+ setFilterFlag: () => { },
2208
2630
  };
2209
- const instance = axios.create({
2210
- baseURL: process.env.NEXT_PUBLIC_API_URL,
2211
- headers: { "Content-Type": "application/json" },
2212
- paramsSerializer: params => {
2213
- const searchParams = new URLSearchParams();
2214
- Object.entries(params || {}).forEach(([key, value]) => {
2215
- if (Array.isArray(value)) {
2216
- value.forEach(v => searchParams.append(key, String(v)));
2217
- }
2218
- else if (value !== undefined && value !== null) {
2219
- searchParams.append(key, String(value));
2631
+ const FeatureFlagsContext = React.createContext(defaultFlags);
2632
+ function FeatureFlagsProvider({ children, flags = {}, }) {
2633
+ const [filterFlag, setFilterFlag] = React.useState(false);
2634
+ const mergedFlags = {
2635
+ ...defaultFlags,
2636
+ ...flags,
2637
+ filterFlag: flags.filterFlag ?? filterFlag,
2638
+ setFilterFlag: (flag) => {
2639
+ setFilterFlag(flag);
2640
+ if (typeof flags.setFilterFlag === "function") {
2641
+ flags.setFilterFlag(flag);
2220
2642
  }
2221
- });
2222
- return searchParams.toString();
2223
- },
2224
- });
2225
- instance.interceptors.request.use(async (config) => {
2226
- if (!config.headers) {
2227
- config.headers = new axios.AxiosHeaders();
2228
- }
2229
- if (config.data instanceof FormData && config.headers) {
2230
- delete config.headers["Content-Type"];
2231
- }
2232
- if (!isBrowser())
2233
- return config;
2234
- const token = await getAccessToken(false);
2235
- if (token) {
2236
- config.headers.Authorization = `Bearer ${token}`;
2237
- }
2238
- // localStorage first, cookie second.
2239
- //
2240
- // Both are written by the same state machine, but not at the same moment:
2241
- // setOrganizationId writes localStorage synchronously and then awaits a
2242
- // server action for the cookie. Preferring the cookie meant every request
2243
- // fired during that round trip carried the previous organisation — while
2244
- // the query params, built from React state, already carried the new one.
2245
- //
2246
- // The backend scopes by this header and ignores organization_id in the
2247
- // query, so those requests asked for organisation A and were handed B, and
2248
- // the answer landed in the cache under A's key. No amount of cache clearing
2249
- // reaches that: the entry is wrong, not stale.
2250
- const organizationId = getOrganizationId() || getOrgFromCookie();
2251
- if (organizationId) {
2252
- config.headers["X-Organization-ID"] = organizationId;
2643
+ },
2644
+ };
2645
+ return (jsxRuntime.jsx(FeatureFlagsContext.Provider, { value: mergedFlags, children: children }));
2646
+ }
2647
+ function useFeatureFlags() {
2648
+ const context = React.useContext(FeatureFlagsContext);
2649
+ if (!context) {
2650
+ throw new Error("useFeatureFlags must be used within a FeatureFlagsProvider");
2253
2651
  }
2254
- const precheck = runGlobalRequestPrecheck(config);
2255
- if (!precheck.valid) {
2256
- throw new ApiError(precheck.message || "Invalid request payload");
2652
+ return context;
2653
+ }
2654
+ function useFeatureFlag(flag) {
2655
+ const flags = useFeatureFlags();
2656
+ return flags[flag];
2657
+ }
2658
+
2659
+ const getAxiosErrorMessage$1 = (error) => {
2660
+ if (error.response?.data?.message) {
2661
+ return error.response.data.message;
2257
2662
  }
2258
- return config;
2259
- });
2260
- let refreshPromise = null;
2261
- const refreshTokenSingleFlight = async () => {
2262
- if (!refreshPromise) {
2263
- refreshPromise = getAccessToken(true).finally(() => {
2264
- refreshPromise = null;
2265
- });
2663
+ if (error.message) {
2664
+ return error.message;
2266
2665
  }
2267
- return refreshPromise;
2666
+ return "An unknown error occurred";
2268
2667
  };
2269
- instance.interceptors.response.use(response => {
2270
- // Any successful call means the session can act in this organisation
2271
- // after all. Without this the gate, once raised, stayed up through a
2272
- // whole working session — including the seconds between an administrator
2273
- // adding somebody and the employee record existing.
2274
- if (typeof window !== "undefined") {
2275
- window.dispatchEvent(new CustomEvent("erp:employee-record-ok"));
2276
- }
2277
- return response;
2278
- }, async (error) => {
2279
- const originalRequest = error.config;
2280
- const requestUrl = originalRequest?.url || error.config?.url;
2281
- if (error.response?.status === 401 &&
2282
- originalRequest &&
2283
- !originalRequest._retry &&
2284
- !isAuthEndpoint(requestUrl) &&
2285
- !isAuthPage()) {
2286
- originalRequest._retry = true;
2287
- const newToken = await refreshTokenSingleFlight();
2288
- if (newToken && originalRequest.headers) {
2289
- originalRequest.headers.Authorization = `Bearer ${newToken}`;
2290
- return instance(originalRequest);
2291
- }
2292
- await redirectToLogout();
2293
- return Promise.reject(toApiError(error));
2294
- }
2295
- // The server refuses every organisation-scoped call when the account has
2296
- // no employee record. Announce it once so a gate can explain it, instead
2297
- // of letting each failed query raise its own toast.
2298
- if (error.response?.status === 403) {
2299
- const body = error.response.data;
2300
- if (body?.code === "EMPLOYEE_RECORD_REQUIRED" && typeof window !== "undefined") {
2301
- window.dispatchEvent(new CustomEvent("erp:employee-record-required"));
2302
- }
2668
+ const formatError = (handlerName, error) => {
2669
+ const axiosError = error;
2670
+ const message = getAxiosErrorMessage$1(axiosError);
2671
+ const isOrgRequired = message.includes("Organization context is required") ||
2672
+ message.includes("organization_id") ||
2673
+ message.includes("X-Organization-ID");
2674
+ if (isOrgRequired) {
2675
+ // Expected when no org selected yet (e.g. select-organization page).
2676
+ console.warn(`[React Query] ${handlerName} skipped (no organization context): ${message}`);
2677
+ return;
2303
2678
  }
2304
- return Promise.reject(toApiError(error));
2679
+ };
2680
+ /**
2681
+ * Default React Query config used across ERP apps:
2682
+ * - 2 retries on queries / 1 on mutations, exponential backoff
2683
+ * - 5 min staleTime, refetchOnWindowFocus
2684
+ * - swallows "no org context" errors to silence noise on the
2685
+ * select-organization page
2686
+ *
2687
+ * Apps can compose on top of this — pass the returned config to `new QueryClient(...)`
2688
+ * directly or spread + override default options.
2689
+ */
2690
+ const getQueryClientConfig = () => ({
2691
+ defaultOptions: {
2692
+ queries: {
2693
+ retry: 2,
2694
+ retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
2695
+ staleTime: 5 * 60 * 1000,
2696
+ refetchOnWindowFocus: true,
2697
+ },
2698
+ mutations: {
2699
+ // NEVER auto-retry a mutation. Unlike a query, a mutation is a side
2700
+ // effect on a non-idempotent endpoint (submit, create, approve), and
2701
+ // React Query cannot tell "the network dropped" from "the server
2702
+ // rejected this on business grounds".
2703
+ //
2704
+ // With retry: 1 every failed mutation fired twice. Two visible
2705
+ // consequences: a rejected submit showed up as two failed POSTs in the
2706
+ // network tab, and — far worse — a create whose response was lost in
2707
+ // flight would be re-sent and duplicate the record server-side.
2708
+ //
2709
+ // Retrying is the caller's decision, not a global default: the user
2710
+ // sees the error and presses the button again.
2711
+ retry: 0,
2712
+ },
2713
+ },
2714
+ queryCache: new reactQuery.QueryCache({
2715
+ onError: error => formatError("Query", error),
2716
+ }),
2717
+ mutationCache: new reactQuery.MutationCache({
2718
+ onError: error => formatError("Mutation", error),
2719
+ }),
2305
2720
  });
2306
2721
 
2307
- const hasPaginationShape = (payload) => {
2308
- return ("total" in payload ||
2309
- "page" in payload ||
2310
- "per_page" in payload ||
2311
- "total_pages" in payload ||
2312
- "has_next" in payload ||
2313
- "has_prev" in payload ||
2314
- "pagination" in payload);
2315
- };
2316
- const isEnvelope = (payload) => {
2317
- return ("success" in payload || "message" in payload) && !hasPaginationShape(payload);
2318
- };
2319
- const unwrapApiResponse = (payload) => {
2320
- if (!payload || typeof payload !== "object") {
2321
- return payload;
2722
+ function ReactQueryProvider({ children }) {
2723
+ // Create a new QueryClient instance for each render to avoid SSR issues.
2724
+ const [queryClient] = React.useState(() => new reactQuery.QueryClient(getQueryClientConfig()));
2725
+ return (jsxRuntime.jsxs(reactQuery.QueryClientProvider, { client: queryClient, children: [children, jsxRuntime.jsx(reactQueryDevtools.ReactQueryDevtools, { client: queryClient, initialIsOpen: false })] }));
2726
+ }
2727
+
2728
+ /**
2729
+ * Renders nothing. Runs during render (after SessionProvider, inside
2730
+ * QueryClientProvider) to seed the cached profile slot from the JWT-backed
2731
+ * session — so `useMyProfile` doesn't fire a redundant GET on initial load.
2732
+ *
2733
+ * Generic over the profile shape; each app passes its own queryKey + mapper.
2734
+ */
2735
+ function SessionBackedProfileCacheSeeder({ queryKey, mapper, }) {
2736
+ const { data: session, status } = react.useSession();
2737
+ const queryClient = reactQuery.useQueryClient();
2738
+ if (status === "authenticated" && session) {
2739
+ const mapped = mapper(session);
2740
+ if (mapped) {
2741
+ const existing = queryClient.getQueryData(queryKey);
2742
+ if (!existing) {
2743
+ // updatedAt: 0 → immediately stale so observers still GET /myprofile
2744
+ // for fields the JWT seed omits (avatar/signature paths).
2745
+ queryClient.setQueryData(queryKey, mapped, { updatedAt: 0 });
2746
+ }
2747
+ else {
2748
+ // Only overwrite when the mapped id changed — we don't know T's shape
2749
+ // so we rely on a shallow id check via JSON tag if present.
2750
+ const existingId = existing?.id;
2751
+ const mappedId = mapped?.id;
2752
+ if (existingId !== mappedId) {
2753
+ queryClient.setQueryData(queryKey, mapped, { updatedAt: 0 });
2754
+ }
2755
+ }
2756
+ }
2322
2757
  }
2323
- const record = payload;
2324
- if ("data" in record &&
2325
- record.data !== undefined &&
2326
- isEnvelope(record)) {
2327
- return record.data;
2758
+ if (status === "unauthenticated") {
2759
+ queryClient.removeQueries({ queryKey });
2328
2760
  }
2329
- return payload;
2330
- };
2761
+ return null;
2762
+ }
2331
2763
 
2332
2764
  // Hooks
2333
2765
  const updateOrganizationId = (organizationId) => {
@@ -2358,6 +2790,8 @@ exports.EMPLOYEE_RECORD_REQUIRED_EVENT = EMPLOYEE_RECORD_REQUIRED_EVENT;
2358
2790
  exports.EmployeeRequiredGate = EmployeeRequiredGate;
2359
2791
  exports.FeatureFlagsProvider = FeatureFlagsProvider;
2360
2792
  exports.HeaderBackground = HeaderBackground;
2793
+ exports.HelpButton = HelpButton;
2794
+ exports.HelpDialogHost = HelpDialogHost;
2361
2795
  exports.NavMain = NavMain;
2362
2796
  exports.NavTools = NavTools;
2363
2797
  exports.NavUser = NavUser;
@@ -2367,6 +2801,7 @@ exports.PermissionGateShell = PermissionGateShell;
2367
2801
  exports.PermissionProvider = PermissionProvider;
2368
2802
  exports.PermissionRulesProvider = PermissionRulesProvider;
2369
2803
  exports.ReactQueryProvider = ReactQueryProvider;
2804
+ exports.SHELL_VERSION = SHELL_VERSION;
2370
2805
  exports.SessionBackedProfileCacheSeeder = SessionBackedProfileCacheSeeder;
2371
2806
  exports.SessionRefreshTrigger = SessionRefreshTrigger;
2372
2807
  exports.SidebarContent = SidebarContent;
@@ -2387,7 +2822,11 @@ exports.buildUrlWithCode = buildUrlWithCode;
2387
2822
  exports.canAccessNavPermissionRoute = canAccessNavPermissionRoute;
2388
2823
  exports.clearAccessTokenCache = clearAccessTokenCache;
2389
2824
  exports.clearOrganizationId = clearOrganizationId;
2825
+ exports.closeHelp = closeHelp;
2390
2826
  exports.decodeJWT = decodeJWT;
2827
+ exports.describeAttached = describeAttached;
2828
+ exports.describeBrowser = describeBrowser;
2829
+ exports.detectHelpApp = detectHelpApp;
2391
2830
  exports.filterNavigationModulesByPermission = filterNavigationModulesByPermission;
2392
2831
  exports.filterNavigationToolsByPermission = filterNavigationToolsByPermission;
2393
2832
  exports.getAccessToken = getAccessToken;
@@ -2401,6 +2840,7 @@ exports.hasPlanFeature = hasPlanFeature;
2401
2840
  exports.isLikelyTransientNetworkError = isLikelyTransientNetworkError;
2402
2841
  exports.isPathActive = isPathActive;
2403
2842
  exports.normalizePathnameForPermissionRules = normalizePathnameForPermissionRules;
2843
+ exports.openHelp = openHelp;
2404
2844
  exports.resolveUiRoutePermission = resolveUiRoutePermission;
2405
2845
  exports.runGlobalRequestPrecheck = runGlobalRequestPrecheck;
2406
2846
  exports.runWithTransientRetry = runWithTransientRetry;