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