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