@eintrek/erp-shell 0.1.51 → 0.1.53
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/README.md +4 -1
- package/dist/help/help-api.d.ts +85 -0
- package/dist/help/help-attach.d.ts +145 -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 +15 -0
- package/dist/help/help-images.d.ts +42 -0
- package/dist/help/help-link.d.ts +15 -0
- package/dist/help/help-store.d.ts +34 -0
- package/dist/http/platform-api.d.ts +2 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.esm.js +2418 -1143
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +2426 -1138
- package/dist/index.js.map +1 -1
- package/dist/navigation/app-layout-shell.d.ts +11 -1
- package/dist/navigation/my-roles.d.ts +41 -0
- package/dist/navigation/nav-user.d.ts +12 -1
- package/dist/navigation/use-my-roles.d.ts +15 -0
- package/dist/version.d.ts +5 -0
- package/package.json +3 -2
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,270 +822,982 @@ function isRecordID(segment) {
|
|
|
822
822
|
/^[0-9a-f]{24,}$/i.test(segment));
|
|
823
823
|
}
|
|
824
824
|
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
const user = session?.user
|
|
840
|
-
? {
|
|
841
|
-
name: session.user.name || "ผู้ใช้",
|
|
842
|
-
email: session.user.email || "",
|
|
843
|
-
// avatarSrc first: the NextAuth session carries no picture unless the
|
|
844
|
-
// host app deliberately puts one in the JWT, so relying on
|
|
845
|
-
// session.user.image alone left every user on their initials forever.
|
|
846
|
-
avatar: avatarSrc || session.user.image || undefined,
|
|
847
|
-
}
|
|
848
|
-
: null;
|
|
849
|
-
const getInitials = (name) => {
|
|
850
|
-
if (!name)
|
|
851
|
-
return "U";
|
|
852
|
-
return name
|
|
853
|
-
.split(" ")
|
|
854
|
-
.map(n => n[0])
|
|
855
|
-
.join("")
|
|
856
|
-
.toUpperCase()
|
|
857
|
-
.slice(0, 2);
|
|
858
|
-
};
|
|
859
|
-
const handleSignOut = async () => {
|
|
860
|
-
setIsSigningOut(true);
|
|
861
|
-
try {
|
|
862
|
-
await react.signOut({ callbackUrl: signInRedirectHref, redirect: true });
|
|
863
|
-
}
|
|
864
|
-
catch (error) {
|
|
865
|
-
console.error("Sign out error:", error);
|
|
866
|
-
window.location.href = signInRedirectHref;
|
|
867
|
-
}
|
|
868
|
-
finally {
|
|
869
|
-
setIsSigningOut(false);
|
|
870
|
-
}
|
|
871
|
-
};
|
|
872
|
-
const handleSelectOrganization = () => {
|
|
873
|
-
setIsOpen(false);
|
|
874
|
-
router.push(selectOrganizationHref);
|
|
875
|
-
};
|
|
876
|
-
const handleProfile = () => {
|
|
877
|
-
setIsOpen(false);
|
|
878
|
-
if (profileHref)
|
|
879
|
-
router.push(profileHref);
|
|
880
|
-
};
|
|
881
|
-
const handleSystemLink = (href) => {
|
|
882
|
-
setIsOpen(false);
|
|
883
|
-
if (isExternalHref(href)) {
|
|
884
|
-
window.location.assign(href);
|
|
885
|
-
return;
|
|
886
|
-
}
|
|
887
|
-
router.push(href);
|
|
825
|
+
let open = false;
|
|
826
|
+
let hosts = 0;
|
|
827
|
+
let declaredApp = null;
|
|
828
|
+
// The message the dialog should open on (the ตอบกลับ link's ?help=H1004),
|
|
829
|
+
// until it is closed or left.
|
|
830
|
+
let pendingRef = null;
|
|
831
|
+
const listeners = new Set();
|
|
832
|
+
function emit() {
|
|
833
|
+
listeners.forEach(l => l());
|
|
834
|
+
}
|
|
835
|
+
function subscribe(l) {
|
|
836
|
+
listeners.add(l);
|
|
837
|
+
return () => {
|
|
838
|
+
listeners.delete(l);
|
|
888
839
|
};
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
setShowLanguageMenu(false);
|
|
896
|
-
};
|
|
897
|
-
React.useEffect(() => {
|
|
898
|
-
if (isOpen && buttonRef.current) {
|
|
899
|
-
const rect = buttonRef.current.getBoundingClientRect();
|
|
900
|
-
setPosition({ top: rect.bottom, left: rect.right + 8 });
|
|
901
|
-
}
|
|
902
|
-
}, [isOpen]);
|
|
903
|
-
React.useEffect(() => {
|
|
904
|
-
if (!isOpen)
|
|
905
|
-
return;
|
|
906
|
-
const handleClickOutside = (event) => {
|
|
907
|
-
if (buttonRef.current &&
|
|
908
|
-
!buttonRef.current.contains(event.target) &&
|
|
909
|
-
!event.target.closest('[data-dropdown="nav-user"]')) {
|
|
910
|
-
setIsOpen(false);
|
|
911
|
-
}
|
|
912
|
-
};
|
|
913
|
-
document.addEventListener("mousedown", handleClickOutside);
|
|
914
|
-
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
915
|
-
}, [isOpen]);
|
|
916
|
-
if (status === "loading") {
|
|
917
|
-
return (jsxRuntime.jsx("div", { className: "p-2", children: jsxRuntime.jsxs("div", { className: "flex items-center gap-2 p-2 rounded-md", children: [jsxRuntime.jsx("div", { className: "h-8 w-8 rounded-lg bg-sidebar-accent animate-pulse" }), jsxRuntime.jsxs("div", { className: "flex-1 space-y-1", children: [jsxRuntime.jsx("div", { className: "h-4 w-24 bg-sidebar-accent rounded animate-pulse" }), jsxRuntime.jsx("div", { className: "h-3 w-32 bg-sidebar-accent rounded animate-pulse" })] })] }) }));
|
|
840
|
+
}
|
|
841
|
+
/** Opens the dialog — a no-op when no layout has mounted one. */
|
|
842
|
+
function openHelp() {
|
|
843
|
+
if (!open) {
|
|
844
|
+
open = true;
|
|
845
|
+
emit();
|
|
918
846
|
}
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
// A dead logo URL must not leave a broken-image glyph in the
|
|
926
|
-
// sidebar; drop back to the generic icon's empty slot.
|
|
927
|
-
onError: e => {
|
|
928
|
-
e.currentTarget.style.display = "none";
|
|
929
|
-
} })) : (jsxRuntime.jsx(lucideReact.Building2, { className: "h-5 w-5 text-sidebar-foreground/70 flex-shrink-0" })), jsxRuntime.jsxs("div", { className: "flex-1 min-w-0", children: [jsxRuntime.jsx("div", { className: "font-medium text-sm sm:truncate", children: organization.name }), organization.description && (jsxRuntime.jsx("div", { className: "text-xs text-sidebar-foreground/70 truncate mt-0.5", children: organization.description }))] })] }) })), jsxRuntime.jsxs("div", { className: "p-2", children: [jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsxs("button", { onClick: () => {
|
|
930
|
-
setShowThemeMenu(!showThemeMenu);
|
|
931
|
-
setShowLanguageMenu(false);
|
|
932
|
-
setShowAppsMenu(false);
|
|
933
|
-
}, 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.Palette, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E18\u0E35\u0E21" }), jsxRuntime.jsx(lucideReact.ChevronUp, { className: `h-4 w-4 transition-transform ${showThemeMenu ? "rotate-180" : ""}` })] }), showThemeMenu && (jsxRuntime.jsx("div", { className: "mt-1 ml-4 space-y-1", children: [
|
|
934
|
-
{ key: "light", label: "สว่าง", Icon: lucideReact.Sun },
|
|
935
|
-
{ key: "dark", label: "มืด", Icon: lucideReact.Moon },
|
|
936
|
-
{ key: "system", label: "ตามระบบ", Icon: lucideReact.Monitor },
|
|
937
|
-
].map(({ key, label, Icon }) => (jsxRuntime.jsxs("button", { onClick: () => handleThemeChange(key), 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: [jsxRuntime.jsx(Icon, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: label }), theme === key && (jsxRuntime.jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, key))) }))] }), showLanguageSwitch && (jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsxs("button", { onClick: () => {
|
|
938
|
-
setShowLanguageMenu(!showLanguageMenu);
|
|
939
|
-
setShowThemeMenu(false);
|
|
940
|
-
setShowAppsMenu(false);
|
|
941
|
-
}, 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.Globe, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E20\u0E32\u0E29\u0E32" }), jsxRuntime.jsx(lucideReact.ChevronUp, { className: `h-4 w-4 transition-transform ${showLanguageMenu ? "rotate-180" : ""}` })] }), showLanguageMenu && (jsxRuntime.jsx("div", { className: "mt-1 ml-4 space-y-1", children: [
|
|
942
|
-
{ code: "th", label: "ไทย" },
|
|
943
|
-
{ code: "en", label: "English" },
|
|
944
|
-
{ code: "zh", label: "中文" },
|
|
945
|
-
{ code: "ja", label: "日本語" },
|
|
946
|
-
].map(({ code, label }) => (jsxRuntime.jsxs("button", { onClick: () => handleLanguageChange(code), 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: [jsxRuntime.jsx("span", { className: "flex-1 text-left", children: label }), organization?.language === code && (jsxRuntime.jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, code))) }))] })), profileHref && (jsxRuntime.jsxs("button", { onClick: handleProfile, 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.User, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E2A\u0E48\u0E27\u0E19\u0E15\u0E31\u0E27" })] })), showSelectOrganization && (jsxRuntime.jsxs("button", { onClick: handleSelectOrganization, 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.Building2, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E25\u0E37\u0E2D\u0E01\u0E18\u0E38\u0E23\u0E01\u0E34\u0E08" })] })), visibleSystemLinks.length > 0 && (jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsxs("button", { onClick: () => {
|
|
947
|
-
setShowAppsMenu(!showAppsMenu);
|
|
948
|
-
setShowThemeMenu(false);
|
|
949
|
-
setShowLanguageMenu(false);
|
|
950
|
-
}, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx(lucideReact.LayoutGrid, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E41\u0E2D\u0E1B\u0E1E\u0E25\u0E34\u0E40\u0E04\u0E0A\u0E31\u0E19" }), jsxRuntime.jsx(lucideReact.ChevronUp, { className: `h-4 w-4 transition-transform ${showAppsMenu ? "rotate-180" : ""}` })] }), showAppsMenu && (jsxRuntime.jsx("div", { className: "mt-1 ml-4 space-y-1", children: visibleSystemLinks.map(link => (jsxRuntime.jsxs("button", { type: "button", onClick: () => handleSystemLink(link.href), className: "flex w-full items-center gap-3 px-3 py-2 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [link.icon ?? jsxRuntime.jsx(lucideReact.ExternalLink, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: link.label }), link.active && (jsxRuntime.jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, `${link.label}:${link.href}`))) }))] })), jsxRuntime.jsx("button", { onClick: handleSignOut, disabled: isSigningOut, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed mt-1", children: isSigningOut ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(lucideReact.Loader2, { className: "h-4 w-4 animate-spin" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E23\u0E30\u0E1A\u0E1A..." })] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(lucideReact.LogOut, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E23\u0E30\u0E1A\u0E1A" })] })) })] })] })) : null;
|
|
951
|
-
if (variant === "workspace") {
|
|
952
|
-
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
|
-
reactDom.createPortal(dropdownContent, document.body)] }) }));
|
|
847
|
+
}
|
|
848
|
+
function closeHelp() {
|
|
849
|
+
pendingRef = null;
|
|
850
|
+
if (open) {
|
|
851
|
+
open = false;
|
|
852
|
+
emit();
|
|
954
853
|
}
|
|
955
|
-
return (jsxRuntime.jsx("div", { className: "p-2", children: jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsxs("button", { ref: buttonRef, onClick: () => setIsOpen(!isOpen), className: "flex w-full items-center gap-2 p-2 rounded-md transition-colors", children: [jsxRuntime.jsxs(erpTheme.Avatar, { className: "h-8 w-8 rounded-lg", children: [user.avatar ? (jsxRuntime.jsx(erpTheme.AvatarImage, { src: user.avatar, alt: user.name })) : null, jsxRuntime.jsx(erpTheme.AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground w-8 h-8", children: getInitials(user.name) })] }), jsxRuntime.jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [jsxRuntime.jsx("span", { className: "truncate font-medium", title: user.name, children: user.name }), jsxRuntime.jsx("span", { className: "truncate text-xs text-sidebar-foreground/70", title: user.email, children: user.email })] }), jsxRuntime.jsx(lucideReact.ChevronUp, { className: `ml-auto size-4 transition-transform ${isOpen ? "rotate-180" : ""}` })] }), typeof window !== "undefined" &&
|
|
956
|
-
reactDom.createPortal(dropdownContent, document.body)] }) }));
|
|
957
854
|
}
|
|
958
|
-
|
|
855
|
+
/** Opens the dialog on one of the person's messages, by id or "H1004". */
|
|
856
|
+
function openHelpOn(ref) {
|
|
857
|
+
pendingRef = ref;
|
|
858
|
+
open = true;
|
|
859
|
+
emit();
|
|
860
|
+
}
|
|
861
|
+
/** The message the dialog was asked to open on, if it has not been left. */
|
|
862
|
+
function pendingHelpRef() {
|
|
863
|
+
return pendingRef;
|
|
864
|
+
}
|
|
865
|
+
/** The dialog moved on from the message it was opened on. */
|
|
866
|
+
function clearPendingHelpRef() {
|
|
867
|
+
pendingRef = null;
|
|
868
|
+
}
|
|
959
869
|
/**
|
|
960
|
-
*
|
|
961
|
-
*
|
|
962
|
-
*
|
|
963
|
-
* private copies of the same list drift: the app-switcher list did exactly
|
|
964
|
-
* that before it moved here.
|
|
870
|
+
* Called by the dialog host on mount; returns its unmount. A host that
|
|
871
|
+
* mounts while a message is waiting to be shown (a page that mounted its
|
|
872
|
+
* layout twice) opens the dialog again.
|
|
965
873
|
*/
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
}
|
|
983
|
-
/**
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
PLAN_FEATURES.MODULE_SECRETARIAT,
|
|
988
|
-
];
|
|
874
|
+
function registerHelpHost() {
|
|
875
|
+
hosts += 1;
|
|
876
|
+
if (pendingRef)
|
|
877
|
+
open = true;
|
|
878
|
+
emit();
|
|
879
|
+
return () => {
|
|
880
|
+
hosts -= 1;
|
|
881
|
+
if (hosts <= 0) {
|
|
882
|
+
hosts = 0;
|
|
883
|
+
open = false;
|
|
884
|
+
}
|
|
885
|
+
emit();
|
|
886
|
+
};
|
|
887
|
+
}
|
|
888
|
+
function useHelpOpen() {
|
|
889
|
+
return React.useSyncExternalStore(subscribe, () => open, () => false);
|
|
890
|
+
}
|
|
891
|
+
/** True while a dialog host is mounted, i.e. the ช่วยเหลือ entry works. */
|
|
892
|
+
function useHelpAvailable() {
|
|
893
|
+
return React.useSyncExternalStore(subscribe, () => hosts > 0, () => false);
|
|
894
|
+
}
|
|
989
895
|
/**
|
|
990
|
-
*
|
|
991
|
-
*
|
|
992
|
-
*
|
|
896
|
+
* Every app names itself when it builds its app-switcher links
|
|
897
|
+
* (buildSystemLinks({ activeApp })); the dialog reads that to say which app
|
|
898
|
+
* a message came from without each app passing it again.
|
|
993
899
|
*/
|
|
994
|
-
function
|
|
995
|
-
|
|
996
|
-
return false;
|
|
997
|
-
if (features.includes(feature))
|
|
998
|
-
return true;
|
|
999
|
-
if ((feature === PLAN_FEATURES.MODULE_HR ||
|
|
1000
|
-
feature === PLAN_FEATURES.MODULE_ACCOUNTING ||
|
|
1001
|
-
ACCOUNTING_SUB_KEYS.includes(feature)) &&
|
|
1002
|
-
features.includes(PLAN_FEATURES.BASIC_ACCOUNTING_HR)) {
|
|
1003
|
-
return true;
|
|
1004
|
-
}
|
|
1005
|
-
if (ACCOUNTING_SUB_KEYS.includes(feature) &&
|
|
1006
|
-
features.includes(PLAN_FEATURES.MODULE_ACCOUNTING)) {
|
|
1007
|
-
return true;
|
|
1008
|
-
}
|
|
1009
|
-
if (feature === PLAN_FEATURES.BASIC_ACCOUNTING_HR &&
|
|
1010
|
-
features.includes(PLAN_FEATURES.MODULE_HR) &&
|
|
1011
|
-
features.includes(PLAN_FEATURES.MODULE_ACCOUNTING)) {
|
|
1012
|
-
return true;
|
|
1013
|
-
}
|
|
1014
|
-
return false;
|
|
900
|
+
function declareHelpApp(app) {
|
|
901
|
+
declaredApp = app;
|
|
1015
902
|
}
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
return (value ?? fallback).replace(/\/$/, "");
|
|
903
|
+
function declaredHelpApp() {
|
|
904
|
+
return declaredApp;
|
|
1019
905
|
}
|
|
906
|
+
|
|
1020
907
|
/**
|
|
1021
|
-
*
|
|
908
|
+
* The caller's roles in the current organisation, as platform-api answers
|
|
909
|
+
* GET /organizations/:id/my-roles, and how the user card words them.
|
|
1022
910
|
*
|
|
1023
|
-
*
|
|
1024
|
-
* shipped without a link to Plannex: five copies, one of them edited by hand.
|
|
1025
|
-
* Adding the sixth app is now a change here plus a version bump.
|
|
911
|
+
* No runtime imports: the tests run this file in Node as it is.
|
|
1026
912
|
*/
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
features: null,
|
|
1033
|
-
baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_PLATFORM_URL, "https://platform.antniti.com"),
|
|
1034
|
-
},
|
|
1035
|
-
{
|
|
1036
|
-
id: "accounx",
|
|
1037
|
-
label: "Accounx",
|
|
1038
|
-
icon: jsxRuntime.jsx(lucideReact.Calculator, { className: "h-4 w-4 shrink-0" }),
|
|
1039
|
-
features: [PLAN_FEATURES.MODULE_ACCOUNTING],
|
|
1040
|
-
baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_ACCOUNTING_URL, "https://accounx.antniti.com"),
|
|
1041
|
-
},
|
|
1042
|
-
{
|
|
1043
|
-
id: "invenx",
|
|
1044
|
-
label: "Invenx",
|
|
1045
|
-
icon: jsxRuntime.jsx(lucideReact.Warehouse, { className: "h-4 w-4 shrink-0" }),
|
|
1046
|
-
features: [PLAN_FEATURES.MODULE_INVENTORY],
|
|
1047
|
-
baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_INVENX_URL, "https://invenx.antniti.com"),
|
|
1048
|
-
},
|
|
1049
|
-
{
|
|
1050
|
-
id: "plannex",
|
|
1051
|
-
label: "Plannex",
|
|
1052
|
-
icon: jsxRuntime.jsx(lucideReact.FolderKanban, { className: "h-4 w-4 shrink-0" }),
|
|
1053
|
-
// Tasks, day-logs and work records are free with HR; projects need the add-on.
|
|
1054
|
-
features: [PLAN_FEATURES.MODULE_HR, PLAN_FEATURES.MODULE_PROJECT],
|
|
1055
|
-
baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_PLANNEX_URL, "https://plannex.antniti.com"),
|
|
1056
|
-
},
|
|
1057
|
-
{
|
|
1058
|
-
id: "peoplx",
|
|
1059
|
-
label: "Peoplx",
|
|
1060
|
-
icon: jsxRuntime.jsx(lucideReact.Users, { className: "h-4 w-4 shrink-0" }),
|
|
1061
|
-
features: [PLAN_FEATURES.MODULE_HR],
|
|
1062
|
-
baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_HR_URL, "https://peoplx.antniti.com"),
|
|
1063
|
-
},
|
|
1064
|
-
];
|
|
913
|
+
/** The owner's chip. Ownership is a flag, not a role. */
|
|
914
|
+
const OWNER_CHIP = "เจ้าของ";
|
|
915
|
+
/** How many role names the card spells out before counting the rest. */
|
|
916
|
+
const CARD_ROLE_LIMIT = 2;
|
|
917
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1065
918
|
/**
|
|
1066
|
-
*
|
|
1067
|
-
*
|
|
1068
|
-
*
|
|
1069
|
-
* it — the user is looking at it, so hiding the link would only make the menu
|
|
1070
|
-
* disagree with the screen.
|
|
919
|
+
* The answer, checked. Anything that is not the shape the card needs — an
|
|
920
|
+
* older API, a proxy's error page, another organisation's answer — is null,
|
|
921
|
+
* and the card shows no role line.
|
|
1071
922
|
*/
|
|
1072
|
-
function
|
|
1073
|
-
if (!
|
|
1074
|
-
return
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
923
|
+
function parseMyRoles(payload, organizationId) {
|
|
924
|
+
if (!isRecord(payload))
|
|
925
|
+
return null;
|
|
926
|
+
// Envelope { success, data } or the bare data.
|
|
927
|
+
const data = isRecord(payload.data) ? payload.data : payload;
|
|
928
|
+
if (!Array.isArray(data.roles))
|
|
929
|
+
return null;
|
|
930
|
+
if (typeof data.organization_id === "string" && data.organization_id !== organizationId) {
|
|
931
|
+
return null;
|
|
932
|
+
}
|
|
933
|
+
const roles = [];
|
|
934
|
+
const seen = new Set();
|
|
935
|
+
for (const r of data.roles) {
|
|
936
|
+
if (!isRecord(r))
|
|
937
|
+
continue;
|
|
938
|
+
const name = typeof r.name_th === "string" ? r.name_th.trim() : "";
|
|
939
|
+
const key = typeof r.key === "string" ? r.key : "";
|
|
940
|
+
// A role with no name is left out, never shown by its key.
|
|
941
|
+
if (!name || seen.has(name))
|
|
942
|
+
continue;
|
|
943
|
+
seen.add(name);
|
|
944
|
+
roles.push({
|
|
945
|
+
key,
|
|
946
|
+
name_th: name,
|
|
947
|
+
app: typeof r.app === "string" ? r.app : "",
|
|
948
|
+
kind: typeof r.kind === "string" ? r.kind : "",
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
return { organizationId, isOwner: data.is_owner === true, roles };
|
|
952
|
+
}
|
|
953
|
+
/** The card names the first two roles and counts the rest. */
|
|
954
|
+
function cardRoleLine(roles, limit = CARD_ROLE_LIMIT) {
|
|
955
|
+
const names = roles.map(r => r.name_th);
|
|
956
|
+
return { shown: names.slice(0, limit), more: Math.max(0, names.length - limit) };
|
|
957
|
+
}
|
|
958
|
+
/** Everything, for the tooltip: "เจ้าของ · ผู้ดูแลสูงสุด · พนักงานทั่วไป". */
|
|
959
|
+
function roleSummary(my) {
|
|
960
|
+
return [...(my.isOwner ? [OWNER_CHIP] : []), ...my.roles.map(r => r.name_th)].join(" · ");
|
|
961
|
+
}
|
|
962
|
+
/** Whether there is anything to show at all. */
|
|
963
|
+
function hasRoleLine(my) {
|
|
964
|
+
return !!my && (my.isOwner || my.roles.length > 0);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
const TOKEN_TTL_MS = 30000;
|
|
968
|
+
let cachedToken = null;
|
|
969
|
+
let cachedAt = 0;
|
|
970
|
+
let inFlightTokenPromise = null;
|
|
971
|
+
const readSessionToken = async () => {
|
|
972
|
+
const response = await fetch("/api/auth/session", {
|
|
973
|
+
credentials: "include",
|
|
974
|
+
cache: "no-store",
|
|
975
|
+
});
|
|
976
|
+
if (!response.ok)
|
|
977
|
+
return null;
|
|
978
|
+
const session = (await response.json());
|
|
979
|
+
return session.accessToken ?? null;
|
|
980
|
+
};
|
|
981
|
+
const getAccessToken = async (forceRefresh = false) => {
|
|
982
|
+
const now = Date.now();
|
|
983
|
+
if (!forceRefresh && cachedToken && now - cachedAt < TOKEN_TTL_MS) {
|
|
984
|
+
return cachedToken;
|
|
985
|
+
}
|
|
986
|
+
if (!inFlightTokenPromise) {
|
|
987
|
+
inFlightTokenPromise = readSessionToken()
|
|
988
|
+
.then(token => {
|
|
989
|
+
cachedToken = token;
|
|
990
|
+
cachedAt = Date.now();
|
|
991
|
+
return token;
|
|
992
|
+
})
|
|
993
|
+
.finally(() => {
|
|
994
|
+
inFlightTokenPromise = null;
|
|
995
|
+
});
|
|
996
|
+
}
|
|
997
|
+
return inFlightTokenPromise;
|
|
998
|
+
};
|
|
999
|
+
const clearAccessTokenCache = () => {
|
|
1000
|
+
cachedToken = null;
|
|
1001
|
+
cachedAt = 0;
|
|
1002
|
+
};
|
|
1003
|
+
|
|
1004
|
+
class ApiError extends Error {
|
|
1005
|
+
constructor(message, options) {
|
|
1006
|
+
super(message);
|
|
1007
|
+
Object.defineProperty(this, "status", {
|
|
1008
|
+
enumerable: true,
|
|
1009
|
+
configurable: true,
|
|
1010
|
+
writable: true,
|
|
1011
|
+
value: void 0
|
|
1012
|
+
});
|
|
1013
|
+
Object.defineProperty(this, "code", {
|
|
1014
|
+
enumerable: true,
|
|
1015
|
+
configurable: true,
|
|
1016
|
+
writable: true,
|
|
1017
|
+
value: void 0
|
|
1018
|
+
});
|
|
1019
|
+
Object.defineProperty(this, "field", {
|
|
1020
|
+
enumerable: true,
|
|
1021
|
+
configurable: true,
|
|
1022
|
+
writable: true,
|
|
1023
|
+
value: void 0
|
|
1024
|
+
});
|
|
1025
|
+
Object.defineProperty(this, "details", {
|
|
1026
|
+
enumerable: true,
|
|
1027
|
+
configurable: true,
|
|
1028
|
+
writable: true,
|
|
1029
|
+
value: void 0
|
|
1030
|
+
});
|
|
1031
|
+
this.name = "ApiError";
|
|
1032
|
+
this.status = options?.status;
|
|
1033
|
+
this.code = options?.code;
|
|
1034
|
+
this.field = options?.field;
|
|
1035
|
+
this.details = options?.details;
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
const toApiError = (error) => {
|
|
1039
|
+
if (error instanceof ApiError)
|
|
1040
|
+
return error;
|
|
1041
|
+
if (axios.isAxiosError(error)) {
|
|
1042
|
+
const axiosErr = error;
|
|
1043
|
+
const message = axiosErr.response?.data?.message ||
|
|
1044
|
+
axiosErr.response?.data?.error ||
|
|
1045
|
+
axiosErr.message ||
|
|
1046
|
+
"Request failed";
|
|
1047
|
+
return new ApiError(message, {
|
|
1048
|
+
status: axiosErr.response?.status,
|
|
1049
|
+
code: axiosErr.response?.data?.code,
|
|
1050
|
+
field: axiosErr.response?.data?.field,
|
|
1051
|
+
details: axiosErr.response?.data?.details,
|
|
1052
|
+
});
|
|
1053
|
+
}
|
|
1054
|
+
if (error instanceof Error) {
|
|
1055
|
+
return new ApiError(error.message);
|
|
1056
|
+
}
|
|
1057
|
+
return new ApiError(String(error));
|
|
1058
|
+
};
|
|
1059
|
+
|
|
1060
|
+
const isPlainObject = (value) => Object.prototype.toString.call(value) === "[object Object]";
|
|
1061
|
+
const visit = (value, cb) => {
|
|
1062
|
+
if (Array.isArray(value)) {
|
|
1063
|
+
value.forEach(item => visit(item, cb));
|
|
1064
|
+
return;
|
|
1065
|
+
}
|
|
1066
|
+
if (!isPlainObject(value)) {
|
|
1067
|
+
return;
|
|
1068
|
+
}
|
|
1069
|
+
cb(value);
|
|
1070
|
+
Object.values(value).forEach(item => visit(item, cb));
|
|
1071
|
+
};
|
|
1072
|
+
const validateGoodsReceiptChecks = (data) => {
|
|
1073
|
+
let invalidPath = null;
|
|
1074
|
+
const isCheckStatus = (v) => v === undefined || v === null || v === "pass" || v === "fail";
|
|
1075
|
+
const isCompareStatus = (v) => v === undefined || v === null || v === "match" || v === "mismatch";
|
|
1076
|
+
visit(data, node => {
|
|
1077
|
+
if (invalidPath)
|
|
1078
|
+
return;
|
|
1079
|
+
if ("quantity_check" in node && !isCheckStatus(node.quantity_check)) {
|
|
1080
|
+
invalidPath = "quantity_check";
|
|
1081
|
+
return;
|
|
1082
|
+
}
|
|
1083
|
+
if ("quality_check" in node && !isCheckStatus(node.quality_check)) {
|
|
1084
|
+
invalidPath = "quality_check";
|
|
1085
|
+
return;
|
|
1086
|
+
}
|
|
1087
|
+
if ("compare_with_reference" in node &&
|
|
1088
|
+
!isCompareStatus(node.compare_with_reference)) {
|
|
1089
|
+
invalidPath = "compare_with_reference";
|
|
1090
|
+
}
|
|
1091
|
+
});
|
|
1092
|
+
if (invalidPath) {
|
|
1093
|
+
return {
|
|
1094
|
+
valid: false,
|
|
1095
|
+
message: "รูปแบบข้อมูลไม่ถูกต้อง: ฟิลด์ตรวจรับต้องเป็น enum string (quantity/quality: pass|fail, compare: match|mismatch)",
|
|
1096
|
+
};
|
|
1097
|
+
}
|
|
1098
|
+
return { valid: true };
|
|
1099
|
+
};
|
|
1100
|
+
const runGlobalRequestPrecheck = (config) => {
|
|
1101
|
+
const method = (config.method || "get").toLowerCase();
|
|
1102
|
+
const isWriteMethod = ["post", "put", "patch"].includes(method);
|
|
1103
|
+
if (!isWriteMethod || config.data === undefined || config.data === null) {
|
|
1104
|
+
return { valid: true };
|
|
1105
|
+
}
|
|
1106
|
+
if (typeof FormData !== "undefined" && config.data instanceof FormData) {
|
|
1107
|
+
return { valid: true };
|
|
1108
|
+
}
|
|
1109
|
+
return validateGoodsReceiptChecks(config.data);
|
|
1110
|
+
};
|
|
1111
|
+
|
|
1112
|
+
const isBrowser = () => typeof window !== "undefined";
|
|
1113
|
+
const isAuthEndpoint = (url) => !!url && url.includes("/api/auth/");
|
|
1114
|
+
const isAuthPage = () => isBrowser() && window.location.pathname.startsWith("/auth/");
|
|
1115
|
+
const getOrgFromCookie = () => {
|
|
1116
|
+
if (!isBrowser())
|
|
1117
|
+
return null;
|
|
1118
|
+
const cookies = document.cookie.split(";").map(cookie => cookie.trim());
|
|
1119
|
+
const orgCookie = cookies.find(cookie => cookie.startsWith("erp.organizationId="));
|
|
1120
|
+
if (!orgCookie)
|
|
1121
|
+
return null;
|
|
1122
|
+
const raw = orgCookie.substring("erp.organizationId=".length);
|
|
1123
|
+
return raw ? decodeURIComponent(raw) : null;
|
|
1124
|
+
};
|
|
1125
|
+
const redirectToLogout = async () => {
|
|
1126
|
+
if (!isBrowser() || isAuthPage())
|
|
1127
|
+
return;
|
|
1128
|
+
localStorage.clear();
|
|
1129
|
+
sessionStorage.clear();
|
|
1130
|
+
setOrganizationId(null);
|
|
1131
|
+
clearAccessTokenCache();
|
|
1132
|
+
await react.signOut({ callbackUrl: "/auth/signin" });
|
|
1133
|
+
};
|
|
1134
|
+
const instance = axios.create({
|
|
1135
|
+
baseURL: process.env.NEXT_PUBLIC_API_URL,
|
|
1136
|
+
headers: { "Content-Type": "application/json" },
|
|
1137
|
+
paramsSerializer: params => {
|
|
1138
|
+
const searchParams = new URLSearchParams();
|
|
1139
|
+
Object.entries(params || {}).forEach(([key, value]) => {
|
|
1140
|
+
if (Array.isArray(value)) {
|
|
1141
|
+
value.forEach(v => searchParams.append(key, String(v)));
|
|
1142
|
+
}
|
|
1143
|
+
else if (value !== undefined && value !== null) {
|
|
1144
|
+
searchParams.append(key, String(value));
|
|
1145
|
+
}
|
|
1146
|
+
});
|
|
1147
|
+
return searchParams.toString();
|
|
1148
|
+
},
|
|
1149
|
+
});
|
|
1150
|
+
instance.interceptors.request.use(async (config) => {
|
|
1151
|
+
if (!config.headers) {
|
|
1152
|
+
config.headers = new axios.AxiosHeaders();
|
|
1153
|
+
}
|
|
1154
|
+
if (config.data instanceof FormData && config.headers) {
|
|
1155
|
+
delete config.headers["Content-Type"];
|
|
1156
|
+
}
|
|
1157
|
+
if (!isBrowser())
|
|
1158
|
+
return config;
|
|
1159
|
+
const token = await getAccessToken(false);
|
|
1160
|
+
if (token) {
|
|
1161
|
+
config.headers.Authorization = `Bearer ${token}`;
|
|
1162
|
+
}
|
|
1163
|
+
// localStorage first, cookie second.
|
|
1164
|
+
//
|
|
1165
|
+
// Both are written by the same state machine, but not at the same moment:
|
|
1166
|
+
// setOrganizationId writes localStorage synchronously and then awaits a
|
|
1167
|
+
// server action for the cookie. Preferring the cookie meant every request
|
|
1168
|
+
// fired during that round trip carried the previous organisation — while
|
|
1169
|
+
// the query params, built from React state, already carried the new one.
|
|
1170
|
+
//
|
|
1171
|
+
// The backend scopes by this header and ignores organization_id in the
|
|
1172
|
+
// query, so those requests asked for organisation A and were handed B, and
|
|
1173
|
+
// the answer landed in the cache under A's key. No amount of cache clearing
|
|
1174
|
+
// reaches that: the entry is wrong, not stale.
|
|
1175
|
+
const organizationId = getOrganizationId() || getOrgFromCookie();
|
|
1176
|
+
if (organizationId) {
|
|
1177
|
+
config.headers["X-Organization-ID"] = organizationId;
|
|
1178
|
+
}
|
|
1179
|
+
const precheck = runGlobalRequestPrecheck(config);
|
|
1180
|
+
if (!precheck.valid) {
|
|
1181
|
+
throw new ApiError(precheck.message || "Invalid request payload");
|
|
1182
|
+
}
|
|
1183
|
+
return config;
|
|
1184
|
+
});
|
|
1185
|
+
let refreshPromise = null;
|
|
1186
|
+
const refreshTokenSingleFlight = async () => {
|
|
1187
|
+
if (!refreshPromise) {
|
|
1188
|
+
refreshPromise = getAccessToken(true).finally(() => {
|
|
1189
|
+
refreshPromise = null;
|
|
1190
|
+
});
|
|
1191
|
+
}
|
|
1192
|
+
return refreshPromise;
|
|
1193
|
+
};
|
|
1194
|
+
instance.interceptors.response.use(response => {
|
|
1195
|
+
// Any successful call means the session can act in this organisation
|
|
1196
|
+
// after all. Without this the gate, once raised, stayed up through a
|
|
1197
|
+
// whole working session — including the seconds between an administrator
|
|
1198
|
+
// adding somebody and the employee record existing.
|
|
1199
|
+
if (typeof window !== "undefined") {
|
|
1200
|
+
window.dispatchEvent(new CustomEvent("erp:employee-record-ok"));
|
|
1201
|
+
}
|
|
1202
|
+
return response;
|
|
1203
|
+
}, async (error) => {
|
|
1204
|
+
const originalRequest = error.config;
|
|
1205
|
+
const requestUrl = originalRequest?.url || error.config?.url;
|
|
1206
|
+
if (error.response?.status === 401 &&
|
|
1207
|
+
originalRequest &&
|
|
1208
|
+
!originalRequest._retry &&
|
|
1209
|
+
!isAuthEndpoint(requestUrl) &&
|
|
1210
|
+
!isAuthPage()) {
|
|
1211
|
+
originalRequest._retry = true;
|
|
1212
|
+
const newToken = await refreshTokenSingleFlight();
|
|
1213
|
+
if (newToken && originalRequest.headers) {
|
|
1214
|
+
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
|
1215
|
+
return instance(originalRequest);
|
|
1216
|
+
}
|
|
1217
|
+
await redirectToLogout();
|
|
1218
|
+
return Promise.reject(toApiError(error));
|
|
1219
|
+
}
|
|
1220
|
+
// The server refuses every organisation-scoped call when the account has
|
|
1221
|
+
// no employee record. Announce it once so a gate can explain it, instead
|
|
1222
|
+
// of letting each failed query raise its own toast.
|
|
1223
|
+
if (error.response?.status === 403) {
|
|
1224
|
+
const body = error.response.data;
|
|
1225
|
+
if (body?.code === "EMPLOYEE_RECORD_REQUIRED" && typeof window !== "undefined") {
|
|
1226
|
+
window.dispatchEvent(new CustomEvent("erp:employee-record-required"));
|
|
1227
|
+
}
|
|
1228
|
+
}
|
|
1229
|
+
return Promise.reject(toApiError(error));
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
/**
|
|
1233
|
+
* Where platform-api is, for the calls the shell itself makes to it (the
|
|
1234
|
+
* help channel, the user card's roles). Every app already calls it with the
|
|
1235
|
+
* user's token and the organisation header, and its CORS admits every app.
|
|
1236
|
+
*/
|
|
1237
|
+
const DEFAULT_PLATFORM_API_URL = "https://platform-api.antniti.com/api/v1";
|
|
1238
|
+
function platformApiBase(app) {
|
|
1239
|
+
const explicit = process.env.NEXT_PUBLIC_PLATFORM_API_URL?.trim();
|
|
1240
|
+
if (explicit)
|
|
1241
|
+
return explicit.replace(/\/$/, "");
|
|
1242
|
+
// Platform's own API is platform-api.
|
|
1243
|
+
if (app === "platform") {
|
|
1244
|
+
const own = process.env.NEXT_PUBLIC_API_URL?.trim();
|
|
1245
|
+
if (own)
|
|
1246
|
+
return own.replace(/\/$/, "");
|
|
1247
|
+
}
|
|
1248
|
+
return DEFAULT_PLATFORM_API_URL;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* This package's version, attached to help messages so the back office can
|
|
1253
|
+
* tell which shell a report came from. Keep in step with package.json.
|
|
1254
|
+
*/
|
|
1255
|
+
const SHELL_VERSION = "0.1.53";
|
|
1256
|
+
|
|
1257
|
+
const HELP_APP_LABEL = {
|
|
1258
|
+
platform: "แพลตฟอร์ม",
|
|
1259
|
+
accounx: "Accounx",
|
|
1260
|
+
peoplx: "Peoplx",
|
|
1261
|
+
plannex: "Plannex",
|
|
1262
|
+
invenx: "Invenx",
|
|
1263
|
+
other: "อื่น ๆ",
|
|
1264
|
+
};
|
|
1265
|
+
function originOf(value) {
|
|
1266
|
+
if (!value)
|
|
1267
|
+
return null;
|
|
1268
|
+
try {
|
|
1269
|
+
return new URL(value).origin;
|
|
1270
|
+
}
|
|
1271
|
+
catch {
|
|
1272
|
+
return null;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Which app this page is: what the host said, what the app declared to the
|
|
1277
|
+
* app switcher, then the production sub-domain, then the app URLs the build
|
|
1278
|
+
* was given (local development). "other" when none of them tells.
|
|
1279
|
+
*/
|
|
1280
|
+
function detectHelpApp(explicit) {
|
|
1281
|
+
if (explicit)
|
|
1282
|
+
return explicit;
|
|
1283
|
+
const declared = declaredHelpApp();
|
|
1284
|
+
if (declared)
|
|
1285
|
+
return declared;
|
|
1286
|
+
if (typeof window === "undefined")
|
|
1287
|
+
return "other";
|
|
1288
|
+
const host = window.location.hostname.toLowerCase();
|
|
1289
|
+
if (/^(accounx|erp|www\.erp)\./.test(host))
|
|
1290
|
+
return "accounx";
|
|
1291
|
+
if (/^(peoplx|hr)\./.test(host))
|
|
1292
|
+
return "peoplx";
|
|
1293
|
+
if (host.startsWith("plannex."))
|
|
1294
|
+
return "plannex";
|
|
1295
|
+
if (host.startsWith("invenx."))
|
|
1296
|
+
return "invenx";
|
|
1297
|
+
if (host.startsWith("platform.") || host === "antniti.com" || host === "www.antniti.com") {
|
|
1298
|
+
return "platform";
|
|
1299
|
+
}
|
|
1300
|
+
const here = window.location.origin;
|
|
1301
|
+
const configured = [
|
|
1302
|
+
["platform", process.env.NEXT_PUBLIC_PLATFORM_URL],
|
|
1303
|
+
["accounx", process.env.NEXT_PUBLIC_ACCOUNTING_URL],
|
|
1304
|
+
["invenx", process.env.NEXT_PUBLIC_INVENX_URL],
|
|
1305
|
+
["plannex", process.env.NEXT_PUBLIC_PLANNEX_URL],
|
|
1306
|
+
["peoplx", process.env.NEXT_PUBLIC_HR_URL],
|
|
1307
|
+
];
|
|
1308
|
+
for (const [id, url] of configured) {
|
|
1309
|
+
if (originOf(url) === here)
|
|
1310
|
+
return id;
|
|
1311
|
+
}
|
|
1312
|
+
return "other";
|
|
1313
|
+
}
|
|
1314
|
+
/** "Chrome 141" and "macOS" from a user agent — enough to reproduce. */
|
|
1315
|
+
function describeBrowser(ua) {
|
|
1316
|
+
const pick = (re) => ua.match(re)?.[1] ?? "";
|
|
1317
|
+
let browser = "";
|
|
1318
|
+
if (/\bLine\//i.test(ua))
|
|
1319
|
+
browser = `LINE ${pick(/\bLine\/([\d.]+)/i)}`;
|
|
1320
|
+
else if (/\bEdg(A|iOS)?\//.test(ua))
|
|
1321
|
+
browser = `Edge ${pick(/\bEdg(?:A|iOS)?\/(\d+)/)}`;
|
|
1322
|
+
else if (/\bOPR\//.test(ua))
|
|
1323
|
+
browser = `Opera ${pick(/\bOPR\/(\d+)/)}`;
|
|
1324
|
+
else if (/SamsungBrowser\//.test(ua))
|
|
1325
|
+
browser = `Samsung Internet ${pick(/SamsungBrowser\/(\d+)/)}`;
|
|
1326
|
+
else if (/\b(Firefox|FxiOS)\//.test(ua))
|
|
1327
|
+
browser = `Firefox ${pick(/\b(?:Firefox|FxiOS)\/(\d+)/)}`;
|
|
1328
|
+
else if (/\bCriOS\//.test(ua))
|
|
1329
|
+
browser = `Chrome ${pick(/\bCriOS\/(\d+)/)}`;
|
|
1330
|
+
else if (/(\b|Headless)Chrome\//.test(ua))
|
|
1331
|
+
browser = `Chrome ${pick(/(?:\b|Headless)Chrome\/(\d+)/)}`;
|
|
1332
|
+
else if (/\bSafari\//.test(ua))
|
|
1333
|
+
browser = `Safari ${pick(/\bVersion\/([\d.]+)/)}`;
|
|
1334
|
+
let platform = "";
|
|
1335
|
+
if (/\b(iPhone|iPad|iPod)\b/.test(ua)) {
|
|
1336
|
+
platform = `iOS ${pick(/\bOS (\d+[_\d]*)/).replace(/_/g, ".")}`;
|
|
1337
|
+
}
|
|
1338
|
+
else if (/\bAndroid\b/.test(ua)) {
|
|
1339
|
+
platform = `Android ${pick(/\bAndroid ([\d.]+)/)}`;
|
|
1340
|
+
}
|
|
1341
|
+
else if (/\bWindows\b/.test(ua)) {
|
|
1342
|
+
platform = "Windows";
|
|
1343
|
+
}
|
|
1344
|
+
else if (/\bMac OS X\b/.test(ua)) {
|
|
1345
|
+
platform = "macOS";
|
|
1346
|
+
}
|
|
1347
|
+
else if (/\bCrOS\b/.test(ua)) {
|
|
1348
|
+
platform = "ChromeOS";
|
|
1349
|
+
}
|
|
1350
|
+
else if (/\bLinux\b/.test(ua)) {
|
|
1351
|
+
platform = "Linux";
|
|
1352
|
+
}
|
|
1353
|
+
return { browser: browser.trim(), platform: platform.trim() };
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* The deployed build: NEXT_PUBLIC_APP_VERSION when the app sets one, else
|
|
1357
|
+
* the Next build id in the page's own script paths (the build time, as every
|
|
1358
|
+
* app's generateBuildId writes it).
|
|
1359
|
+
*/
|
|
1360
|
+
function readAppVersion() {
|
|
1361
|
+
const declared = process.env.NEXT_PUBLIC_APP_VERSION?.trim();
|
|
1362
|
+
if (declared)
|
|
1363
|
+
return declared;
|
|
1364
|
+
if (typeof document === "undefined")
|
|
1365
|
+
return "";
|
|
1366
|
+
const ignore = new Set(["chunks", "css", "media", "development", "webpack"]);
|
|
1367
|
+
for (const s of Array.from(document.querySelectorAll("script[src]"))) {
|
|
1368
|
+
const m = s.src.match(/\/_next\/static\/([^/]+)\//);
|
|
1369
|
+
if (m && !ignore.has(m[1]))
|
|
1370
|
+
return m[1];
|
|
1371
|
+
}
|
|
1372
|
+
return "";
|
|
1373
|
+
}
|
|
1374
|
+
function pageUrl() {
|
|
1375
|
+
if (typeof window === "undefined")
|
|
1376
|
+
return "";
|
|
1377
|
+
const { origin, pathname, search } = window.location;
|
|
1378
|
+
return `${origin}${pathname}${search}`;
|
|
1379
|
+
}
|
|
1380
|
+
/** Everything attached to a message, read at the moment it is sent. */
|
|
1381
|
+
function collectHelpContext(orgCode) {
|
|
1382
|
+
if (typeof window === "undefined")
|
|
1383
|
+
return { shell_version: SHELL_VERSION };
|
|
1384
|
+
const { browser, platform } = describeBrowser(window.navigator.userAgent || "");
|
|
1385
|
+
let timeZone = "";
|
|
1386
|
+
try {
|
|
1387
|
+
timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone || "";
|
|
1388
|
+
}
|
|
1389
|
+
catch {
|
|
1390
|
+
timeZone = "";
|
|
1391
|
+
}
|
|
1392
|
+
const ctx = {
|
|
1393
|
+
page_url: pageUrl(),
|
|
1394
|
+
page_title: document.title || undefined,
|
|
1395
|
+
org_code: orgCode || undefined,
|
|
1396
|
+
app_version: readAppVersion() || undefined,
|
|
1397
|
+
shell_version: SHELL_VERSION,
|
|
1398
|
+
browser: browser || undefined,
|
|
1399
|
+
platform: platform || undefined,
|
|
1400
|
+
screen: `${window.screen.width}x${window.screen.height}`,
|
|
1401
|
+
viewport: `${window.innerWidth}x${window.innerHeight}`,
|
|
1402
|
+
language: window.navigator.language || undefined,
|
|
1403
|
+
time_zone: timeZone || undefined,
|
|
1404
|
+
};
|
|
1405
|
+
return ctx;
|
|
1406
|
+
}
|
|
1407
|
+
/** The short line under the form, "ข้อมูลที่แนบ: …". */
|
|
1408
|
+
function describeAttached(app, ctx, email) {
|
|
1409
|
+
let path = "";
|
|
1410
|
+
if (ctx.page_url) {
|
|
1411
|
+
try {
|
|
1412
|
+
path = new URL(ctx.page_url).pathname;
|
|
1413
|
+
}
|
|
1414
|
+
catch {
|
|
1415
|
+
path = ctx.page_url;
|
|
1416
|
+
}
|
|
1417
|
+
}
|
|
1418
|
+
// The organisation code is shown on its own; the path after it says where.
|
|
1419
|
+
if (ctx.org_code && path.startsWith(`/${ctx.org_code}`)) {
|
|
1420
|
+
path = path.slice(ctx.org_code.length + 1);
|
|
1421
|
+
}
|
|
1422
|
+
if (path === "/")
|
|
1423
|
+
path = "";
|
|
1424
|
+
const browser = [ctx.browser, ctx.platform && `(${ctx.platform})`].filter(Boolean).join(" ");
|
|
1425
|
+
return [
|
|
1426
|
+
HELP_APP_LABEL[app],
|
|
1427
|
+
ctx.org_code,
|
|
1428
|
+
path,
|
|
1429
|
+
email || undefined,
|
|
1430
|
+
browser || undefined,
|
|
1431
|
+
ctx.app_version,
|
|
1432
|
+
]
|
|
1433
|
+
.filter(Boolean)
|
|
1434
|
+
.join(" · ");
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
const myRolesQueryKey = (organizationId) => ["erp-shell", "my-roles", organizationId];
|
|
1438
|
+
// Only for a host that renders NavUser outside a QueryClientProvider: the
|
|
1439
|
+
// card still works there instead of throwing.
|
|
1440
|
+
let fallbackClient = null;
|
|
1441
|
+
function useShellQueryClient() {
|
|
1442
|
+
const provided = React.useContext(reactQuery.QueryClientContext);
|
|
1443
|
+
if (provided)
|
|
1444
|
+
return provided;
|
|
1445
|
+
fallbackClient ?? (fallbackClient = new reactQuery.QueryClient());
|
|
1446
|
+
return fallbackClient;
|
|
1447
|
+
}
|
|
1448
|
+
function subscribeStorage(onChange) {
|
|
1449
|
+
window.addEventListener("storage", onChange);
|
|
1450
|
+
return () => window.removeEventListener("storage", onChange);
|
|
1451
|
+
}
|
|
1452
|
+
/**
|
|
1453
|
+
* The organisation the API client sends (X-Organization-ID). Null while
|
|
1454
|
+
* server-rendering and hydrating, so the first client render matches the
|
|
1455
|
+
* server's.
|
|
1456
|
+
*/
|
|
1457
|
+
function useStoredOrganizationId() {
|
|
1458
|
+
return React.useSyncExternalStore(subscribeStorage, getOrganizationId, () => null);
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* The signed-in person's roles in an organisation, from platform-api
|
|
1462
|
+
* (GET /organizations/:id/my-roles). Cached per organisation; a different
|
|
1463
|
+
* organisation is a different query. A failure, or an API too old to have
|
|
1464
|
+
* the route, leaves `data` empty — the caller shows nothing.
|
|
1465
|
+
*/
|
|
1466
|
+
function useMyRoles(organizationId, enabled = true) {
|
|
1467
|
+
const orgId = organizationId !== null &&
|
|
1468
|
+
organizationId !== undefined &&
|
|
1469
|
+
String(organizationId).trim() !== ""
|
|
1470
|
+
? String(organizationId).trim()
|
|
1471
|
+
: null;
|
|
1472
|
+
const client = useShellQueryClient();
|
|
1473
|
+
return reactQuery.useQuery({
|
|
1474
|
+
queryKey: myRolesQueryKey(orgId),
|
|
1475
|
+
queryFn: async ({ signal }) => {
|
|
1476
|
+
const base = platformApiBase(detectHelpApp());
|
|
1477
|
+
const res = await instance.get(`${base}/organizations/${encodeURIComponent(orgId)}/my-roles`, { signal });
|
|
1478
|
+
return parseMyRoles(res.data, orgId);
|
|
1479
|
+
},
|
|
1480
|
+
enabled: enabled && orgId !== null,
|
|
1481
|
+
staleTime: 60000,
|
|
1482
|
+
gcTime: 30 * 60000,
|
|
1483
|
+
// A refusal or a missing route will not be different a second later;
|
|
1484
|
+
// the next focus or organisation change asks again.
|
|
1485
|
+
retry: false,
|
|
1486
|
+
refetchOnWindowFocus: true,
|
|
1487
|
+
}, client);
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
/** The owner's chip: the app's accent, tinted. */
|
|
1491
|
+
function OwnerChip({ size = "card" }) {
|
|
1492
|
+
return (jsxRuntime.jsx("span", { className: size === "menu"
|
|
1493
|
+
? "inline-flex shrink-0 items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary"
|
|
1494
|
+
: "inline-flex shrink-0 items-center rounded-full bg-primary/10 px-1.5 text-[11px] font-semibold leading-[17px] text-primary", children: OWNER_CHIP }));
|
|
1495
|
+
}
|
|
1496
|
+
/**
|
|
1497
|
+
* The card's role line: the owner's chip, then two role names and a count of
|
|
1498
|
+
* the rest. Wraps between names rather than inside one, and truncates a name
|
|
1499
|
+
* that is wider than the card on its own; the tooltip has everything.
|
|
1500
|
+
*/
|
|
1501
|
+
function CardRoles({ my, className }) {
|
|
1502
|
+
const { shown, more } = cardRoleLine(my.roles);
|
|
1503
|
+
return (jsxRuntime.jsxs("span", { className: `flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5 ${className}`, title: roleSummary(my), children: [my.isOwner ? jsxRuntime.jsx(OwnerChip, {}) : null, shown.map((name, i) => {
|
|
1504
|
+
const last = i === shown.length - 1;
|
|
1505
|
+
return (jsxRuntime.jsxs("span", { className: "flex min-w-0 max-w-full items-baseline", children: [jsxRuntime.jsx("span", { className: "truncate", children: name }), !last ? (jsxRuntime.jsx("span", { "aria-hidden": "true", className: "shrink-0 pl-1 opacity-60", children: "\u00B7" })) : null, last && more > 0 ? (jsxRuntime.jsxs("span", { className: "shrink-0 pl-1 opacity-70", children: ["+", more] })) : null] }, name));
|
|
1506
|
+
})] }));
|
|
1507
|
+
}
|
|
1508
|
+
/** Every role in full, for the menu. */
|
|
1509
|
+
function MenuRoles({ my }) {
|
|
1510
|
+
return (jsxRuntime.jsxs("div", { className: "flex flex-wrap gap-1.5", children: [my.isOwner ? jsxRuntime.jsx(OwnerChip, { size: "menu" }) : null, my.roles.map(r => (jsxRuntime.jsx("span", { className: "rounded-md border border-sidebar-border px-2 py-0.5 text-xs text-sidebar-foreground", children: r.name_th }, r.key || r.name_th)))] }));
|
|
1511
|
+
}
|
|
1512
|
+
function isExternalHref(href) {
|
|
1513
|
+
return /^https?:\/\//i.test(href) || href.startsWith("//");
|
|
1514
|
+
}
|
|
1515
|
+
function NavUser({ organization, avatarSrc, profileHref, selectOrganizationHref = "/select-organization", signInRedirectHref = "/auth/signin", systemLinks, showSelectOrganization = true, showLanguageSwitch = true, variant = "brand", showRoles, organizationId, } = {}) {
|
|
1516
|
+
const { data: session, status } = react.useSession();
|
|
1517
|
+
const storedOrganizationId = useStoredOrganizationId();
|
|
1518
|
+
const rolesOrganizationId = organizationId !== null &&
|
|
1519
|
+
organizationId !== undefined &&
|
|
1520
|
+
String(organizationId).trim() !== ""
|
|
1521
|
+
? organizationId
|
|
1522
|
+
: storedOrganizationId;
|
|
1523
|
+
const rolesOn = (showRoles ?? showSelectOrganization) && status === "authenticated";
|
|
1524
|
+
const { data: rolesData, fetchStatus: rolesFetchStatus } = useMyRoles(rolesOrganizationId, rolesOn);
|
|
1525
|
+
const myRoles = rolesOn && hasRoleLine(rolesData) ? rolesData : null;
|
|
1526
|
+
// First load only: hold the line's place so the card does not jump when
|
|
1527
|
+
// the roles arrive. A failure drops it.
|
|
1528
|
+
const rolesLoading = rolesOn && !!rolesOrganizationId && rolesData === undefined && rolesFetchStatus === "fetching";
|
|
1529
|
+
// With a role line the card is three lines or more: the chevron moves up
|
|
1530
|
+
// to the name so the lines under it get the card's full width.
|
|
1531
|
+
const cardTall = !!myRoles || rolesLoading;
|
|
1532
|
+
const router = navigation.useRouter();
|
|
1533
|
+
const { theme, setTheme } = nextThemes.useTheme();
|
|
1534
|
+
const [isSigningOut, setIsSigningOut] = React.useState(false);
|
|
1535
|
+
const [isOpen, setIsOpen] = React.useState(false);
|
|
1536
|
+
const [showThemeMenu, setShowThemeMenu] = React.useState(false);
|
|
1537
|
+
const [showLanguageMenu, setShowLanguageMenu] = React.useState(false);
|
|
1538
|
+
const [showAppsMenu, setShowAppsMenu] = React.useState(false);
|
|
1539
|
+
const buttonRef = React.useRef(null);
|
|
1540
|
+
const [position, setPosition] = React.useState({ top: 0, left: 0 });
|
|
1541
|
+
// Shown where a layout has mounted the help dialog (AppLayoutShell).
|
|
1542
|
+
const helpAvailable = useHelpAvailable();
|
|
1543
|
+
const user = session?.user
|
|
1544
|
+
? {
|
|
1545
|
+
name: session.user.name || "ผู้ใช้",
|
|
1546
|
+
email: session.user.email || "",
|
|
1547
|
+
// avatarSrc first: the NextAuth session carries no picture unless the
|
|
1548
|
+
// host app deliberately puts one in the JWT, so relying on
|
|
1549
|
+
// session.user.image alone left every user on their initials forever.
|
|
1550
|
+
avatar: avatarSrc || session.user.image || undefined,
|
|
1551
|
+
}
|
|
1552
|
+
: null;
|
|
1553
|
+
const getInitials = (name) => {
|
|
1554
|
+
if (!name)
|
|
1555
|
+
return "U";
|
|
1556
|
+
return name
|
|
1557
|
+
.split(" ")
|
|
1558
|
+
.map(n => n[0])
|
|
1559
|
+
.join("")
|
|
1560
|
+
.toUpperCase()
|
|
1561
|
+
.slice(0, 2);
|
|
1562
|
+
};
|
|
1563
|
+
const handleSignOut = async () => {
|
|
1564
|
+
setIsSigningOut(true);
|
|
1565
|
+
try {
|
|
1566
|
+
await react.signOut({ callbackUrl: signInRedirectHref, redirect: true });
|
|
1567
|
+
}
|
|
1568
|
+
catch (error) {
|
|
1569
|
+
console.error("Sign out error:", error);
|
|
1570
|
+
window.location.href = signInRedirectHref;
|
|
1571
|
+
}
|
|
1572
|
+
finally {
|
|
1573
|
+
setIsSigningOut(false);
|
|
1574
|
+
}
|
|
1575
|
+
};
|
|
1576
|
+
const handleSelectOrganization = () => {
|
|
1577
|
+
setIsOpen(false);
|
|
1578
|
+
router.push(selectOrganizationHref);
|
|
1579
|
+
};
|
|
1580
|
+
const handleProfile = () => {
|
|
1581
|
+
setIsOpen(false);
|
|
1582
|
+
if (profileHref)
|
|
1583
|
+
router.push(profileHref);
|
|
1584
|
+
};
|
|
1585
|
+
const handleSystemLink = (href) => {
|
|
1586
|
+
setIsOpen(false);
|
|
1587
|
+
if (isExternalHref(href)) {
|
|
1588
|
+
window.location.assign(href);
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
router.push(href);
|
|
1592
|
+
};
|
|
1593
|
+
const handleThemeChange = (newTheme) => {
|
|
1594
|
+
setTheme(newTheme);
|
|
1595
|
+
setShowThemeMenu(false);
|
|
1596
|
+
};
|
|
1597
|
+
const handleLanguageChange = (language) => {
|
|
1598
|
+
console.log("Change language to:", language);
|
|
1599
|
+
setShowLanguageMenu(false);
|
|
1600
|
+
};
|
|
1601
|
+
React.useEffect(() => {
|
|
1602
|
+
if (isOpen && buttonRef.current) {
|
|
1603
|
+
const rect = buttonRef.current.getBoundingClientRect();
|
|
1604
|
+
setPosition({ top: rect.bottom, left: rect.right + 8 });
|
|
1605
|
+
}
|
|
1606
|
+
}, [isOpen]);
|
|
1607
|
+
React.useEffect(() => {
|
|
1608
|
+
if (!isOpen)
|
|
1609
|
+
return;
|
|
1610
|
+
const handleClickOutside = (event) => {
|
|
1611
|
+
if (buttonRef.current &&
|
|
1612
|
+
!buttonRef.current.contains(event.target) &&
|
|
1613
|
+
!event.target.closest('[data-dropdown="nav-user"]')) {
|
|
1614
|
+
setIsOpen(false);
|
|
1615
|
+
}
|
|
1616
|
+
};
|
|
1617
|
+
document.addEventListener("mousedown", handleClickOutside);
|
|
1618
|
+
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
1619
|
+
}, [isOpen]);
|
|
1620
|
+
if (status === "loading") {
|
|
1621
|
+
return (jsxRuntime.jsx("div", { className: "p-2", children: jsxRuntime.jsxs("div", { className: "flex items-center gap-2 p-2 rounded-md", children: [jsxRuntime.jsx("div", { className: "h-8 w-8 rounded-lg bg-sidebar-accent animate-pulse" }), jsxRuntime.jsxs("div", { className: "flex-1 space-y-1", children: [jsxRuntime.jsx("div", { className: "h-4 w-24 bg-sidebar-accent rounded animate-pulse" }), jsxRuntime.jsx("div", { className: "h-3 w-32 bg-sidebar-accent rounded animate-pulse" })] })] }) }));
|
|
1622
|
+
}
|
|
1623
|
+
if (!user)
|
|
1624
|
+
return null;
|
|
1625
|
+
const visibleSystemLinks = (systemLinks ?? []).filter(link => link.href && link.label);
|
|
1626
|
+
const dropdownContent = isOpen ? (jsxRuntime.jsxs("div", { "data-dropdown": "nav-user", className: "fixed w-52 sm:w-60 md:w-80 rounded-lg bg-sidebar border border-sidebar-border shadow-xl z-50 overflow-hidden\n -translate-x-[65%] -translate-y-[110%] sm:translate-x-0 sm:-translate-y-full", style: { top: `${position.top}px`, left: `${position.left}px` }, children: [organization && (jsxRuntime.jsx("div", { className: "p-4 border-b border-sidebar-border bg-sidebar-accent/30", children: jsxRuntime.jsxs("div", { className: `flex gap-3 ${myRoles ? "items-start" : "items-center"}`, children: [organization.logoUrl ? (
|
|
1627
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
1628
|
+
jsxRuntime.jsx("img", { src: organization.logoUrl, alt: organization.name ?? "โลโก้บริษัท", className: "h-8 w-8 flex-shrink-0 rounded object-contain bg-white",
|
|
1629
|
+
// A dead logo URL must not leave a broken-image glyph in the
|
|
1630
|
+
// sidebar; drop back to the generic icon's empty slot.
|
|
1631
|
+
onError: e => {
|
|
1632
|
+
e.currentTarget.style.display = "none";
|
|
1633
|
+
} })) : (jsxRuntime.jsx(lucideReact.Building2, { className: "h-5 w-5 text-sidebar-foreground/70 flex-shrink-0" })), jsxRuntime.jsxs("div", { className: "flex-1 min-w-0", children: [jsxRuntime.jsx("div", { className: "font-medium text-sm sm:truncate", children: organization.name }), organization.description && (jsxRuntime.jsx("div", { className: "text-xs text-sidebar-foreground/70 truncate mt-0.5", children: organization.description })), myRoles ? (jsxRuntime.jsx("div", { className: "mt-2.5", children: jsxRuntime.jsx(MenuRoles, { my: myRoles }) })) : null] })] }) })), !organization && myRoles ? (jsxRuntime.jsx("div", { className: "p-4 border-b border-sidebar-border bg-sidebar-accent/30", children: jsxRuntime.jsx(MenuRoles, { my: myRoles }) })) : null, jsxRuntime.jsxs("div", { className: "p-2", children: [jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsxs("button", { onClick: () => {
|
|
1634
|
+
setShowThemeMenu(!showThemeMenu);
|
|
1635
|
+
setShowLanguageMenu(false);
|
|
1636
|
+
setShowAppsMenu(false);
|
|
1637
|
+
}, 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.Palette, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E18\u0E35\u0E21" }), jsxRuntime.jsx(lucideReact.ChevronUp, { className: `h-4 w-4 transition-transform ${showThemeMenu ? "rotate-180" : ""}` })] }), showThemeMenu && (jsxRuntime.jsx("div", { className: "mt-1 ml-4 space-y-1", children: [
|
|
1638
|
+
{ key: "light", label: "สว่าง", Icon: lucideReact.Sun },
|
|
1639
|
+
{ key: "dark", label: "มืด", Icon: lucideReact.Moon },
|
|
1640
|
+
{ key: "system", label: "ตามระบบ", Icon: lucideReact.Monitor },
|
|
1641
|
+
].map(({ key, label, Icon }) => (jsxRuntime.jsxs("button", { onClick: () => handleThemeChange(key), 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: [jsxRuntime.jsx(Icon, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: label }), theme === key && (jsxRuntime.jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, key))) }))] }), showLanguageSwitch && (jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsxs("button", { onClick: () => {
|
|
1642
|
+
setShowLanguageMenu(!showLanguageMenu);
|
|
1643
|
+
setShowThemeMenu(false);
|
|
1644
|
+
setShowAppsMenu(false);
|
|
1645
|
+
}, 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.Globe, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E20\u0E32\u0E29\u0E32" }), jsxRuntime.jsx(lucideReact.ChevronUp, { className: `h-4 w-4 transition-transform ${showLanguageMenu ? "rotate-180" : ""}` })] }), showLanguageMenu && (jsxRuntime.jsx("div", { className: "mt-1 ml-4 space-y-1", children: [
|
|
1646
|
+
{ code: "th", label: "ไทย" },
|
|
1647
|
+
{ code: "en", label: "English" },
|
|
1648
|
+
{ code: "zh", label: "中文" },
|
|
1649
|
+
{ code: "ja", label: "日本語" },
|
|
1650
|
+
].map(({ code, label }) => (jsxRuntime.jsxs("button", { onClick: () => handleLanguageChange(code), 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: [jsxRuntime.jsx("span", { className: "flex-1 text-left", children: label }), organization?.language === code && (jsxRuntime.jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, code))) }))] })), profileHref && (jsxRuntime.jsxs("button", { onClick: handleProfile, 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.User, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E2A\u0E48\u0E27\u0E19\u0E15\u0E31\u0E27" })] })), showSelectOrganization && (jsxRuntime.jsxs("button", { onClick: handleSelectOrganization, 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.Building2, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E25\u0E37\u0E2D\u0E01\u0E18\u0E38\u0E23\u0E01\u0E34\u0E08" })] })), visibleSystemLinks.length > 0 && (jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsxs("button", { onClick: () => {
|
|
1651
|
+
setShowAppsMenu(!showAppsMenu);
|
|
1652
|
+
setShowThemeMenu(false);
|
|
1653
|
+
setShowLanguageMenu(false);
|
|
1654
|
+
}, 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: () => {
|
|
1655
|
+
setIsOpen(false);
|
|
1656
|
+
openHelp();
|
|
1657
|
+
}, 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;
|
|
1658
|
+
if (variant === "workspace") {
|
|
1659
|
+
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: `relative flex w-full 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 ${cardTall ? "items-start" : "items-center"}`, 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 ${cardTall ? "gap-0.5" : ""}`, children: [jsxRuntime.jsx("strong", { className: `truncate text-[13px] font-semibold ${cardTall ? "pr-5" : ""}`, title: user.name, children: user.name }), myRoles ? (jsxRuntime.jsx(CardRoles, { my: myRoles, className: "text-[12px] font-medium leading-[17px] text-sidebar-foreground/80" })) : rolesLoading ? (jsxRuntime.jsx("span", { className: "my-[3px] block h-[11px] w-20 animate-pulse rounded bg-sidebar-accent" })) : null, jsxRuntime.jsx("small", { className: "truncate text-[11px] text-muted-foreground", title: user.email, children: user.email })] }), jsxRuntime.jsx(lucideReact.ChevronsUpDown, { className: cardTall
|
|
1660
|
+
? "absolute right-3 top-[9px] size-3.5 text-muted-foreground"
|
|
1661
|
+
: "size-3.5 shrink-0 text-muted-foreground" })] }), typeof window !== "undefined" &&
|
|
1662
|
+
reactDom.createPortal(dropdownContent, document.body)] }) }));
|
|
1663
|
+
}
|
|
1664
|
+
return (jsxRuntime.jsx("div", { className: "p-2", children: jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsxs("button", { ref: buttonRef, onClick: () => setIsOpen(!isOpen), className: `relative flex w-full gap-2 p-2 rounded-md transition-colors ${cardTall ? "items-start" : "items-center"}`, children: [jsxRuntime.jsxs(erpTheme.Avatar, { className: "h-8 w-8 shrink-0 rounded-lg", children: [user.avatar ? (jsxRuntime.jsx(erpTheme.AvatarImage, { src: user.avatar, alt: user.name })) : null, jsxRuntime.jsx(erpTheme.AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground w-8 h-8", children: getInitials(user.name) })] }), jsxRuntime.jsxs("div", { className: `grid min-w-0 flex-1 text-left text-sm leading-tight ${cardTall ? "gap-0.5" : ""}`, children: [jsxRuntime.jsx("span", { className: `truncate font-medium ${cardTall ? "pr-6" : ""}`, title: user.name, children: user.name }), myRoles ? (jsxRuntime.jsx(CardRoles, { my: myRoles, className: "text-xs font-medium text-sidebar-foreground/85" })) : rolesLoading ? (jsxRuntime.jsx("span", { className: "my-0.5 block h-3 w-24 animate-pulse rounded bg-sidebar-accent" })) : null, jsxRuntime.jsx("span", { className: "truncate text-xs text-sidebar-foreground/70", title: user.email, children: user.email })] }), jsxRuntime.jsx(lucideReact.ChevronUp, { className: `size-4 transition-transform ${isOpen ? "rotate-180" : ""} ${cardTall ? "absolute right-2 top-2.5" : "ml-auto shrink-0"}` })] }), typeof window !== "undefined" &&
|
|
1665
|
+
reactDom.createPortal(dropdownContent, document.body)] }) }));
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/**
|
|
1669
|
+
* Plan feature keys (billable entitlements). Mirror of platform-apis plan.go.
|
|
1670
|
+
*
|
|
1671
|
+
* This lives in the shell because every app gates on the same keys, and five
|
|
1672
|
+
* private copies of the same list drift: the app-switcher list did exactly
|
|
1673
|
+
* that before it moved here.
|
|
1674
|
+
*/
|
|
1675
|
+
const PLAN_FEATURES = {
|
|
1676
|
+
ORG_CHART: "org_chart",
|
|
1677
|
+
APPROVAL_WORKFLOW: "approval_workflow",
|
|
1678
|
+
API_ACCESS: "api_access",
|
|
1679
|
+
SSO: "sso",
|
|
1680
|
+
BASIC_ACCOUNTING_HR: "basic_accounting_hr",
|
|
1681
|
+
MODULE_PLATFORM: "module.platform",
|
|
1682
|
+
MODULE_HR: "module.hr",
|
|
1683
|
+
MODULE_ACCOUNTING: "module.accounting",
|
|
1684
|
+
MODULE_SALES: "module.sales",
|
|
1685
|
+
MODULE_PURCHASING: "module.purchasing",
|
|
1686
|
+
MODULE_INVENTORY: "module.inventory",
|
|
1687
|
+
MODULE_PROJECT: "module.project",
|
|
1688
|
+
MODULE_SECRETARIAT: "module.secretariat",
|
|
1689
|
+
/** AI pack (paid or trial): AI calls on AntNiti's key while credits remain. */
|
|
1690
|
+
AI: "ai",
|
|
1691
|
+
};
|
|
1692
|
+
/** Keys the Accounx add-on carries with it (same alias as org_effective_features). */
|
|
1693
|
+
const ACCOUNTING_SUB_KEYS = [
|
|
1694
|
+
PLAN_FEATURES.MODULE_SALES,
|
|
1695
|
+
PLAN_FEATURES.MODULE_PURCHASING,
|
|
1696
|
+
PLAN_FEATURES.MODULE_SECRETARIAT,
|
|
1697
|
+
];
|
|
1698
|
+
/**
|
|
1699
|
+
* True when plan features include key. Aliases, mirroring the backend:
|
|
1700
|
+
* legacy basic_accounting_hr → module.hr + module.accounting (+ its sub-keys);
|
|
1701
|
+
* module.accounting → module.sales, module.purchasing, module.secretariat.
|
|
1702
|
+
*/
|
|
1703
|
+
function hasPlanFeature(features, feature) {
|
|
1704
|
+
if (!features?.length)
|
|
1705
|
+
return false;
|
|
1706
|
+
if (features.includes(feature))
|
|
1707
|
+
return true;
|
|
1708
|
+
if ((feature === PLAN_FEATURES.MODULE_HR ||
|
|
1709
|
+
feature === PLAN_FEATURES.MODULE_ACCOUNTING ||
|
|
1710
|
+
ACCOUNTING_SUB_KEYS.includes(feature)) &&
|
|
1711
|
+
features.includes(PLAN_FEATURES.BASIC_ACCOUNTING_HR)) {
|
|
1712
|
+
return true;
|
|
1713
|
+
}
|
|
1714
|
+
if (ACCOUNTING_SUB_KEYS.includes(feature) &&
|
|
1715
|
+
features.includes(PLAN_FEATURES.MODULE_ACCOUNTING)) {
|
|
1716
|
+
return true;
|
|
1717
|
+
}
|
|
1718
|
+
if (feature === PLAN_FEATURES.BASIC_ACCOUNTING_HR &&
|
|
1719
|
+
features.includes(PLAN_FEATURES.MODULE_HR) &&
|
|
1720
|
+
features.includes(PLAN_FEATURES.MODULE_ACCOUNTING)) {
|
|
1721
|
+
return true;
|
|
1722
|
+
}
|
|
1723
|
+
return false;
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
function normaliseBase(value, fallback) {
|
|
1727
|
+
return (value ?? fallback).replace(/\/$/, "");
|
|
1728
|
+
}
|
|
1729
|
+
/**
|
|
1730
|
+
* One list, in display order, for the whole suite.
|
|
1731
|
+
*
|
|
1732
|
+
* It used to be copied into every app's own nav-user.tsx, which is how Invenx
|
|
1733
|
+
* shipped without a link to Plannex: five copies, one of them edited by hand.
|
|
1734
|
+
* Adding the sixth app is now a change here plus a version bump.
|
|
1735
|
+
*/
|
|
1736
|
+
const APPS = [
|
|
1737
|
+
{
|
|
1738
|
+
id: "platform",
|
|
1739
|
+
label: "แพลตฟอร์ม",
|
|
1740
|
+
icon: jsxRuntime.jsx(lucideReact.Building2, { className: "h-4 w-4 shrink-0" }),
|
|
1741
|
+
features: null,
|
|
1742
|
+
baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_PLATFORM_URL, "https://platform.antniti.com"),
|
|
1743
|
+
},
|
|
1744
|
+
{
|
|
1745
|
+
id: "accounx",
|
|
1746
|
+
label: "Accounx",
|
|
1747
|
+
icon: jsxRuntime.jsx(lucideReact.Calculator, { className: "h-4 w-4 shrink-0" }),
|
|
1748
|
+
features: [PLAN_FEATURES.MODULE_ACCOUNTING],
|
|
1749
|
+
baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_ACCOUNTING_URL, "https://accounx.antniti.com"),
|
|
1750
|
+
},
|
|
1751
|
+
{
|
|
1752
|
+
id: "invenx",
|
|
1753
|
+
label: "Invenx",
|
|
1754
|
+
icon: jsxRuntime.jsx(lucideReact.Warehouse, { className: "h-4 w-4 shrink-0" }),
|
|
1755
|
+
features: [PLAN_FEATURES.MODULE_INVENTORY],
|
|
1756
|
+
baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_INVENX_URL, "https://invenx.antniti.com"),
|
|
1757
|
+
},
|
|
1758
|
+
{
|
|
1759
|
+
id: "plannex",
|
|
1760
|
+
label: "Plannex",
|
|
1761
|
+
icon: jsxRuntime.jsx(lucideReact.FolderKanban, { className: "h-4 w-4 shrink-0" }),
|
|
1762
|
+
// Tasks, day-logs and work records are free with HR; projects need the add-on.
|
|
1763
|
+
features: [PLAN_FEATURES.MODULE_HR, PLAN_FEATURES.MODULE_PROJECT],
|
|
1764
|
+
baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_PLANNEX_URL, "https://plannex.antniti.com"),
|
|
1765
|
+
},
|
|
1766
|
+
{
|
|
1767
|
+
id: "peoplx",
|
|
1768
|
+
label: "Peoplx",
|
|
1769
|
+
icon: jsxRuntime.jsx(lucideReact.Users, { className: "h-4 w-4 shrink-0" }),
|
|
1770
|
+
features: [PLAN_FEATURES.MODULE_HR],
|
|
1771
|
+
baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_HR_URL, "https://peoplx.antniti.com"),
|
|
1772
|
+
},
|
|
1773
|
+
];
|
|
1774
|
+
/**
|
|
1775
|
+
* Builds the cross-app link list for <NavUser systemLinks>.
|
|
1776
|
+
*
|
|
1777
|
+
* The current app is always present even when the org's plan no longer covers
|
|
1778
|
+
* it — the user is looking at it, so hiding the link would only make the menu
|
|
1779
|
+
* disagree with the screen.
|
|
1780
|
+
*/
|
|
1781
|
+
function buildSystemLinks({ orgCode, activeApp, features, }) {
|
|
1782
|
+
// The app names itself here; the help dialog says which app a message
|
|
1783
|
+
// came from by it.
|
|
1784
|
+
declareHelpApp(activeApp);
|
|
1785
|
+
if (!orgCode)
|
|
1786
|
+
return [];
|
|
1787
|
+
return APPS.filter(app => app.id === activeApp ||
|
|
1788
|
+
app.features === null ||
|
|
1789
|
+
app.features.some(key => hasPlanFeature(features, key))).map(app => ({
|
|
1790
|
+
label: app.label,
|
|
1791
|
+
icon: app.icon,
|
|
1792
|
+
// Same-app link stays relative so it never leaves the current origin.
|
|
1793
|
+
href: app.id === activeApp ? `/${orgCode}` : `${app.baseUrl()}/${orgCode}`,
|
|
1794
|
+
...(app.id === activeApp ? { active: true } : {}),
|
|
1795
|
+
}));
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
// Helper function to check if pathname matches a URL pattern
|
|
1799
|
+
// This prevents false matches like /reports/sales matching /sales
|
|
1800
|
+
// Handles paths with or without [code] prefix
|
|
1089
1801
|
function isPathActive(pathname, url) {
|
|
1090
1802
|
// Exact match
|
|
1091
1803
|
if (pathname === url) {
|
|
@@ -1341,993 +2053,1556 @@ const WORKSPACE_LIGHT_HEADER_VARS = {
|
|
|
1341
2053
|
};
|
|
1342
2054
|
|
|
1343
2055
|
/**
|
|
1344
|
-
*
|
|
1345
|
-
*
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
2056
|
+
* The ช่วยเหลือ button in the top bar. Drawn in the header's ink (the
|
|
2057
|
+
* --header-* variables of the workspace chrome, white on the brand chrome).
|
|
2058
|
+
*/
|
|
2059
|
+
function HelpButton({ className }) {
|
|
2060
|
+
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)]" +
|
|
2061
|
+
(className ? ` ${className}` : ""), children: jsxRuntime.jsx(lucideReact.CircleHelp, { className: "size-5", strokeWidth: 1.65 }) }));
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
const hasPaginationShape = (payload) => {
|
|
2065
|
+
return ("total" in payload ||
|
|
2066
|
+
"page" in payload ||
|
|
2067
|
+
"per_page" in payload ||
|
|
2068
|
+
"total_pages" in payload ||
|
|
2069
|
+
"has_next" in payload ||
|
|
2070
|
+
"has_prev" in payload ||
|
|
2071
|
+
"pagination" in payload);
|
|
2072
|
+
};
|
|
2073
|
+
const isEnvelope = (payload) => {
|
|
2074
|
+
return ("success" in payload || "message" in payload) && !hasPaginationShape(payload);
|
|
2075
|
+
};
|
|
2076
|
+
const unwrapApiResponse = (payload) => {
|
|
2077
|
+
if (!payload || typeof payload !== "object") {
|
|
2078
|
+
return payload;
|
|
2079
|
+
}
|
|
2080
|
+
const record = payload;
|
|
2081
|
+
if ("data" in record &&
|
|
2082
|
+
record.data !== undefined &&
|
|
2083
|
+
isEnvelope(record)) {
|
|
2084
|
+
return record.data;
|
|
2085
|
+
}
|
|
2086
|
+
return payload;
|
|
2087
|
+
};
|
|
2088
|
+
|
|
2089
|
+
/**
|
|
2090
|
+
* The images on a help message (and on a follow-up): what was added, what is
|
|
2091
|
+
* uploading, what is ready to send, and what was refused and why.
|
|
1349
2092
|
*
|
|
1350
|
-
*
|
|
1351
|
-
*
|
|
1352
|
-
*
|
|
1353
|
-
*
|
|
2093
|
+
* Plain state with no React and no network of its own — the uploader is
|
|
2094
|
+
* handed in — so the rules are tested on their own (help-attach.test.ts):
|
|
2095
|
+
* PNG, JPEG, WebP or GIF; at most 5 MB each; at most three; a paste or a drop
|
|
2096
|
+
* adds the same way the file picker does; ส่ง waits for what is uploading.
|
|
2097
|
+
*
|
|
2098
|
+
* Kept free of imports: the tests load it straight from source.
|
|
1354
2099
|
*/
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
2100
|
+
const HELP_IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
|
|
2101
|
+
const HELP_IMAGE_ACCEPT = HELP_IMAGE_TYPES.join(",");
|
|
2102
|
+
const HELP_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
|
|
2103
|
+
const HELP_IMAGE_MAX_COUNT = 3;
|
|
2104
|
+
/** The words for each refusal, per file. */
|
|
2105
|
+
const HELP_IMAGE_ERRORS = {
|
|
2106
|
+
tooLarge: "ใหญ่เกิน 5 MB",
|
|
2107
|
+
notImage: "ไม่ใช่ไฟล์รูปภาพ (PNG, JPEG, WebP หรือ GIF)",
|
|
2108
|
+
tooMany: "แนบได้ไม่เกิน 3 รูป",
|
|
2109
|
+
failed: "อัปโหลดไม่สำเร็จ",
|
|
2110
|
+
};
|
|
2111
|
+
/** The declared type, as one of the four, or "". */
|
|
2112
|
+
function helpImageType(type) {
|
|
2113
|
+
const t = (type || "").toLowerCase().split(";")[0].trim();
|
|
2114
|
+
if (t === "image/jpg" || t === "image/pjpeg")
|
|
2115
|
+
return "image/jpeg";
|
|
2116
|
+
return HELP_IMAGE_TYPES.includes(t) ? t : "";
|
|
2117
|
+
}
|
|
2118
|
+
/** Why a file cannot be attached, or null when it can. */
|
|
2119
|
+
function checkHelpImage(file) {
|
|
2120
|
+
if (!helpImageType(file.type) || file.size <= 0)
|
|
2121
|
+
return HELP_IMAGE_ERRORS.notImage;
|
|
2122
|
+
if (file.size > HELP_IMAGE_MAX_BYTES)
|
|
2123
|
+
return HELP_IMAGE_ERRORS.tooLarge;
|
|
2124
|
+
return null;
|
|
2125
|
+
}
|
|
2126
|
+
/**
|
|
2127
|
+
* The files a paste carries, when it is a paste of a picture: a screenshot
|
|
2128
|
+
* or a copied image. A paste that carries text is text — copying a cell from
|
|
2129
|
+
* a spreadsheet also puts a picture of it on the clipboard, and that is not
|
|
2130
|
+
* an attachment.
|
|
2131
|
+
*/
|
|
2132
|
+
function filesFromPaste(data) {
|
|
2133
|
+
if (!data)
|
|
2134
|
+
return [];
|
|
2135
|
+
const text = data.getData?.("text/plain") ?? "";
|
|
2136
|
+
if (text.trim() !== "")
|
|
2137
|
+
return [];
|
|
2138
|
+
const out = [];
|
|
2139
|
+
if (data.files && data.files.length > 0) {
|
|
2140
|
+
for (let i = 0; i < data.files.length; i++)
|
|
2141
|
+
out.push(data.files[i]);
|
|
2142
|
+
return out;
|
|
2143
|
+
}
|
|
2144
|
+
if (data.items) {
|
|
2145
|
+
for (let i = 0; i < data.items.length; i++) {
|
|
2146
|
+
const it = data.items[i];
|
|
2147
|
+
if (it.kind !== "file")
|
|
2148
|
+
continue;
|
|
2149
|
+
const f = it.getAsFile();
|
|
2150
|
+
if (f)
|
|
2151
|
+
out.push(f);
|
|
1391
2152
|
}
|
|
1392
|
-
}, [
|
|
1393
|
-
orgFromCode,
|
|
1394
|
-
organizationId,
|
|
1395
|
-
setOrganizationId,
|
|
1396
|
-
code,
|
|
1397
|
-
organizations.length,
|
|
1398
|
-
]);
|
|
1399
|
-
// Key for the content subtree. It exists so switching organizations throws
|
|
1400
|
-
// away the previous org's component state — but it must react ONLY to a real
|
|
1401
|
-
// switch. organizationId legitimately passes through null while the context
|
|
1402
|
-
// re-settles (session resolving, cookie write in flight), and keying on that
|
|
1403
|
-
// unmounted and remounted the entire page: forms lost their loaded values,
|
|
1404
|
-
// in-flight edits vanished, and settings pickers snapped back to defaults.
|
|
1405
|
-
//
|
|
1406
|
-
// Holding the last known id makes a transient null a no-op while a genuine
|
|
1407
|
-
// org-to-org switch still changes the key.
|
|
1408
|
-
const lastOrgKeyRef = React__namespace.useRef("no-org");
|
|
1409
|
-
const currentOrgKey = organizationId === null ||
|
|
1410
|
-
organizationId === undefined ||
|
|
1411
|
-
String(organizationId).trim() === ""
|
|
1412
|
-
? null
|
|
1413
|
-
: String(organizationId);
|
|
1414
|
-
if (currentOrgKey !== null) {
|
|
1415
|
-
lastOrgKeyRef.current = currentOrgKey;
|
|
1416
|
-
}
|
|
1417
|
-
const contentOrgKey = lastOrgKeyRef.current;
|
|
1418
|
-
// Hold the page back until the active organisation is the one the URL names.
|
|
1419
|
-
//
|
|
1420
|
-
// The sync effect above writes the cookie through a server action, which is
|
|
1421
|
-
// a round trip. Until it lands, getOrgFromCookie() still answers with the
|
|
1422
|
-
// previous organisation and every request the page fires on mount carries
|
|
1423
|
-
// it — so /O26000021/work-record asked about O26000033 and was correctly
|
|
1424
|
-
// told there was nothing there. An empty table is the mild version: the
|
|
1425
|
-
// create form posts to the same header, so a record could be filed against
|
|
1426
|
-
// a company the user was not looking at.
|
|
1427
|
-
//
|
|
1428
|
-
// Only while the two genuinely disagree. A code naming no organisation of
|
|
1429
|
-
// the caller's is left to the app to refuse, and a page with no code in its
|
|
1430
|
-
// path is unaffected.
|
|
1431
|
-
const orgContextIsStale = !!orgFromCode &&
|
|
1432
|
-
organizationId !== null &&
|
|
1433
|
-
organizationId !== undefined &&
|
|
1434
|
-
String(organizationId) !== String(orgFromCode.id);
|
|
1435
|
-
// ≤1024px: sidebar renders as a modal overlay (backdrop dims content). >1024px:
|
|
1436
|
-
// sidebar is docked and the content gets a left margin to clear it.
|
|
1437
|
-
const [isSidebarModalLayout, setIsSidebarModalLayout] = React__namespace.useState(false);
|
|
1438
|
-
React.useEffect(() => {
|
|
1439
|
-
const mq = window.matchMedia("(max-width: 1024px)");
|
|
1440
|
-
const sync = () => setIsSidebarModalLayout(mq.matches);
|
|
1441
|
-
sync();
|
|
1442
|
-
mq.addEventListener("change", sync);
|
|
1443
|
-
return () => mq.removeEventListener("change", sync);
|
|
1444
|
-
}, []);
|
|
1445
|
-
// Wait for session to load (initial load only). When refetching/updating we
|
|
1446
|
-
// already have a session — don't flash the full-screen loader.
|
|
1447
|
-
if (status === "loading" && !session) {
|
|
1448
|
-
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent });
|
|
1449
2153
|
}
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
2154
|
+
return out;
|
|
2155
|
+
}
|
|
2156
|
+
/** True while what is dragged over the dialog includes files. */
|
|
2157
|
+
function dragHasFiles(dt) {
|
|
2158
|
+
if (!dt?.types)
|
|
2159
|
+
return false;
|
|
2160
|
+
for (let i = 0; i < dt.types.length; i++)
|
|
2161
|
+
if (dt.types[i] === "Files")
|
|
2162
|
+
return true;
|
|
2163
|
+
return false;
|
|
2164
|
+
}
|
|
2165
|
+
/** The files a drop carries. */
|
|
2166
|
+
function filesFromDrop(dt) {
|
|
2167
|
+
const out = [];
|
|
2168
|
+
if (!dt?.files)
|
|
2169
|
+
return out;
|
|
2170
|
+
for (let i = 0; i < dt.files.length; i++)
|
|
2171
|
+
out.push(dt.files[i]);
|
|
2172
|
+
return out;
|
|
2173
|
+
}
|
|
2174
|
+
let seq = 0;
|
|
2175
|
+
/**
|
|
2176
|
+
* The images being attached to one message. Subscribe to redraw; every
|
|
2177
|
+
* change replaces items and rejections with new arrays.
|
|
2178
|
+
*/
|
|
2179
|
+
class HelpAttachments {
|
|
2180
|
+
constructor(uploader, opts = {}) {
|
|
2181
|
+
Object.defineProperty(this, "list", {
|
|
2182
|
+
enumerable: true,
|
|
2183
|
+
configurable: true,
|
|
2184
|
+
writable: true,
|
|
2185
|
+
value: []
|
|
2186
|
+
});
|
|
2187
|
+
Object.defineProperty(this, "refused", {
|
|
2188
|
+
enumerable: true,
|
|
2189
|
+
configurable: true,
|
|
2190
|
+
writable: true,
|
|
2191
|
+
value: []
|
|
2192
|
+
});
|
|
2193
|
+
Object.defineProperty(this, "listeners", {
|
|
2194
|
+
enumerable: true,
|
|
2195
|
+
configurable: true,
|
|
2196
|
+
writable: true,
|
|
2197
|
+
value: new Set()
|
|
2198
|
+
});
|
|
2199
|
+
Object.defineProperty(this, "running", {
|
|
2200
|
+
enumerable: true,
|
|
2201
|
+
configurable: true,
|
|
2202
|
+
writable: true,
|
|
2203
|
+
value: new Map()
|
|
2204
|
+
});
|
|
2205
|
+
Object.defineProperty(this, "uploader", {
|
|
2206
|
+
enumerable: true,
|
|
2207
|
+
configurable: true,
|
|
2208
|
+
writable: true,
|
|
2209
|
+
value: void 0
|
|
2210
|
+
});
|
|
2211
|
+
Object.defineProperty(this, "opts", {
|
|
2212
|
+
enumerable: true,
|
|
2213
|
+
configurable: true,
|
|
2214
|
+
writable: true,
|
|
2215
|
+
value: void 0
|
|
2216
|
+
});
|
|
2217
|
+
this.uploader = uploader;
|
|
2218
|
+
this.opts = opts;
|
|
2219
|
+
}
|
|
2220
|
+
get items() {
|
|
2221
|
+
return this.list;
|
|
2222
|
+
}
|
|
2223
|
+
/** The files the last add, paste or drop refused, each with its reason. */
|
|
2224
|
+
get rejections() {
|
|
2225
|
+
return this.refused;
|
|
2226
|
+
}
|
|
2227
|
+
get max() {
|
|
2228
|
+
return this.opts.max ?? HELP_IMAGE_MAX_COUNT;
|
|
2229
|
+
}
|
|
2230
|
+
/** True while any image is still uploading. */
|
|
2231
|
+
get uploading() {
|
|
2232
|
+
return this.list.some(i => i.status === "uploading");
|
|
2233
|
+
}
|
|
2234
|
+
/** How many more can be added. */
|
|
2235
|
+
get room() {
|
|
2236
|
+
return Math.max(0, this.max - this.list.length);
|
|
2237
|
+
}
|
|
2238
|
+
subscribe(fn) {
|
|
2239
|
+
this.listeners.add(fn);
|
|
2240
|
+
return () => {
|
|
2241
|
+
this.listeners.delete(fn);
|
|
2242
|
+
};
|
|
2243
|
+
}
|
|
2244
|
+
emit() {
|
|
2245
|
+
this.listeners.forEach(l => l());
|
|
2246
|
+
}
|
|
2247
|
+
patch(key, change) {
|
|
2248
|
+
let changed = false;
|
|
2249
|
+
this.list = this.list.map(i => {
|
|
2250
|
+
if (i.key !== key)
|
|
2251
|
+
return i;
|
|
2252
|
+
changed = true;
|
|
2253
|
+
return { ...i, ...change };
|
|
2254
|
+
});
|
|
2255
|
+
if (changed)
|
|
2256
|
+
this.emit();
|
|
2257
|
+
}
|
|
2258
|
+
/**
|
|
2259
|
+
* Adds files: each is checked, the first ones that fit are uploaded at
|
|
2260
|
+
* once, and every one refused is named with its reason.
|
|
2261
|
+
*/
|
|
2262
|
+
add(files) {
|
|
2263
|
+
const refused = [];
|
|
2264
|
+
const added = [];
|
|
2265
|
+
let room = this.room;
|
|
2266
|
+
for (const file of files) {
|
|
2267
|
+
const why = checkHelpImage(file);
|
|
2268
|
+
if (why) {
|
|
2269
|
+
refused.push({ name: file.name || "image", message: why });
|
|
2270
|
+
continue;
|
|
2271
|
+
}
|
|
2272
|
+
if (room <= 0) {
|
|
2273
|
+
refused.push({ name: file.name || "image", message: HELP_IMAGE_ERRORS.tooMany });
|
|
2274
|
+
continue;
|
|
2275
|
+
}
|
|
2276
|
+
room--;
|
|
2277
|
+
seq += 1;
|
|
2278
|
+
const item = {
|
|
2279
|
+
key: `img-${Date.now().toString(36)}-${seq}`,
|
|
2280
|
+
name: file.name || "image",
|
|
2281
|
+
size: file.size,
|
|
2282
|
+
preview: this.opts.preview ? this.opts.preview(file) : "",
|
|
2283
|
+
status: "uploading",
|
|
2284
|
+
progress: 0,
|
|
2285
|
+
id: null,
|
|
2286
|
+
error: null,
|
|
2287
|
+
};
|
|
2288
|
+
added.push({ item, file });
|
|
1464
2289
|
}
|
|
1465
|
-
|
|
2290
|
+
this.refused = refused;
|
|
2291
|
+
this.list = [...this.list, ...added.map(a => a.item)];
|
|
2292
|
+
this.emit();
|
|
2293
|
+
for (const { item, file } of added)
|
|
2294
|
+
this.start(item.key, file);
|
|
2295
|
+
}
|
|
2296
|
+
start(key, file) {
|
|
2297
|
+
const abort = new AbortController();
|
|
2298
|
+
const done = this.uploader
|
|
2299
|
+
.upload(file, {
|
|
2300
|
+
signal: abort.signal,
|
|
2301
|
+
onProgress: p => {
|
|
2302
|
+
if (!abort.signal.aborted)
|
|
2303
|
+
this.patch(key, { progress: Math.max(0, Math.min(1, p)) });
|
|
2304
|
+
},
|
|
2305
|
+
onId: id => {
|
|
2306
|
+
if (abort.signal.aborted) {
|
|
2307
|
+
this.uploader.discard?.(id);
|
|
2308
|
+
return;
|
|
2309
|
+
}
|
|
2310
|
+
this.patch(key, { id });
|
|
2311
|
+
},
|
|
2312
|
+
})
|
|
2313
|
+
.then(res => {
|
|
2314
|
+
if (abort.signal.aborted) {
|
|
2315
|
+
this.uploader.discard?.(res.id);
|
|
2316
|
+
return;
|
|
2317
|
+
}
|
|
2318
|
+
this.patch(key, { status: "ready", progress: 1, id: res.id, error: null });
|
|
2319
|
+
}, (err) => {
|
|
2320
|
+
if (abort.signal.aborted)
|
|
2321
|
+
return;
|
|
2322
|
+
const message = err instanceof Error && err.message ? err.message : HELP_IMAGE_ERRORS.failed;
|
|
2323
|
+
this.patch(key, { status: "failed", error: message });
|
|
2324
|
+
})
|
|
2325
|
+
.finally(() => {
|
|
2326
|
+
this.running.delete(key);
|
|
2327
|
+
});
|
|
2328
|
+
this.running.set(key, { abort, done });
|
|
1466
2329
|
}
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
2330
|
+
/** Takes an image off: stops its upload and forgets it on the server. */
|
|
2331
|
+
remove(key) {
|
|
2332
|
+
const item = this.list.find(i => i.key === key);
|
|
2333
|
+
if (!item)
|
|
2334
|
+
return;
|
|
2335
|
+
const run = this.running.get(key);
|
|
2336
|
+
if (run)
|
|
2337
|
+
run.abort.abort();
|
|
2338
|
+
if (item.id)
|
|
2339
|
+
this.uploader.discard?.(item.id);
|
|
2340
|
+
if (item.preview)
|
|
2341
|
+
this.opts.revoke?.(item.preview);
|
|
2342
|
+
this.list = this.list.filter(i => i.key !== key);
|
|
2343
|
+
this.emit();
|
|
2344
|
+
}
|
|
2345
|
+
/** Marks the image platform-api refused on sending (it names it by id). */
|
|
2346
|
+
refuse(id, message) {
|
|
2347
|
+
const item = this.list.find(i => i.id === id);
|
|
2348
|
+
if (item)
|
|
2349
|
+
this.patch(item.key, { status: "failed", error: message });
|
|
2350
|
+
}
|
|
2351
|
+
dismissRejections() {
|
|
2352
|
+
if (this.refused.length === 0)
|
|
2353
|
+
return;
|
|
2354
|
+
this.refused = [];
|
|
2355
|
+
this.emit();
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* Waits until nothing is uploading, then says what can be sent: the ids
|
|
2359
|
+
* of the ready images in the order they are shown, and the ones that
|
|
2360
|
+
* failed. ส่ง calls this, so a send never leaves without its images.
|
|
2361
|
+
*/
|
|
2362
|
+
async settled() {
|
|
2363
|
+
while (this.running.size > 0) {
|
|
2364
|
+
await Promise.all(Array.from(this.running.values(), r => r.done));
|
|
2365
|
+
}
|
|
2366
|
+
return {
|
|
2367
|
+
ids: this.list.filter(i => i.status === "ready" && i.id).map(i => i.id),
|
|
2368
|
+
failed: this.list.filter(i => i.status === "failed"),
|
|
2369
|
+
};
|
|
1471
2370
|
}
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
2371
|
+
/**
|
|
2372
|
+
* Empties the list. sent: the images went with a message — nothing to
|
|
2373
|
+
* forget on the server. Otherwise every upload made is discarded.
|
|
2374
|
+
*/
|
|
2375
|
+
clear(sent = false) {
|
|
2376
|
+
for (const [, run] of this.running)
|
|
2377
|
+
run.abort.abort();
|
|
2378
|
+
for (const item of this.list) {
|
|
2379
|
+
if (!sent && item.id)
|
|
2380
|
+
this.uploader.discard?.(item.id);
|
|
2381
|
+
if (item.preview)
|
|
2382
|
+
this.opts.revoke?.(item.preview);
|
|
2383
|
+
}
|
|
2384
|
+
this.list = [];
|
|
2385
|
+
this.refused = [];
|
|
2386
|
+
this.emit();
|
|
1475
2387
|
}
|
|
1476
|
-
// When the sidebar is docked-open it consumes 21.5rem on the left; otherwise
|
|
1477
|
-
// the content takes the full width and we add a left gutter inside.
|
|
1478
|
-
const workspace = chrome === "workspace";
|
|
1479
|
-
const contentMargin = open && !isSidebarModalLayout
|
|
1480
|
-
? workspace
|
|
1481
|
-
? "ml-[244px]"
|
|
1482
|
-
: "ml-[21.5rem]"
|
|
1483
|
-
: "";
|
|
1484
|
-
const needsLeftGutter = workspace || !(open && !isSidebarModalLayout);
|
|
1485
|
-
const wrap = permissionProvider ?? ((c) => c);
|
|
1486
|
-
return (jsxRuntime.jsxs("div", { className: "flex min-h-screen w-full bg-background", children: [open && (jsxRuntime.jsx("div", { className: "fixed left-0 top-0 z-50 h-screen", children: sidebar })), open && isSidebarModalLayout && (jsxRuntime.jsx("button", { type: "button", "aria-label": "\u0E1B\u0E34\u0E14\u0E40\u0E21\u0E19\u0E39", className: "fixed inset-0 z-40 bg-black/50", onClick: () => setOpen(false) })), jsxRuntime.jsx("div", { className: cx("relative flex min-w-0 flex-1 flex-col transition-all", contentMargin), children: jsxRuntime.jsxs("main", { className: cx("flex flex-1 flex-col min-h-screen", workspace ? "gap-0" : "gap-4"), children: [workspace ? (jsxRuntime.jsxs("div", { className: cx("sticky top-0 z-30 shrink-0 w-full", workspaceHeader === "light" &&
|
|
1487
|
-
"border-b border-border bg-card/85 backdrop-blur-sm"), style: workspaceHeader === "light"
|
|
1488
|
-
? WORKSPACE_LIGHT_HEADER_VARS
|
|
1489
|
-
: WORKSPACE_HEADER_VARS, children: [workspaceHeader === "light" ? null : (jsxRuntime.jsx(HeaderBackground, { className: "rounded-none" })), jsxRuntime.jsxs("header", { className: cx("relative z-10 flex w-full items-center gap-3 text-[var(--header-fg)]", workspaceHeader === "light"
|
|
1490
|
-
? "min-h-[66px] px-3 py-2 sm:min-h-[72px] sm:px-[22px] lg:min-h-[82px] lg:px-8"
|
|
1491
|
-
: "min-h-[72px] px-[18px] py-3 sm:px-6 lg:min-h-[88px] lg:px-10"), children: [isSidebarModalLayout ? (
|
|
1492
|
-
// As in the design: the mark, then a menu button, and the
|
|
1493
|
-
// organisation on the right — no trail, no title, so the
|
|
1494
|
-
// organisation's name has the room it needs on a phone.
|
|
1495
|
-
jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [mobileBrand ? (jsxRuntime.jsx("div", { className: "shrink-0", children: mobileBrand })) : null, jsxRuntime.jsx("button", { type: "button", onClick: () => setOpen(!open), "aria-label": open ? "ปิดเมนู" : "เปิดเมนู", className: "grid size-[34px] shrink-0 place-items-center rounded-lg text-[var(--header-fg-muted)] transition-colors hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--header-fg-muted)]", children: jsxRuntime.jsx(lucideReact.LayoutGrid, { className: "size-5", strokeWidth: 1.65 }) })] })) : (jsxRuntime.jsx(SidebarTrigger, { className: "shrink-0 text-[var(--header-fg-muted)] hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] [&_svg]:!text-current" })), jsxRuntime.jsxs("div", { className: "flex min-w-0 flex-1 flex-col justify-center gap-0.5", children: [jsxRuntime.jsx("div", { className: "hidden min-w-0 sm:block", children: breadcrumb }), jsxRuntime.jsx("div", { id: "header-title", className: "min-w-0 max-sm:hidden" })] }), headerEnd ? (jsxRuntime.jsx("div", { className: "flex shrink-0 items-center gap-3 sm:gap-5", children: headerEnd })) : null] })] })) : (jsxRuntime.jsxs("div", { className: "sticky top-0 z-30 shrink-0 w-full", children: [jsxRuntime.jsx(HeaderBackground, {}), jsxRuntime.jsxs("header", { className: "relative shrink-0 gap-2 py-4 h-auto min-h-[123.5px] w-full z-10", children: [jsxRuntime.jsxs("div", { className: "flex w-full items-center gap-2 px-3 sm:px-4 lg:px-6", children: [jsxRuntime.jsx(SidebarTrigger, { className: "shrink-0 text-white hover:bg-white/10" }), jsxRuntime.jsx("div", { className: "flex-1 min-w-0", children: breadcrumb }), headerEnd ? (jsxRuntime.jsx("div", { className: "flex shrink-0 items-center gap-3", children: headerEnd })) : null] }), jsxRuntime.jsx("div", { id: "header-title" })] })] })), jsxRuntime.jsx(React.Suspense, { fallback: jsxRuntime.jsx("div", { className: "relative z-10 min-h-[calc(100vh-8rem)]", children: jsxRuntime.jsx("div", { className: "flex h-full items-center justify-center", children: jsxRuntime.jsxs("div", { className: "space-y-4 text-center", children: [jsxRuntime.jsx("div", { className: "mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-primary" }), jsxRuntime.jsx("p", { className: "text-sm text-muted-foreground", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14..." })] }) }) }), children: jsxRuntime.jsx("div", { className: workspace
|
|
1496
|
-
? "relative z-10 px-[18px] pt-[26px] pb-4 sm:px-6 lg:px-10 lg:pt-[38px]"
|
|
1497
|
-
: cx("relative z-10 pr-6", needsLeftGutter && "pl-6"), children: orgContextIsStale ? loadingComponent : wrap(children) }, `org-${contentOrgKey}`) })] }) })] }));
|
|
1498
|
-
}
|
|
1499
|
-
/** Local tiny classnames helper — avoids pulling clsx/tailwind-merge into
|
|
1500
|
-
* the shell just for the layout file. Falsy values are skipped. */
|
|
1501
|
-
function cx(...parts) {
|
|
1502
|
-
return parts.filter(Boolean).join(" ");
|
|
1503
2388
|
}
|
|
1504
2389
|
|
|
1505
2390
|
/**
|
|
1506
|
-
* The
|
|
1507
|
-
*
|
|
2391
|
+
* The help channel lives on platform-api (POST /support/messages). The shared
|
|
2392
|
+
* axios instance carries the user's token and the organisation header, and
|
|
2393
|
+
* refreshes a stale token.
|
|
1508
2394
|
*/
|
|
1509
|
-
function
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
2395
|
+
function helpApiBase(app) {
|
|
2396
|
+
return platformApiBase(app);
|
|
2397
|
+
}
|
|
2398
|
+
/** The thread of m, oldest first, from whichever shape platform-api sent. */
|
|
2399
|
+
function helpThread(m) {
|
|
2400
|
+
if (m.thread)
|
|
2401
|
+
return m.thread;
|
|
2402
|
+
return m.replies.map((r, i) => ({ id: `${m.id}-${i}`, author: "staff", body: r.body, created_at: r.created_at, images: [] }));
|
|
2403
|
+
}
|
|
2404
|
+
/** A failure the dialog shows as is; attachmentId names the image it is about. */
|
|
2405
|
+
class HelpSendError extends Error {
|
|
2406
|
+
constructor(message, status, attachmentId) {
|
|
2407
|
+
super(message);
|
|
2408
|
+
Object.defineProperty(this, "status", {
|
|
2409
|
+
enumerable: true,
|
|
2410
|
+
configurable: true,
|
|
2411
|
+
writable: true,
|
|
2412
|
+
value: void 0
|
|
2413
|
+
});
|
|
2414
|
+
Object.defineProperty(this, "attachmentId", {
|
|
2415
|
+
enumerable: true,
|
|
2416
|
+
configurable: true,
|
|
2417
|
+
writable: true,
|
|
2418
|
+
value: void 0
|
|
2419
|
+
});
|
|
2420
|
+
this.name = "HelpSendError";
|
|
2421
|
+
this.status = status;
|
|
2422
|
+
this.attachmentId = attachmentId;
|
|
2423
|
+
}
|
|
1513
2424
|
}
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
return
|
|
2425
|
+
function attachmentIdOf(err) {
|
|
2426
|
+
const data = err?.response?.data?.data;
|
|
2427
|
+
return typeof data?.attachment_id === "string" ? data.attachment_id : undefined;
|
|
1517
2428
|
}
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
2429
|
+
function friendly(err, fallback) {
|
|
2430
|
+
const e = toApiError(err);
|
|
2431
|
+
const attachmentId = attachmentIdOf(err);
|
|
2432
|
+
if (e.status === 429) {
|
|
2433
|
+
return new HelpSendError(e.message && e.message !== "Request failed with status code 429" ? e.message : "ส่งบ่อยเกินไป กรุณาลองใหม่ภายหลัง", 429);
|
|
2434
|
+
}
|
|
2435
|
+
if (e.status && e.status >= 400 && e.status < 500 && e.message && e.status !== 401 && (e.status !== 404 || attachmentId)) {
|
|
2436
|
+
return new HelpSendError(e.message, e.status, attachmentId);
|
|
2437
|
+
}
|
|
2438
|
+
if (e.status === 503 && e.message) {
|
|
2439
|
+
return new HelpSendError(e.message, e.status);
|
|
2440
|
+
}
|
|
2441
|
+
return new HelpSendError(fallback, e.status, attachmentId);
|
|
1527
2442
|
}
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
2443
|
+
async function sendHelpMessage(input) {
|
|
2444
|
+
try {
|
|
2445
|
+
const res = await instance.post(`${helpApiBase(input.app)}/support/messages`, {
|
|
2446
|
+
kind: input.kind,
|
|
2447
|
+
message: input.message,
|
|
2448
|
+
app: input.app === "other" ? "" : input.app,
|
|
2449
|
+
context: input.context,
|
|
2450
|
+
attachment_ids: input.attachmentIds ?? [],
|
|
2451
|
+
});
|
|
2452
|
+
return unwrapApiResponse(res.data);
|
|
2453
|
+
}
|
|
2454
|
+
catch (err) {
|
|
2455
|
+
throw friendly(err, "ส่งไม่สำเร็จ");
|
|
2456
|
+
}
|
|
1534
2457
|
}
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
1541
|
-
|
|
1542
|
-
|
|
1543
|
-
|
|
1544
|
-
|
|
1545
|
-
|
|
1546
|
-
const params = navigation.useParams();
|
|
1547
|
-
const code = typeof params?.code === "string" ? params.code : "";
|
|
1548
|
-
const homeTarget = homeHref ?? (code ? `/${code}` : "/");
|
|
1549
|
-
const go = (href) => {
|
|
1550
|
-
if (typeof window !== "undefined")
|
|
1551
|
-
window.location.href = href;
|
|
1552
|
-
};
|
|
1553
|
-
const wrapperClass = [
|
|
1554
|
-
"flex min-w-0 flex-1 items-center justify-center p-4",
|
|
1555
|
-
fullScreen ? "min-h-[70vh]" : "",
|
|
1556
|
-
className ?? "",
|
|
1557
|
-
]
|
|
1558
|
-
.filter(Boolean)
|
|
1559
|
-
.join(" ");
|
|
1560
|
-
return (jsxRuntime.jsx("div", { className: wrapperClass, children: jsxRuntime.jsx("div", { className: "relative w-full max-w-3xl overflow-hidden rounded-2xl border border-border/60 bg-card/60 p-8 shadow-sm backdrop-blur sm:p-10", children: jsxRuntime.jsxs("div", { className: "flex flex-col items-center gap-8 text-center sm:flex-row sm:text-left", children: [jsxRuntime.jsxs("div", { className: "relative shrink-0", children: [jsxRuntime.jsx("div", { className: "absolute inset-0 rounded-full bg-destructive/20 blur-2xl" }), jsxRuntime.jsx("div", { className: "relative flex h-28 w-28 items-center justify-center rounded-3xl border border-border/60 bg-gradient-to-br from-muted/40 to-background shadow-inner", children: jsxRuntime.jsx("div", { className: "flex h-16 w-16 items-center justify-center rounded-full border border-destructive/40 bg-destructive/10", children: jsxRuntime.jsx(lucideReact.Ban, { className: "h-8 w-8 text-destructive", strokeWidth: 2.5 }) }) })] }), jsxRuntime.jsxs("div", { className: "min-w-0 flex-1", children: [jsxRuntime.jsx("h1", { className: "text-xl font-bold text-foreground sm:text-2xl", children: title }), message ? (jsxRuntime.jsx("p", { className: "mt-3 whitespace-pre-line text-sm leading-relaxed text-muted-foreground sm:text-base", children: message })) : null, jsxRuntime.jsxs("div", { className: "mt-6 flex flex-col gap-3 sm:flex-row", children: [jsxRuntime.jsxs(erpTheme.Button, { type: "button", onClick: () => go(homeTarget), className: "w-full sm:w-auto", children: [jsxRuntime.jsx(lucideReact.Home, { className: "mr-2 h-4 w-4" }), homeLabel] }), supportHref ? (jsxRuntime.jsxs(erpTheme.Button, { type: "button", variant: "outline", onClick: () => go(supportHref), className: "w-full sm:w-auto", children: [jsxRuntime.jsx(lucideReact.Headset, { className: "mr-2 h-4 w-4" }), supportLabel] })) : null] })] })] }) }) }));
|
|
2458
|
+
/** One of the caller's messages by id or reference number ("H1004"). */
|
|
2459
|
+
async function getMyHelpMessage(app, key) {
|
|
2460
|
+
try {
|
|
2461
|
+
const res = await instance.get(`${helpApiBase(app)}/support/messages/${encodeURIComponent(key)}`);
|
|
2462
|
+
return unwrapApiResponse(res.data);
|
|
2463
|
+
}
|
|
2464
|
+
catch (err) {
|
|
2465
|
+
if (toApiError(err).status === 404)
|
|
2466
|
+
throw new HelpSendError("ไม่พบข้อความ", 404);
|
|
2467
|
+
throw friendly(err, "โหลดข้อมูลไม่สำเร็จ");
|
|
2468
|
+
}
|
|
1561
2469
|
}
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
const
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
window.addEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, onBlocked);
|
|
1583
|
-
window.addEventListener(EMPLOYEE_RECORD_OK_EVENT, onOk);
|
|
1584
|
-
return () => {
|
|
1585
|
-
window.removeEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, onBlocked);
|
|
1586
|
-
window.removeEventListener(EMPLOYEE_RECORD_OK_EVENT, onOk);
|
|
2470
|
+
/** The author writes again on their own message. */
|
|
2471
|
+
async function sendHelpFollowUp(input) {
|
|
2472
|
+
try {
|
|
2473
|
+
const res = await instance.post(`${helpApiBase(input.app)}/support/messages/${encodeURIComponent(input.key)}/replies`, { message: input.message, attachment_ids: input.attachmentIds ?? [] });
|
|
2474
|
+
return unwrapApiResponse(res.data);
|
|
2475
|
+
}
|
|
2476
|
+
catch (err) {
|
|
2477
|
+
throw friendly(err, "ส่งไม่สำเร็จ");
|
|
2478
|
+
}
|
|
2479
|
+
}
|
|
2480
|
+
/** PUTs the file to the presigned URL, reporting progress. No credentials go with it. */
|
|
2481
|
+
function putFile(ticket, file, onProgress, signal) {
|
|
2482
|
+
return new Promise((resolve, reject) => {
|
|
2483
|
+
const xhr = new XMLHttpRequest();
|
|
2484
|
+
xhr.open(ticket.method || "PUT", ticket.upload_url);
|
|
2485
|
+
for (const [k, v] of Object.entries(ticket.headers ?? {}))
|
|
2486
|
+
xhr.setRequestHeader(k, v);
|
|
2487
|
+
xhr.upload.onprogress = e => {
|
|
2488
|
+
if (e.lengthComputable && e.total > 0)
|
|
2489
|
+
onProgress(e.loaded / e.total);
|
|
1587
2490
|
};
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
2491
|
+
xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new HelpSendError("อัปโหลดไม่สำเร็จ", xhr.status)));
|
|
2492
|
+
xhr.onerror = () => reject(new HelpSendError("อัปโหลดไม่สำเร็จ"));
|
|
2493
|
+
xhr.onabort = () => reject(new HelpSendError("ยกเลิกแล้ว"));
|
|
2494
|
+
if (signal.aborted) {
|
|
2495
|
+
reject(new HelpSendError("ยกเลิกแล้ว"));
|
|
2496
|
+
return;
|
|
2497
|
+
}
|
|
2498
|
+
signal.addEventListener("abort", () => xhr.abort(), { once: true });
|
|
2499
|
+
xhr.send(file);
|
|
2500
|
+
});
|
|
1594
2501
|
}
|
|
1595
|
-
|
|
1596
|
-
const shellClassName = "flex min-w-0 flex-1 flex-col gap-6 overflow-x-hidden";
|
|
1597
2502
|
/**
|
|
1598
|
-
*
|
|
1599
|
-
*
|
|
2503
|
+
* The uploader the dialog uses: an upload URL from platform-api, the bytes
|
|
2504
|
+
* straight to the bucket, then platform-api reads them to say whether they
|
|
2505
|
+
* are an image it will take.
|
|
1600
2506
|
*/
|
|
1601
|
-
function
|
|
1602
|
-
|
|
1603
|
-
|
|
2507
|
+
function helpUploader(app) {
|
|
2508
|
+
const base = () => helpApiBase(app);
|
|
2509
|
+
return {
|
|
2510
|
+
async upload(file, hooks) {
|
|
2511
|
+
let ticket;
|
|
2512
|
+
try {
|
|
2513
|
+
const res = await instance.post(`${base()}/support/attachments`, { file_name: file.name, content_type: helpImageType(file.type), size: file.size }, { signal: hooks.signal });
|
|
2514
|
+
ticket = unwrapApiResponse(res.data);
|
|
2515
|
+
}
|
|
2516
|
+
catch (err) {
|
|
2517
|
+
throw friendly(err, "อัปโหลดไม่สำเร็จ");
|
|
2518
|
+
}
|
|
2519
|
+
hooks.onId(ticket.id);
|
|
2520
|
+
hooks.onProgress(0.02);
|
|
2521
|
+
await putFile(ticket, file, p => hooks.onProgress(0.02 + p * 0.93), hooks.signal);
|
|
2522
|
+
try {
|
|
2523
|
+
await instance.post(`${base()}/support/attachments/${ticket.id}/check`, null, { signal: hooks.signal });
|
|
2524
|
+
}
|
|
2525
|
+
catch (err) {
|
|
2526
|
+
throw friendly(err, "อัปโหลดไม่สำเร็จ");
|
|
2527
|
+
}
|
|
2528
|
+
return { id: ticket.id };
|
|
2529
|
+
},
|
|
2530
|
+
discard(id) {
|
|
2531
|
+
void instance.delete(`${base()}/support/attachments/${encodeURIComponent(id)}`).catch(() => undefined);
|
|
2532
|
+
},
|
|
2533
|
+
};
|
|
2534
|
+
}
|
|
2535
|
+
async function listMyHelpMessages(app) {
|
|
2536
|
+
try {
|
|
2537
|
+
const res = await instance.get(`${helpApiBase(app)}/support/messages`, {
|
|
2538
|
+
params: { limit: 20 },
|
|
2539
|
+
});
|
|
2540
|
+
const data = unwrapApiResponse(res.data);
|
|
2541
|
+
return data?.messages ?? [];
|
|
1604
2542
|
}
|
|
1605
|
-
|
|
1606
|
-
|
|
2543
|
+
catch (err) {
|
|
2544
|
+
throw friendly(err, "โหลดข้อมูลไม่สำเร็จ");
|
|
1607
2545
|
}
|
|
1608
|
-
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
|
|
1609
2546
|
}
|
|
1610
2547
|
|
|
1611
|
-
|
|
2548
|
+
/** One message's images, uploading as they are added. */
|
|
2549
|
+
function useHelpAttachments(app) {
|
|
2550
|
+
const attach = React__namespace.useMemo(() => new HelpAttachments(helpUploader(app), {
|
|
2551
|
+
preview: f => typeof URL !== "undefined" && typeof URL.createObjectURL === "function" && f instanceof Blob
|
|
2552
|
+
? URL.createObjectURL(f)
|
|
2553
|
+
: "",
|
|
2554
|
+
revoke: u => {
|
|
2555
|
+
if (u.startsWith("blob:"))
|
|
2556
|
+
URL.revokeObjectURL(u);
|
|
2557
|
+
},
|
|
2558
|
+
}), [app]);
|
|
2559
|
+
const snap = React__namespace.useRef({
|
|
2560
|
+
items: attach.items,
|
|
2561
|
+
rejections: attach.rejections,
|
|
2562
|
+
});
|
|
2563
|
+
const read = React__namespace.useCallback(() => {
|
|
2564
|
+
if (snap.current.items !== attach.items || snap.current.rejections !== attach.rejections) {
|
|
2565
|
+
snap.current = { items: attach.items, rejections: attach.rejections };
|
|
2566
|
+
}
|
|
2567
|
+
return snap.current;
|
|
2568
|
+
}, [attach]);
|
|
2569
|
+
const state = React__namespace.useSyncExternalStore(React__namespace.useCallback(l => attach.subscribe(l), [attach]), read, read);
|
|
2570
|
+
// Leaving the page: what was uploaded and not sent is forgotten.
|
|
2571
|
+
React__namespace.useEffect(() => () => attach.clear(), [attach]);
|
|
2572
|
+
return { attach, items: state.items, rejections: state.rejections };
|
|
2573
|
+
}
|
|
1612
2574
|
/**
|
|
1613
|
-
*
|
|
1614
|
-
*
|
|
1615
|
-
* (build a flat map of route_key/policy_key → required child groups).
|
|
2575
|
+
* Paste and drop for a form that takes images: spread onto the form. A
|
|
2576
|
+
* pasted screenshot or a dropped file is added like a picked one.
|
|
1616
2577
|
*/
|
|
1617
|
-
function
|
|
1618
|
-
const
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
}
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
2578
|
+
function useHelpImageDrop(attach, enabled) {
|
|
2579
|
+
const [over, setOver] = React__namespace.useState(false);
|
|
2580
|
+
const depth = React__namespace.useRef(0);
|
|
2581
|
+
const props = {
|
|
2582
|
+
onPaste: (e) => {
|
|
2583
|
+
if (!enabled)
|
|
2584
|
+
return;
|
|
2585
|
+
const files = filesFromPaste(e.clipboardData);
|
|
2586
|
+
if (files.length === 0)
|
|
2587
|
+
return;
|
|
2588
|
+
e.preventDefault();
|
|
2589
|
+
attach.add(files);
|
|
2590
|
+
},
|
|
2591
|
+
onDragEnter: (e) => {
|
|
2592
|
+
if (!enabled || !dragHasFiles(e.dataTransfer))
|
|
2593
|
+
return;
|
|
2594
|
+
e.preventDefault();
|
|
2595
|
+
depth.current += 1;
|
|
2596
|
+
setOver(true);
|
|
2597
|
+
},
|
|
2598
|
+
onDragOver: (e) => {
|
|
2599
|
+
if (!enabled || !dragHasFiles(e.dataTransfer))
|
|
2600
|
+
return;
|
|
2601
|
+
e.preventDefault();
|
|
2602
|
+
e.dataTransfer.dropEffect = "copy";
|
|
2603
|
+
},
|
|
2604
|
+
onDragLeave: (e) => {
|
|
2605
|
+
if (!enabled || !dragHasFiles(e.dataTransfer))
|
|
2606
|
+
return;
|
|
2607
|
+
depth.current = Math.max(0, depth.current - 1);
|
|
2608
|
+
if (depth.current === 0)
|
|
2609
|
+
setOver(false);
|
|
2610
|
+
},
|
|
2611
|
+
onDrop: (e) => {
|
|
2612
|
+
if (!enabled)
|
|
2613
|
+
return;
|
|
2614
|
+
depth.current = 0;
|
|
2615
|
+
setOver(false);
|
|
2616
|
+
const files = filesFromDrop(e.dataTransfer);
|
|
2617
|
+
if (files.length === 0)
|
|
2618
|
+
return;
|
|
2619
|
+
e.preventDefault();
|
|
2620
|
+
attach.add(files);
|
|
2621
|
+
},
|
|
2622
|
+
};
|
|
2623
|
+
return { over, props };
|
|
2624
|
+
}
|
|
2625
|
+
function kb(size) {
|
|
2626
|
+
if (size >= 1024 * 1024)
|
|
2627
|
+
return `${(size / (1024 * 1024)).toFixed(1)} MB`;
|
|
2628
|
+
return `${Math.max(1, Math.round(size / 1024))} KB`;
|
|
1647
2629
|
}
|
|
1648
2630
|
/**
|
|
1649
|
-
*
|
|
1650
|
-
*
|
|
2631
|
+
* แนบรูป: the button that opens the file picker (on a phone, the photo
|
|
2632
|
+
* library or the camera), the thumbnails with their progress and a remove
|
|
2633
|
+
* button each, and a line per file that was refused.
|
|
1651
2634
|
*/
|
|
1652
|
-
function
|
|
1653
|
-
const
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
2635
|
+
function HelpAttachField({ state, disabled, id }) {
|
|
2636
|
+
const { attach, items, rejections } = state;
|
|
2637
|
+
const input = React__namespace.useRef(null);
|
|
2638
|
+
const full = items.length >= attach.max;
|
|
2639
|
+
const problems = [
|
|
2640
|
+
...items.filter(i => i.status === "failed").map(i => ({ key: i.key, name: i.name, message: i.error ?? "" })),
|
|
2641
|
+
...rejections.map((r, n) => ({ key: `refused-${n}`, name: r.name, message: r.message })),
|
|
2642
|
+
];
|
|
2643
|
+
return (jsxRuntime.jsxs("div", { className: "grid gap-2", children: [jsxRuntime.jsxs("div", { className: "flex flex-wrap items-start gap-2", children: [items.map(it => (jsxRuntime.jsx(AttachThumb, { item: it, onRemove: () => attach.remove(it.key), disabled: disabled }, it.key))), jsxRuntime.jsxs(erpTheme.Button, { type: "button", variant: "outline", size: "sm", disabled: disabled || full, onClick: () => {
|
|
2644
|
+
attach.dismissRejections();
|
|
2645
|
+
input.current?.click();
|
|
2646
|
+
}, "aria-controls": id, className: items.length > 0 ? "h-16 w-16 flex-col gap-1 p-1 text-[11px]" : "gap-1.5", children: [jsxRuntime.jsx(lucideReact.ImagePlus, { className: "h-4 w-4" }), "\u0E41\u0E19\u0E1A\u0E23\u0E39\u0E1B"] }), jsxRuntime.jsx("input", { ref: input, id: id, type: "file", accept: HELP_IMAGE_ACCEPT, multiple: true, tabIndex: -1, "aria-hidden": "true", className: "sr-only", onChange: e => {
|
|
2647
|
+
const files = e.currentTarget.files;
|
|
2648
|
+
if (files && files.length > 0)
|
|
2649
|
+
attach.add(Array.from(files));
|
|
2650
|
+
e.currentTarget.value = "";
|
|
2651
|
+
} })] }), problems.length > 0 ? (jsxRuntime.jsx("ul", { role: "alert", className: "grid gap-0.5 text-xs text-destructive", children: problems.map(p => (jsxRuntime.jsxs("li", { className: "break-words", children: [p.name, ": ", p.message] }, p.key))) })) : null] }));
|
|
2652
|
+
}
|
|
2653
|
+
function AttachThumb({ item, onRemove, disabled }) {
|
|
2654
|
+
const failed = item.status === "failed";
|
|
2655
|
+
return (jsxRuntime.jsxs("figure", { className: "relative m-0 w-16", children: [jsxRuntime.jsxs("div", { className: "relative h-16 w-16 overflow-hidden rounded-md border bg-muted " +
|
|
2656
|
+
(failed ? "border-destructive" : "border-border"), children: [item.preview ? (
|
|
2657
|
+
// eslint-disable-next-line @next/next/no-img-element
|
|
2658
|
+
jsxRuntime.jsx("img", { src: item.preview, alt: item.name, className: "h-full w-full object-cover" })) : null, item.status === "uploading" ? (jsxRuntime.jsx("div", { role: "progressbar", "aria-label": `กำลังอัปโหลด ${item.name}`, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": Math.round(item.progress * 100), className: "absolute inset-x-1 bottom-1 h-1.5 overflow-hidden rounded-full bg-background/80", children: jsxRuntime.jsx("div", { className: "h-full bg-primary transition-[width]", style: { width: `${Math.round(item.progress * 100)}%` } }) })) : null] }), jsxRuntime.jsx("button", { type: "button", onClick: onRemove, disabled: disabled, "aria-label": `ลบรูป ${item.name}`, title: "\u0E25\u0E1A\u0E23\u0E39\u0E1B", className: "absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full border border-border bg-background text-muted-foreground shadow-sm hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50", children: jsxRuntime.jsx(lucideReact.X, { className: "h-3 w-3" }) }), jsxRuntime.jsx("figcaption", { className: "mt-0.5 truncate text-[10px] leading-tight text-muted-foreground", title: item.name, children: failed ? jsxRuntime.jsx("span", { className: "text-destructive", children: "\u0E44\u0E21\u0E48\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08" }) : kb(item.size) })] }));
|
|
2659
|
+
}
|
|
2660
|
+
/** Images already sent: thumbnails that open the full image. */
|
|
2661
|
+
function HelpImageStrip({ images, size = "md" }) {
|
|
2662
|
+
if (!images || images.length === 0)
|
|
2663
|
+
return null;
|
|
2664
|
+
const box = size === "sm" ? "h-12 w-12" : "h-20 w-20";
|
|
2665
|
+
return (jsxRuntime.jsx("ul", { className: "mt-2 flex flex-wrap gap-2", children: images.map(img => (jsxRuntime.jsx("li", { children: img.url ? (jsxRuntime.jsx("a", { href: img.url, target: "_blank", rel: "noopener noreferrer", "aria-label": `เปิดรูป ${img.file_name || ""}`.trim(), className: `block ${box} overflow-hidden rounded-md border border-border bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`, children: jsxRuntime.jsx("img", { src: img.url, alt: img.file_name || "รูปที่แนบ", loading: "lazy", className: "h-full w-full object-cover" }) })) : (jsxRuntime.jsx("span", { className: `block ${box} rounded-md border border-border bg-muted`, "aria-label": img.file_name })) }, img.id))) }));
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
/**
|
|
2669
|
+
* The ตอบกลับ link in a staff answer e-mail opens the app with ?help=H1004:
|
|
2670
|
+
* the shell's ช่วยเหลือ dialog then opens on that message. Any app page
|
|
2671
|
+
* with the shell's layout works; the e-mail uses Platform.
|
|
2672
|
+
*
|
|
2673
|
+
* Kept free of imports: the tests load it straight from source.
|
|
2674
|
+
*/
|
|
2675
|
+
const HELP_LINK_PARAM = "help";
|
|
2676
|
+
const REF = /^H\d{1,12}$/;
|
|
2677
|
+
/** The message a page address asks the dialog to open, or null. */
|
|
2678
|
+
function helpRefFromSearch(search) {
|
|
2679
|
+
if (!search)
|
|
2680
|
+
return null;
|
|
2681
|
+
let params;
|
|
2682
|
+
try {
|
|
2683
|
+
params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
|
1662
2684
|
}
|
|
1663
|
-
|
|
2685
|
+
catch {
|
|
2686
|
+
return null;
|
|
2687
|
+
}
|
|
2688
|
+
const raw = (params.get(HELP_LINK_PARAM) ?? "").trim().toUpperCase();
|
|
2689
|
+
return REF.test(raw) ? raw : null;
|
|
1664
2690
|
}
|
|
1665
|
-
|
|
1666
2691
|
/**
|
|
1667
|
-
*
|
|
1668
|
-
*
|
|
1669
|
-
* paths (e.g. `/select-organization`) keep their leading slash.
|
|
1670
|
-
*
|
|
1671
|
-
* Also normalizes the user-role subtab aliases — both `purchase-doc` and
|
|
1672
|
-
* `sales-doc` are gated by the same `members` rule because the UI shows
|
|
1673
|
-
* the same management screen with different filters.
|
|
2692
|
+
* The same address without ?help=, so a reload or a shared link does not
|
|
2693
|
+
* open the dialog again. Everything else in it is kept.
|
|
1674
2694
|
*/
|
|
1675
|
-
function
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
2695
|
+
function withoutHelpParam(href) {
|
|
2696
|
+
let u;
|
|
2697
|
+
try {
|
|
2698
|
+
u = new URL(href);
|
|
2699
|
+
}
|
|
2700
|
+
catch {
|
|
2701
|
+
return href;
|
|
2702
|
+
}
|
|
2703
|
+
if (!u.searchParams.has(HELP_LINK_PARAM))
|
|
2704
|
+
return href;
|
|
2705
|
+
u.searchParams.delete(HELP_LINK_PARAM);
|
|
2706
|
+
return u.toString();
|
|
1679
2707
|
}
|
|
1680
|
-
|
|
1681
|
-
|
|
2708
|
+
|
|
2709
|
+
const KINDS = [
|
|
2710
|
+
{ value: "help", label: "ขอความช่วยเหลือ", placeholder: "ต้องการให้ช่วยเรื่องอะไร" },
|
|
2711
|
+
{ value: "problem", label: "แจ้งปัญหา", placeholder: "เกิดอะไรขึ้น และกำลังทำอะไรอยู่" },
|
|
2712
|
+
{ value: "feedback", label: "ข้อเสนอแนะ", placeholder: "อยากให้ปรับหรือเพิ่มอะไร" },
|
|
2713
|
+
];
|
|
2714
|
+
const KIND_LABEL = Object.fromEntries(KINDS.map(k => [k.value, k.label]));
|
|
2715
|
+
const STATUS_LABEL = {
|
|
2716
|
+
new: "รอตอบ",
|
|
2717
|
+
answered: "ตอบแล้ว",
|
|
2718
|
+
closed: "ปิดแล้ว",
|
|
2719
|
+
};
|
|
2720
|
+
const STATUS_TONE = {
|
|
2721
|
+
new: "bg-amber-100 text-amber-900 dark:bg-amber-950 dark:text-amber-200",
|
|
2722
|
+
answered: "bg-emerald-100 text-emerald-900 dark:bg-emerald-950 dark:text-emerald-200",
|
|
2723
|
+
closed: "bg-muted text-muted-foreground",
|
|
2724
|
+
};
|
|
2725
|
+
const MAX_MESSAGE = 4000;
|
|
2726
|
+
const LINK_BUTTON = "rounded text-sm text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
|
|
2727
|
+
function when(iso) {
|
|
2728
|
+
const d = new Date(iso);
|
|
2729
|
+
if (Number.isNaN(d.getTime()))
|
|
2730
|
+
return "";
|
|
2731
|
+
return d.toLocaleString("th-TH", {
|
|
2732
|
+
day: "numeric",
|
|
2733
|
+
month: "short",
|
|
2734
|
+
year: "numeric",
|
|
2735
|
+
hour: "2-digit",
|
|
2736
|
+
minute: "2-digit",
|
|
2737
|
+
hour12: false,
|
|
2738
|
+
});
|
|
2739
|
+
}
|
|
2740
|
+
function errorText(e, fallback) {
|
|
2741
|
+
return e instanceof Error && e.message ? e.message : fallback;
|
|
1682
2742
|
}
|
|
1683
2743
|
/**
|
|
1684
|
-
*
|
|
1685
|
-
*
|
|
1686
|
-
* - Exact / `[param]` pattern match → matched
|
|
1687
|
-
* - Parent-prefix inherit only when the parent rule has a **non-empty**
|
|
1688
|
-
* role list (restrictive inherit). Explicitly public parents (`[]`) do
|
|
1689
|
-
* not open all children.
|
|
1690
|
-
* - No match → `{ matched: false }` (fail closed — never treat as public)
|
|
2744
|
+
* Sends a message with its images: waits for what is still uploading, then
|
|
2745
|
+
* sends; an image platform-api refuses is marked on its thumbnail.
|
|
1691
2746
|
*/
|
|
1692
|
-
function
|
|
1693
|
-
const
|
|
1694
|
-
if (
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
if (patternSegments.length !== pathSegments.length)
|
|
1701
|
-
continue;
|
|
1702
|
-
let matches = true;
|
|
1703
|
-
for (let i = 0; i < patternSegments.length; i++) {
|
|
1704
|
-
const patternSegment = patternSegments[i];
|
|
1705
|
-
const pathSegment = pathSegments[i];
|
|
1706
|
-
if (patternSegment.startsWith("[") && patternSegment.endsWith("]")) {
|
|
1707
|
-
if (!pathSegment) {
|
|
1708
|
-
matches = false;
|
|
1709
|
-
break;
|
|
1710
|
-
}
|
|
1711
|
-
}
|
|
1712
|
-
else if (patternSegment !== pathSegment) {
|
|
1713
|
-
matches = false;
|
|
1714
|
-
break;
|
|
1715
|
-
}
|
|
1716
|
-
}
|
|
1717
|
-
if (matches) {
|
|
1718
|
-
return { matched: true, roles: roles ?? [] };
|
|
1719
|
-
}
|
|
2747
|
+
async function sendWithImages(state, send) {
|
|
2748
|
+
const { ids, failed } = await state.attach.settled();
|
|
2749
|
+
if (failed.length > 0)
|
|
2750
|
+
throw new HelpSendError("มีรูปที่อัปโหลดไม่สำเร็จ");
|
|
2751
|
+
try {
|
|
2752
|
+
const res = await send(ids);
|
|
2753
|
+
state.attach.clear(true);
|
|
2754
|
+
return res;
|
|
1720
2755
|
}
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
return { matched: true, roles: rulesMap[parent] ?? [] };
|
|
1726
|
-
}
|
|
2756
|
+
catch (err) {
|
|
2757
|
+
if (err instanceof HelpSendError && err.attachmentId)
|
|
2758
|
+
state.attach.refuse(err.attachmentId, err.message);
|
|
2759
|
+
throw err;
|
|
1727
2760
|
}
|
|
1728
|
-
return { matched: false, roles: [] };
|
|
1729
2761
|
}
|
|
1730
2762
|
/**
|
|
1731
|
-
*
|
|
1732
|
-
*
|
|
1733
|
-
*
|
|
1734
|
-
*
|
|
1735
|
-
*
|
|
2763
|
+
* The ช่วยเหลือ dialog. AppLayoutShell mounts one; the top-bar button and
|
|
2764
|
+
* the user menu open it with openHelp(), and a page address with
|
|
2765
|
+
* ?help=H1004 (the ตอบกลับ button in a staff answer) opens it on that
|
|
2766
|
+
* message. It sends to platform-api, which puts the message in the back
|
|
2767
|
+
* office's queue on admin.antniti.com.
|
|
1736
2768
|
*/
|
|
1737
|
-
function
|
|
1738
|
-
|
|
2769
|
+
function HelpDialogHost({ app, orgCode }) {
|
|
2770
|
+
const open = useHelpOpen();
|
|
2771
|
+
const { data: session } = react.useSession();
|
|
2772
|
+
const [view, setView] = React__namespace.useState("form");
|
|
2773
|
+
const [kind, setKind] = React__namespace.useState("help");
|
|
2774
|
+
const [message, setMessage] = React__namespace.useState("");
|
|
2775
|
+
const [sending, setSending] = React__namespace.useState(false);
|
|
2776
|
+
const [error, setError] = React__namespace.useState(null);
|
|
2777
|
+
const [sent, setSent] = React__namespace.useState(null);
|
|
2778
|
+
const [mine, setMine] = React__namespace.useState(null);
|
|
2779
|
+
const [mineError, setMineError] = React__namespace.useState(null);
|
|
2780
|
+
const [threadKey, setThreadKey] = React__namespace.useState(null);
|
|
2781
|
+
const [thread, setThread] = React__namespace.useState(null);
|
|
2782
|
+
const [threadError, setThreadError] = React__namespace.useState(null);
|
|
2783
|
+
const [followDraft, setFollowDraft] = React__namespace.useState("");
|
|
2784
|
+
const [following, setFollowing] = React__namespace.useState(false);
|
|
2785
|
+
const [followError, setFollowError] = React__namespace.useState(null);
|
|
2786
|
+
React__namespace.useEffect(() => registerHelpHost(), []);
|
|
2787
|
+
// The ตอบกลับ link: ?help=H1004 opens the dialog on that message. The
|
|
2788
|
+
// parameter comes off the address so a reload does not open it again.
|
|
2789
|
+
React__namespace.useEffect(() => {
|
|
2790
|
+
if (typeof window === "undefined")
|
|
2791
|
+
return;
|
|
2792
|
+
const ref = helpRefFromSearch(window.location.search);
|
|
2793
|
+
if (!ref)
|
|
2794
|
+
return;
|
|
2795
|
+
window.history.replaceState(null, "", withoutHelpParam(window.location.href));
|
|
2796
|
+
openHelpOn(ref);
|
|
2797
|
+
}, []);
|
|
2798
|
+
// Read when the dialog opens: the page and the window as they are now.
|
|
2799
|
+
// open is a dependency on purpose: the app is read again each time.
|
|
2800
|
+
const appId = React__namespace.useMemo(() => detectHelpApp(app), [app, open]);
|
|
2801
|
+
const context = React__namespace.useMemo(() => (open ? collectHelpContext(orgCode) : null), [open, orgCode]);
|
|
2802
|
+
const images = useHelpAttachments(appId);
|
|
2803
|
+
const followImages = useHelpAttachments(appId);
|
|
2804
|
+
const formDrop = useHelpImageDrop(images.attach, view === "form" && !sending);
|
|
2805
|
+
const followDrop = useHelpImageDrop(followImages.attach, view === "thread" && !following && !!thread);
|
|
2806
|
+
const openThread = React__namespace.useCallback((key) => {
|
|
2807
|
+
setView("thread");
|
|
2808
|
+
setThreadKey(key);
|
|
2809
|
+
setThread(null);
|
|
2810
|
+
setThreadError(null);
|
|
2811
|
+
setFollowError(null);
|
|
2812
|
+
getMyHelpMessage(appId, key)
|
|
2813
|
+
.then(setThread)
|
|
2814
|
+
.catch((e) => setThreadError(errorText(e, "โหลดข้อมูลไม่สำเร็จ")));
|
|
2815
|
+
}, [appId]);
|
|
2816
|
+
React__namespace.useEffect(() => {
|
|
2817
|
+
if (!open)
|
|
2818
|
+
return;
|
|
2819
|
+
setError(null);
|
|
2820
|
+
const key = pendingHelpRef();
|
|
2821
|
+
if (key)
|
|
2822
|
+
openThread(key);
|
|
2823
|
+
else
|
|
2824
|
+
setView("form");
|
|
2825
|
+
}, [open, openThread]);
|
|
2826
|
+
const loadMine = React__namespace.useCallback(() => {
|
|
2827
|
+
clearPendingHelpRef();
|
|
2828
|
+
setView("mine");
|
|
2829
|
+
setMine(null);
|
|
2830
|
+
setMineError(null);
|
|
2831
|
+
listMyHelpMessages(appId)
|
|
2832
|
+
.then(setMine)
|
|
2833
|
+
.catch((e) => setMineError(errorText(e, "โหลดข้อมูลไม่สำเร็จ")));
|
|
2834
|
+
}, [appId]);
|
|
2835
|
+
const trimmed = message.trim();
|
|
2836
|
+
const canSend = trimmed.length > 0 && !sending;
|
|
2837
|
+
const send = async (e) => {
|
|
2838
|
+
e?.preventDefault();
|
|
2839
|
+
if (!canSend || !context)
|
|
2840
|
+
return;
|
|
2841
|
+
setSending(true);
|
|
2842
|
+
setError(null);
|
|
2843
|
+
try {
|
|
2844
|
+
const res = await sendWithImages(images, ids => sendHelpMessage({ app: appId, kind, message: trimmed, context, attachmentIds: ids }));
|
|
2845
|
+
setSent(res);
|
|
2846
|
+
setMessage("");
|
|
2847
|
+
setKind("help");
|
|
2848
|
+
setView("sent");
|
|
2849
|
+
}
|
|
2850
|
+
catch (err) {
|
|
2851
|
+
setError(errorText(err, "ส่งไม่สำเร็จ"));
|
|
2852
|
+
}
|
|
2853
|
+
finally {
|
|
2854
|
+
setSending(false);
|
|
2855
|
+
}
|
|
2856
|
+
};
|
|
2857
|
+
const followTrimmed = followDraft.trim();
|
|
2858
|
+
const sendFollowUp = async (e) => {
|
|
2859
|
+
e?.preventDefault();
|
|
2860
|
+
if (!thread || !followTrimmed || following)
|
|
2861
|
+
return;
|
|
2862
|
+
setFollowing(true);
|
|
2863
|
+
setFollowError(null);
|
|
2864
|
+
try {
|
|
2865
|
+
await sendWithImages(followImages, ids => sendHelpFollowUp({ app: appId, key: thread.id, message: followTrimmed, attachmentIds: ids }));
|
|
2866
|
+
setFollowDraft("");
|
|
2867
|
+
setMine(null);
|
|
2868
|
+
setThread(await getMyHelpMessage(appId, thread.id));
|
|
2869
|
+
}
|
|
2870
|
+
catch (err) {
|
|
2871
|
+
setFollowError(errorText(err, "ส่งไม่สำเร็จ"));
|
|
2872
|
+
}
|
|
2873
|
+
finally {
|
|
2874
|
+
setFollowing(false);
|
|
2875
|
+
}
|
|
2876
|
+
};
|
|
2877
|
+
const email = session?.user?.email ?? null;
|
|
2878
|
+
const placeholder = KINDS.find(k => k.value === kind)?.placeholder;
|
|
2879
|
+
const dropRing = (over) => (over ? " rounded-lg ring-2 ring-primary ring-offset-4 ring-offset-background" : "");
|
|
2880
|
+
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" + dropRing(formDrop.over), ...formDrop.props, 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 " +
|
|
2881
|
+
(kind === k.value
|
|
2882
|
+
? "bg-background text-foreground shadow-sm"
|
|
2883
|
+
: "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" })] }), jsxRuntime.jsx(HelpAttachField, { state: images, disabled: sending, id: "erp-help-images" }), 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 " + LINK_BUTTON, 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 " + LINK_BUTTON, 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(BackButton, { onClick: () => setView("form") }), "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E17\u0E35\u0E48\u0E2A\u0E48\u0E07\u0E41\u0E25\u0E49\u0E27"] }) }), mine === null && !mineError ? jsxRuntime.jsx(Loading, {}) : 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 => {
|
|
2884
|
+
const entries = helpThread(m);
|
|
2885
|
+
const last = entries[entries.length - 1];
|
|
2886
|
+
return (jsxRuntime.jsxs("li", { className: "rounded-lg border border-border", children: [jsxRuntime.jsxs("button", { type: "button", onClick: () => openThread(m.id), "aria-label": `เปิด ${m.ref}`, className: "block w-full rounded-lg p-3 text-left hover:bg-accent/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", children: [jsxRuntime.jsxs("span", { 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(lucideReact.ChevronRight, { className: "h-4 w-4", "aria-hidden": "true" })] }), jsxRuntime.jsx("span", { className: "mt-2 line-clamp-3 block whitespace-pre-wrap break-words text-sm", children: m.message }), last ? (jsxRuntime.jsxs("span", { className: "mt-2 line-clamp-2 block rounded-md bg-muted px-3 py-2 text-xs", children: [jsxRuntime.jsxs("span", { className: "text-muted-foreground", children: [last.author === "staff" ? "ทีม AntNiti" : "คุณ", " \u00B7 ", when(last.created_at)] }), jsxRuntime.jsx("span", { className: "mt-0.5 block whitespace-pre-wrap break-words text-sm", children: last.body })] })) : null] }), m.images && m.images.length > 0 ? (jsxRuntime.jsx("div", { className: "px-3 pb-3", children: jsxRuntime.jsx(HelpImageStrip, { images: m.images, size: "sm" }) })) : null] }, m.id));
|
|
2887
|
+
}) })) : null] })) : null, view === "thread" ? (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(BackButton, { onClick: loadMine }), jsxRuntime.jsx("span", { className: "font-mono", children: thread?.ref ?? threadKey }), thread ? (jsxRuntime.jsx("span", { className: `ml-auto mr-7 rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_TONE[thread.status] ?? ""}`, children: STATUS_LABEL[thread.status] ?? thread.status })) : null] }) }), thread === null && !threadError ? jsxRuntime.jsx(Loading, {}) : null, threadError ? (jsxRuntime.jsx("p", { role: "alert", className: "text-sm text-destructive", children: threadError })) : null, thread ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs("ol", { className: "grid gap-3", "aria-label": "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21", children: [jsxRuntime.jsx(ThreadBubble, { mine: true, label: `คุณ · ${KIND_LABEL[thread.kind] ?? thread.kind}`, at: thread.created_at, body: thread.message, images: thread.images }), helpThread(thread).map(e => (jsxRuntime.jsx(ThreadBubble, { mine: e.author === "customer", label: e.author === "staff" ? "ทีม AntNiti" : "คุณ", at: e.created_at, body: e.body, images: e.images }, e.id)))] }), jsxRuntime.jsxs("form", { onSubmit: sendFollowUp, className: "grid gap-3 border-t border-border pt-4" + dropRing(followDrop.over), ...followDrop.props, children: [jsxRuntime.jsxs("div", { className: "grid gap-2", children: [jsxRuntime.jsx(erpTheme.Label, { htmlFor: "erp-help-followup", children: "\u0E15\u0E2D\u0E1A\u0E01\u0E25\u0E31\u0E1A" }), jsxRuntime.jsx(erpTheme.Textarea, { id: "erp-help-followup", rows: 3, maxLength: MAX_MESSAGE, required: true, value: followDraft, onChange: e => setFollowDraft(e.target.value), className: "min-h-[88px] resize-y" })] }), jsxRuntime.jsx(HelpAttachField, { state: followImages, disabled: following, id: "erp-help-followup-images" }), followError ? (jsxRuntime.jsx("p", { role: "alert", className: "text-sm text-destructive", children: followError })) : null, jsxRuntime.jsx(erpTheme.DialogFooter, { className: "flex-row items-center justify-end gap-2", children: jsxRuntime.jsxs(erpTheme.Button, { type: "submit", disabled: !followTrimmed || following, children: [following ? jsxRuntime.jsx(lucideReact.Loader2, { className: "mr-1 h-4 w-4 animate-spin" }) : null, "\u0E2A\u0E48\u0E07"] }) })] })] })) : null] })) : null] }) }));
|
|
2888
|
+
}
|
|
2889
|
+
function BackButton({ onClick }) {
|
|
2890
|
+
return (jsxRuntime.jsx("button", { type: "button", "aria-label": "\u0E01\u0E25\u0E31\u0E1A", onClick: onClick, className: "-ml-1 grid size-8 shrink-0 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" }) }));
|
|
2891
|
+
}
|
|
2892
|
+
function Loading() {
|
|
2893
|
+
return (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"] }));
|
|
2894
|
+
}
|
|
2895
|
+
/** One message in the thread: the author's own on the right, AntNiti's on the left. */
|
|
2896
|
+
function ThreadBubble({ mine, label, at, body, images, }) {
|
|
2897
|
+
return (jsxRuntime.jsx("li", { className: "flex " + (mine ? "justify-end pl-6" : "justify-start pr-6"), children: jsxRuntime.jsxs("div", { className: "min-w-0 max-w-full rounded-lg px-3 py-2 " +
|
|
2898
|
+
(mine ? "border border-primary/30 bg-primary/5" : "bg-muted"), children: [jsxRuntime.jsxs("p", { className: "text-xs text-muted-foreground", children: [label, " \u00B7 ", when(at)] }), jsxRuntime.jsx("p", { className: "mt-1 whitespace-pre-wrap break-words text-sm", children: body }), jsxRuntime.jsx(HelpImageStrip, { images: images })] }) }));
|
|
1739
2899
|
}
|
|
1740
2900
|
|
|
1741
|
-
function DeniedCard() {
|
|
1742
|
-
return (jsxRuntime.jsx(AccessDenied, { message: "\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07\u0E2B\u0E19\u0E49\u0E32\u0E19\u0E35\u0E49 \u0E2B\u0E32\u0E01\u0E04\u0E34\u0E14\u0E27\u0E48\u0E32\u0E04\u0E27\u0E23\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07\u0E44\u0E14\u0E49 \u0E01\u0E23\u0E38\u0E13\u0E32\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E1C\u0E39\u0E49\u0E14\u0E39\u0E41\u0E25\u0E23\u0E30\u0E1A\u0E1A\u0E02\u0E2D\u0E07\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" }));
|
|
1743
|
-
}
|
|
1744
2901
|
/**
|
|
1745
|
-
*
|
|
1746
|
-
*
|
|
1747
|
-
*
|
|
1748
|
-
*
|
|
2902
|
+
* Top-level app chrome: sticky header, sidebar slot, content gutter and the
|
|
2903
|
+
* session/org-context plumbing that both Accounting and HR need. Apps wrap
|
|
2904
|
+
* it inside their `SidebarProvider` (server-rendered with the cookie-backed
|
|
2905
|
+
* `defaultOpen`) and their `ERPProvider`, then pass app-specific bits as
|
|
2906
|
+
* props.
|
|
1749
2907
|
*
|
|
1750
|
-
*
|
|
1751
|
-
*
|
|
1752
|
-
*
|
|
1753
|
-
*
|
|
1754
|
-
* 3. rule is explicit public (`[]`) → children
|
|
1755
|
-
* 4. probe says no access → deny card
|
|
1756
|
-
* 5. otherwise → children
|
|
2908
|
+
* The padding contract is deliberately owned here so future style tweaks
|
|
2909
|
+
* (e.g. the recent left/right/bottom gutter pass) propagate to both apps via
|
|
2910
|
+
* an erp-shell bump — domain components must NOT add their own root padding
|
|
2911
|
+
* or content gets indented twice.
|
|
1757
2912
|
*/
|
|
1758
|
-
function
|
|
1759
|
-
const
|
|
1760
|
-
const {
|
|
1761
|
-
const
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
//
|
|
1769
|
-
|
|
1770
|
-
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
//
|
|
1776
|
-
//
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
//
|
|
2913
|
+
function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permissionProvider, organizationId, setOrganizationId, organizations, signInUrl, selectOrganizationUrl, chrome = "brand", workspaceHeader = "brand", headerEnd, mobileBrand, showHelp = true, helpApp, }) {
|
|
2914
|
+
const { data: session, status } = react.useSession();
|
|
2915
|
+
const { open, setOpen } = useSidebar();
|
|
2916
|
+
const router = navigation.useRouter();
|
|
2917
|
+
const params = navigation.useParams();
|
|
2918
|
+
const code = params?.code;
|
|
2919
|
+
const extendedSession = session;
|
|
2920
|
+
// Track once-authenticated so a transient accessToken drop during the
|
|
2921
|
+
// periodic session refresh (SessionRefreshTrigger every 4 min) doesn't
|
|
2922
|
+
// blank the whole page. The initial-load gate below still handles cold
|
|
2923
|
+
// start; once we've seen a real token we render children optimistically
|
|
2924
|
+
// and let the API layer surface a real 401 if the token actually died.
|
|
2925
|
+
const hasSeenAccessTokenRef = React.useRef(false);
|
|
2926
|
+
React.useEffect(() => {
|
|
2927
|
+
if (extendedSession?.accessToken)
|
|
2928
|
+
hasSeenAccessTokenRef.current = true;
|
|
2929
|
+
}, [extendedSession?.accessToken]);
|
|
2930
|
+
// Find the org from the URL `[code]` so we can detect a context mismatch
|
|
2931
|
+
// (the user bookmarked a different org than the cookie remembers).
|
|
2932
|
+
const orgFromCode = React__namespace.useMemo(() => {
|
|
2933
|
+
if (!code || !organizations.length)
|
|
2934
|
+
return null;
|
|
2935
|
+
return (organizations.find(org => org.code === code || String(org.id) === code) ?? null);
|
|
2936
|
+
}, [code, organizations]);
|
|
2937
|
+
// Sync state: when the URL code points at a different org than the cookie,
|
|
2938
|
+
// trust the URL and update the cookie. Otherwise do nothing — the user
|
|
2939
|
+
// wins ties via the in-app org switcher.
|
|
2940
|
+
React.useEffect(() => {
|
|
2941
|
+
if (!code)
|
|
2942
|
+
return;
|
|
2943
|
+
if (!organizations.length)
|
|
2944
|
+
return;
|
|
2945
|
+
if (!orgFromCode)
|
|
2946
|
+
return;
|
|
2947
|
+
if (String(organizationId) !== String(orgFromCode.id)) {
|
|
2948
|
+
void setOrganizationId(orgFromCode.id);
|
|
2949
|
+
}
|
|
2950
|
+
}, [
|
|
2951
|
+
orgFromCode,
|
|
2952
|
+
organizationId,
|
|
2953
|
+
setOrganizationId,
|
|
2954
|
+
code,
|
|
2955
|
+
organizations.length,
|
|
2956
|
+
]);
|
|
2957
|
+
// Key for the content subtree. It exists so switching organizations throws
|
|
2958
|
+
// away the previous org's component state — but it must react ONLY to a real
|
|
2959
|
+
// switch. organizationId legitimately passes through null while the context
|
|
2960
|
+
// re-settles (session resolving, cookie write in flight), and keying on that
|
|
2961
|
+
// unmounted and remounted the entire page: forms lost their loaded values,
|
|
2962
|
+
// in-flight edits vanished, and settings pickers snapped back to defaults.
|
|
1783
2963
|
//
|
|
1784
|
-
//
|
|
1785
|
-
//
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
if (!lookup.matched) {
|
|
1795
|
-
return jsxRuntime.jsx(DeniedCard, {});
|
|
2964
|
+
// Holding the last known id makes a transient null a no-op while a genuine
|
|
2965
|
+
// org-to-org switch still changes the key.
|
|
2966
|
+
const lastOrgKeyRef = React__namespace.useRef("no-org");
|
|
2967
|
+
const currentOrgKey = organizationId === null ||
|
|
2968
|
+
organizationId === undefined ||
|
|
2969
|
+
String(organizationId).trim() === ""
|
|
2970
|
+
? null
|
|
2971
|
+
: String(organizationId);
|
|
2972
|
+
if (currentOrgKey !== null) {
|
|
2973
|
+
lastOrgKeyRef.current = currentOrgKey;
|
|
1796
2974
|
}
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
2975
|
+
const contentOrgKey = lastOrgKeyRef.current;
|
|
2976
|
+
// Hold the page back until the active organisation is the one the URL names.
|
|
2977
|
+
//
|
|
2978
|
+
// The sync effect above writes the cookie through a server action, which is
|
|
2979
|
+
// a round trip. Until it lands, getOrgFromCookie() still answers with the
|
|
2980
|
+
// previous organisation and every request the page fires on mount carries
|
|
2981
|
+
// it — so /O26000021/work-record asked about O26000033 and was correctly
|
|
2982
|
+
// told there was nothing there. An empty table is the mild version: the
|
|
2983
|
+
// create form posts to the same header, so a record could be filed against
|
|
2984
|
+
// a company the user was not looking at.
|
|
2985
|
+
//
|
|
2986
|
+
// Only while the two genuinely disagree. A code naming no organisation of
|
|
2987
|
+
// the caller's is left to the app to refuse, and a page with no code in its
|
|
2988
|
+
// path is unaffected.
|
|
2989
|
+
const orgContextIsStale = !!orgFromCode &&
|
|
2990
|
+
organizationId !== null &&
|
|
2991
|
+
organizationId !== undefined &&
|
|
2992
|
+
String(organizationId) !== String(orgFromCode.id);
|
|
2993
|
+
// ≤1024px: sidebar renders as a modal overlay (backdrop dims content). >1024px:
|
|
2994
|
+
// sidebar is docked and the content gets a left margin to clear it.
|
|
2995
|
+
const [isSidebarModalLayout, setIsSidebarModalLayout] = React__namespace.useState(false);
|
|
2996
|
+
React.useEffect(() => {
|
|
2997
|
+
const mq = window.matchMedia("(max-width: 1024px)");
|
|
2998
|
+
const sync = () => setIsSidebarModalLayout(mq.matches);
|
|
2999
|
+
sync();
|
|
3000
|
+
mq.addEventListener("change", sync);
|
|
3001
|
+
return () => mq.removeEventListener("change", sync);
|
|
3002
|
+
}, []);
|
|
3003
|
+
// Help opened from the drawer's user menu: the drawer steps aside so the
|
|
3004
|
+
// dialog is the only thing on a phone's screen.
|
|
3005
|
+
const helpOpen = useHelpOpen();
|
|
3006
|
+
React.useEffect(() => {
|
|
3007
|
+
if (helpOpen && isSidebarModalLayout && open)
|
|
3008
|
+
setOpen(false);
|
|
3009
|
+
}, [helpOpen, isSidebarModalLayout, open, setOpen]);
|
|
3010
|
+
// Wait for session to load (initial load only). When refetching/updating we
|
|
3011
|
+
// already have a session — don't flash the full-screen loader.
|
|
3012
|
+
if (status === "loading" && !session) {
|
|
3013
|
+
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent });
|
|
1800
3014
|
}
|
|
1801
|
-
|
|
1802
|
-
|
|
3015
|
+
// Session loaded but no accessToken AND we've never seen one → real auth
|
|
3016
|
+
// failure (token refresh gave nothing back on cold start), bounce through
|
|
3017
|
+
// signin. If we HAVE seen one before, the drop is almost certainly the
|
|
3018
|
+
// in-flight periodic refresh (SessionRefreshTrigger). Don't redirect on
|
|
3019
|
+
// that transient — the JWT callback will settle in a moment and API
|
|
3020
|
+
// calls will keep working with the still-valid cookie.
|
|
3021
|
+
if (status === "authenticated" &&
|
|
3022
|
+
!extendedSession?.accessToken &&
|
|
3023
|
+
!hasSeenAccessTokenRef.current) {
|
|
3024
|
+
if (typeof window !== "undefined") {
|
|
3025
|
+
const url = new URL(signInUrl, window.location.origin);
|
|
3026
|
+
url.searchParams.set("error", "NoAccessToken");
|
|
3027
|
+
url.searchParams.set("callbackUrl", window.location.pathname);
|
|
3028
|
+
window.location.href = url.toString();
|
|
3029
|
+
}
|
|
3030
|
+
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent });
|
|
1803
3031
|
}
|
|
1804
|
-
|
|
1805
|
-
|
|
3032
|
+
// Only blank the page when we've never seen a token — otherwise the
|
|
3033
|
+
// periodic session refresh would visibly flash the loader every 4 min.
|
|
3034
|
+
if (!extendedSession?.accessToken && !hasSeenAccessTokenRef.current) {
|
|
3035
|
+
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent });
|
|
1806
3036
|
}
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
}
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
const
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
3037
|
+
if (!code) {
|
|
3038
|
+
router.push(selectOrganizationUrl);
|
|
3039
|
+
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: loadingComponent });
|
|
3040
|
+
}
|
|
3041
|
+
// When the sidebar is docked-open it consumes 21.5rem on the left; otherwise
|
|
3042
|
+
// the content takes the full width and we add a left gutter inside.
|
|
3043
|
+
const workspace = chrome === "workspace";
|
|
3044
|
+
const contentMargin = open && !isSidebarModalLayout
|
|
3045
|
+
? workspace
|
|
3046
|
+
? "ml-[244px]"
|
|
3047
|
+
: "ml-[21.5rem]"
|
|
3048
|
+
: "";
|
|
3049
|
+
const needsLeftGutter = workspace || !(open && !isSidebarModalLayout);
|
|
3050
|
+
const wrap = permissionProvider ?? ((c) => c);
|
|
3051
|
+
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" &&
|
|
3052
|
+
"border-b border-border bg-card/85 backdrop-blur-sm"), style: workspaceHeader === "light"
|
|
3053
|
+
? WORKSPACE_LIGHT_HEADER_VARS
|
|
3054
|
+
: 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"
|
|
3055
|
+
? "min-h-[66px] px-3 py-2 sm:min-h-[72px] sm:px-[22px] lg:min-h-[82px] lg:px-8"
|
|
3056
|
+
: "min-h-[72px] px-[18px] py-3 sm:px-6 lg:min-h-[88px] lg:px-10"), children: [isSidebarModalLayout ? (
|
|
3057
|
+
// As in the design: the mark, then a menu button, and the
|
|
3058
|
+
// organisation on the right — no trail, no title, so the
|
|
3059
|
+
// organisation's name has the room it needs on a phone.
|
|
3060
|
+
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
|
|
3061
|
+
? "relative z-10 px-[18px] pt-[26px] pb-4 sm:px-6 lg:px-10 lg:pt-[38px]"
|
|
3062
|
+
: 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] }));
|
|
1833
3063
|
}
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
const parts = [
|
|
1839
|
-
`${THEME_COOKIE}=${encodeURIComponent(value)}`,
|
|
1840
|
-
"path=/",
|
|
1841
|
-
"max-age=31536000", // 1 year
|
|
1842
|
-
"SameSite=Lax",
|
|
1843
|
-
];
|
|
1844
|
-
if (domain)
|
|
1845
|
-
parts.push(`domain=${domain}`);
|
|
1846
|
-
document.cookie = parts.join("; ");
|
|
3064
|
+
/** Local tiny classnames helper — avoids pulling clsx/tailwind-merge into
|
|
3065
|
+
* the shell just for the layout file. Falsy values are skipped. */
|
|
3066
|
+
function cx(...parts) {
|
|
3067
|
+
return parts.filter(Boolean).join(" ");
|
|
1847
3068
|
}
|
|
3069
|
+
|
|
1848
3070
|
/**
|
|
1849
|
-
*
|
|
1850
|
-
*
|
|
1851
|
-
* accounting / HR / platform subdomains.
|
|
1852
|
-
*
|
|
1853
|
-
* Local storage wins when it already has a value (H57): adopting the shared
|
|
1854
|
-
* cookie first used to overwrite an explicit choice — including one set via
|
|
1855
|
-
* DevTools — and write `theme=light` back on every load whenever the cookie
|
|
1856
|
-
* still said light. Push the local value into the cookie instead so sibling
|
|
1857
|
-
* apps catch up.
|
|
3071
|
+
* The product mark at the top of the workspace sidebar: the app icon, the
|
|
3072
|
+
* name, and a spaced caption beneath it.
|
|
1858
3073
|
*/
|
|
1859
|
-
function
|
|
1860
|
-
const {
|
|
1861
|
-
const
|
|
1862
|
-
|
|
1863
|
-
const local = typeof window !== "undefined"
|
|
1864
|
-
? window.localStorage.getItem("theme")
|
|
1865
|
-
: null;
|
|
1866
|
-
const cookie = readThemeCookie();
|
|
1867
|
-
if (local) {
|
|
1868
|
-
if (local !== theme)
|
|
1869
|
-
setTheme(local);
|
|
1870
|
-
writeThemeCookie(local);
|
|
1871
|
-
}
|
|
1872
|
-
else if (cookie) {
|
|
1873
|
-
if (cookie !== theme)
|
|
1874
|
-
setTheme(cookie);
|
|
1875
|
-
}
|
|
1876
|
-
else if (theme) {
|
|
1877
|
-
writeThemeCookie(theme);
|
|
1878
|
-
}
|
|
1879
|
-
// Run once on mount — `theme` is intentionally not a dependency here.
|
|
1880
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1881
|
-
}, []);
|
|
1882
|
-
React__namespace.useEffect(() => {
|
|
1883
|
-
// Skip the initial render; only persist genuine changes (incl. the
|
|
1884
|
-
// setTheme() triggered by adopting storage above — harmless re-write).
|
|
1885
|
-
if (skipFirstWrite.current) {
|
|
1886
|
-
skipFirstWrite.current = false;
|
|
1887
|
-
return;
|
|
1888
|
-
}
|
|
1889
|
-
if (theme)
|
|
1890
|
-
writeThemeCookie(theme);
|
|
1891
|
-
}, [theme]);
|
|
1892
|
-
return null;
|
|
1893
|
-
}
|
|
1894
|
-
function ThemeProvider({ children, ...props }) {
|
|
1895
|
-
return (jsxRuntime.jsxs(nextThemes.ThemeProvider, { ...props, children: [jsxRuntime.jsx(ThemeCookieSync, {}), children] }));
|
|
3074
|
+
function WorkspaceBrand({ name, caption, mark, href, compact = false, className, }) {
|
|
3075
|
+
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] }))] }));
|
|
3076
|
+
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);
|
|
3077
|
+
return href ? (jsxRuntime.jsx(Link, { href: href, className: cls, "aria-label": compact ? name : undefined, children: body })) : (jsxRuntime.jsx("div", { className: cls, children: body }));
|
|
1896
3078
|
}
|
|
1897
3079
|
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
enableMultiCurrency: false,
|
|
1901
|
-
enableThaiTaxCalculation: true,
|
|
1902
|
-
enableProcurementWorkflow: true,
|
|
1903
|
-
enableInventoryTracking: true,
|
|
1904
|
-
enableAuditTrail: true,
|
|
1905
|
-
enableAdvancedFilter: true,
|
|
1906
|
-
filterFlag: false,
|
|
1907
|
-
setFilterFlag: () => { },
|
|
1908
|
-
};
|
|
1909
|
-
const FeatureFlagsContext = React.createContext(defaultFlags);
|
|
1910
|
-
function FeatureFlagsProvider({ children, flags = {}, }) {
|
|
1911
|
-
const [filterFlag, setFilterFlag] = React.useState(false);
|
|
1912
|
-
const mergedFlags = {
|
|
1913
|
-
...defaultFlags,
|
|
1914
|
-
...flags,
|
|
1915
|
-
filterFlag: flags.filterFlag ?? filterFlag,
|
|
1916
|
-
setFilterFlag: (flag) => {
|
|
1917
|
-
setFilterFlag(flag);
|
|
1918
|
-
if (typeof flags.setFilterFlag === "function") {
|
|
1919
|
-
flags.setFilterFlag(flag);
|
|
1920
|
-
}
|
|
1921
|
-
},
|
|
1922
|
-
};
|
|
1923
|
-
return (jsxRuntime.jsx(FeatureFlagsContext.Provider, { value: mergedFlags, children: children }));
|
|
1924
|
-
}
|
|
1925
|
-
function useFeatureFlags() {
|
|
1926
|
-
const context = React.useContext(FeatureFlagsContext);
|
|
1927
|
-
if (!context) {
|
|
1928
|
-
throw new Error("useFeatureFlags must be used within a FeatureFlagsProvider");
|
|
1929
|
-
}
|
|
1930
|
-
return context;
|
|
1931
|
-
}
|
|
1932
|
-
function useFeatureFlag(flag) {
|
|
1933
|
-
const flags = useFeatureFlags();
|
|
1934
|
-
return flags[flag];
|
|
3080
|
+
function isExternal(href) {
|
|
3081
|
+
return /^https?:\/\//i.test(href);
|
|
1935
3082
|
}
|
|
1936
|
-
|
|
1937
|
-
const getAxiosErrorMessage$1 = (error) => {
|
|
1938
|
-
if (error.response?.data?.message) {
|
|
1939
|
-
return error.response.data.message;
|
|
1940
|
-
}
|
|
1941
|
-
if (error.message) {
|
|
1942
|
-
return error.message;
|
|
1943
|
-
}
|
|
1944
|
-
return "An unknown error occurred";
|
|
1945
|
-
};
|
|
1946
|
-
const formatError = (handlerName, error) => {
|
|
1947
|
-
const axiosError = error;
|
|
1948
|
-
const message = getAxiosErrorMessage$1(axiosError);
|
|
1949
|
-
const isOrgRequired = message.includes("Organization context is required") ||
|
|
1950
|
-
message.includes("organization_id") ||
|
|
1951
|
-
message.includes("X-Organization-ID");
|
|
1952
|
-
if (isOrgRequired) {
|
|
1953
|
-
// Expected when no org selected yet (e.g. select-organization page).
|
|
1954
|
-
console.warn(`[React Query] ${handlerName} skipped (no organization context): ${message}`);
|
|
1955
|
-
return;
|
|
1956
|
-
}
|
|
1957
|
-
};
|
|
1958
3083
|
/**
|
|
1959
|
-
*
|
|
1960
|
-
*
|
|
1961
|
-
* - 5 min staleTime, refetchOnWindowFocus
|
|
1962
|
-
* - swallows "no org context" errors to silence noise on the
|
|
1963
|
-
* select-organization page
|
|
1964
|
-
*
|
|
1965
|
-
* Apps can compose on top of this — pass the returned config to `new QueryClient(...)`
|
|
1966
|
-
* directly or spread + override default options.
|
|
3084
|
+
* The card at the foot of the workspace sidebar that points someone who
|
|
3085
|
+
* does not know where to start at the place that will tell them.
|
|
1967
3086
|
*/
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
//
|
|
1982
|
-
// With retry: 1 every failed mutation fired twice. Two visible
|
|
1983
|
-
// consequences: a rejected submit showed up as two failed POSTs in the
|
|
1984
|
-
// network tab, and — far worse — a create whose response was lost in
|
|
1985
|
-
// flight would be re-sent and duplicate the record server-side.
|
|
1986
|
-
//
|
|
1987
|
-
// Retrying is the caller's decision, not a global default: the user
|
|
1988
|
-
// sees the error and presses the button again.
|
|
1989
|
-
retry: 0,
|
|
1990
|
-
},
|
|
1991
|
-
},
|
|
1992
|
-
queryCache: new reactQuery.QueryCache({
|
|
1993
|
-
onError: error => formatError("Query", error),
|
|
1994
|
-
}),
|
|
1995
|
-
mutationCache: new reactQuery.MutationCache({
|
|
1996
|
-
onError: error => formatError("Mutation", error),
|
|
1997
|
-
}),
|
|
1998
|
-
});
|
|
3087
|
+
function SidebarHelpCard({ title, description, actionLabel, href, icon, className, }) {
|
|
3088
|
+
const external = isExternal(href);
|
|
3089
|
+
const action = (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [actionLabel, jsxRuntime.jsx(lucideReact.ArrowRight, { className: "size-[15px]" })] }));
|
|
3090
|
+
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";
|
|
3091
|
+
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 }))] }));
|
|
3092
|
+
}
|
|
3093
|
+
/** A quiet row under the help card — the manual, the support chat. */
|
|
3094
|
+
function SidebarHelpLink({ label, href, icon, className }) {
|
|
3095
|
+
const external = isExternal(href);
|
|
3096
|
+
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);
|
|
3097
|
+
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] }));
|
|
3098
|
+
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 }));
|
|
3099
|
+
}
|
|
1999
3100
|
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
3101
|
+
/**
|
|
3102
|
+
* Central "no access" screen shared by every policy check (route-level
|
|
3103
|
+
* PermissionProvider and page-level PermissionGateShell). One look everywhere:
|
|
3104
|
+
* an illustration, a 403 headline, the page-specific reason, and recovery
|
|
3105
|
+
* actions.
|
|
3106
|
+
*/
|
|
3107
|
+
function AccessDenied({ title = "ขออภัย, คุณไม่มีสิทธิ์เข้าถึงหน้านี้ (403)", message = "คุณไม่มีสิทธิ์เข้าถึงหน้านี้ ขออภัยในความไม่สะดวก", homeHref, homeLabel = "กลับไปหน้าหลัก", supportHref, supportLabel = "ติดต่อฝ่ายสนับสนุน", fullScreen = true, className, }) {
|
|
3108
|
+
// "/" is not the home page inside an app — it bounces to organization
|
|
3109
|
+
// selection, so the button read "กลับไปหน้าหลัก" and dropped the user into a
|
|
3110
|
+
// picker for the org they were already in. Send them to that org's home.
|
|
3111
|
+
const params = navigation.useParams();
|
|
3112
|
+
const code = typeof params?.code === "string" ? params.code : "";
|
|
3113
|
+
const homeTarget = homeHref ?? (code ? `/${code}` : "/");
|
|
3114
|
+
const go = (href) => {
|
|
3115
|
+
if (typeof window !== "undefined")
|
|
3116
|
+
window.location.href = href;
|
|
3117
|
+
};
|
|
3118
|
+
const wrapperClass = [
|
|
3119
|
+
"flex min-w-0 flex-1 items-center justify-center p-4",
|
|
3120
|
+
fullScreen ? "min-h-[70vh]" : "",
|
|
3121
|
+
className ?? "",
|
|
3122
|
+
]
|
|
3123
|
+
.filter(Boolean)
|
|
3124
|
+
.join(" ");
|
|
3125
|
+
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] })] })] }) }) }));
|
|
2004
3126
|
}
|
|
2005
3127
|
|
|
3128
|
+
const EMPLOYEE_RECORD_REQUIRED = "EMPLOYEE_RECORD_REQUIRED";
|
|
3129
|
+
const EMPLOYEE_RECORD_REQUIRED_EVENT = "erp:employee-record-required";
|
|
3130
|
+
const EMPLOYEE_RECORD_OK_EVENT = "erp:employee-record-ok";
|
|
2006
3131
|
/**
|
|
2007
|
-
*
|
|
2008
|
-
*
|
|
2009
|
-
* session — so `useMyProfile` doesn't fire a redundant GET on initial load.
|
|
3132
|
+
* Shown when the API refuses every request because the signed-in account has
|
|
3133
|
+
* no employee record in this organisation.
|
|
2010
3134
|
*
|
|
2011
|
-
*
|
|
3135
|
+
* A gate rather than a banner. Leave, attendance, approvals and payslips all
|
|
3136
|
+
* identify a person by that record, so once the server started enforcing it
|
|
3137
|
+
* every call fails — and without something that says so, the app becomes a
|
|
3138
|
+
* screen of error toasts with no explanation and no way forward.
|
|
2012
3139
|
*/
|
|
2013
|
-
function
|
|
2014
|
-
const
|
|
2015
|
-
const
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
}
|
|
2033
|
-
}
|
|
2034
|
-
}
|
|
2035
|
-
}
|
|
2036
|
-
if (status === "unauthenticated") {
|
|
2037
|
-
queryClient.removeQueries({ queryKey });
|
|
2038
|
-
}
|
|
2039
|
-
return null;
|
|
3140
|
+
function EmployeeRequiredGate({ platformBaseUrl, }) {
|
|
3141
|
+
const [blocked, setBlocked] = React.useState(false);
|
|
3142
|
+
const params = navigation.useParams();
|
|
3143
|
+
const code = params?.code ?? "";
|
|
3144
|
+
React.useEffect(() => {
|
|
3145
|
+
const onBlocked = () => setBlocked(true);
|
|
3146
|
+
const onOk = () => setBlocked(false);
|
|
3147
|
+
window.addEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, onBlocked);
|
|
3148
|
+
window.addEventListener(EMPLOYEE_RECORD_OK_EVENT, onOk);
|
|
3149
|
+
return () => {
|
|
3150
|
+
window.removeEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, onBlocked);
|
|
3151
|
+
window.removeEventListener(EMPLOYEE_RECORD_OK_EVENT, onOk);
|
|
3152
|
+
};
|
|
3153
|
+
}, []);
|
|
3154
|
+
if (!blocked)
|
|
3155
|
+
return null;
|
|
3156
|
+
const base = platformBaseUrl?.replace(/\/$/, "") ?? "";
|
|
3157
|
+
const createHref = code ? `${base}/${code}/employee/create` : `${base}/`;
|
|
3158
|
+
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" })] }) }));
|
|
2040
3159
|
}
|
|
2041
3160
|
|
|
2042
|
-
const
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
});
|
|
2051
|
-
if (!response.ok)
|
|
2052
|
-
return null;
|
|
2053
|
-
const session = (await response.json());
|
|
2054
|
-
return session.accessToken ?? null;
|
|
2055
|
-
};
|
|
2056
|
-
const getAccessToken = async (forceRefresh = false) => {
|
|
2057
|
-
const now = Date.now();
|
|
2058
|
-
if (!forceRefresh && cachedToken && now - cachedAt < TOKEN_TTL_MS) {
|
|
2059
|
-
return cachedToken;
|
|
3161
|
+
const shellClassName = "flex min-w-0 flex-1 flex-col gap-6 overflow-x-hidden";
|
|
3162
|
+
/**
|
|
3163
|
+
* Shared layout for “กำลังตรวจสอบสิทธิ์” (skeleton) and “ไม่มีสิทธิ์” (message).
|
|
3164
|
+
* Renders `children` only when policy is loaded and access is granted.
|
|
3165
|
+
*/
|
|
3166
|
+
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", }) {
|
|
3167
|
+
if (policyLoading) {
|
|
3168
|
+
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))) }) }));
|
|
2060
3169
|
}
|
|
2061
|
-
if (!
|
|
2062
|
-
|
|
2063
|
-
.then(token => {
|
|
2064
|
-
cachedToken = token;
|
|
2065
|
-
cachedAt = Date.now();
|
|
2066
|
-
return token;
|
|
2067
|
-
})
|
|
2068
|
-
.finally(() => {
|
|
2069
|
-
inFlightTokenPromise = null;
|
|
2070
|
-
});
|
|
3170
|
+
if (!hasPolicyAccess) {
|
|
3171
|
+
return (jsxRuntime.jsx("div", { className: shellClassName, children: jsxRuntime.jsx(AccessDenied, { message: deniedMessage }) }));
|
|
2071
3172
|
}
|
|
2072
|
-
return
|
|
2073
|
-
}
|
|
2074
|
-
const clearAccessTokenCache = () => {
|
|
2075
|
-
cachedToken = null;
|
|
2076
|
-
cachedAt = 0;
|
|
2077
|
-
};
|
|
3173
|
+
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
|
|
3174
|
+
}
|
|
2078
3175
|
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
}
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
}
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
3176
|
+
const PermissionRulesContext = React.createContext(undefined);
|
|
3177
|
+
/**
|
|
3178
|
+
* Generic provider — the consumer app passes in `data` (live fetch) and
|
|
3179
|
+
* `fallbackPolicies` (static JSON). All apps share the same mapping logic
|
|
3180
|
+
* (build a flat map of route_key/policy_key → required child groups).
|
|
3181
|
+
*/
|
|
3182
|
+
function PermissionRulesProvider({ children, data, isLoading = false, isError = false, fallbackPolicies = [], }) {
|
|
3183
|
+
const rulesMap = React.useMemo(() => {
|
|
3184
|
+
const m = {};
|
|
3185
|
+
if (!data?.routes)
|
|
3186
|
+
return m;
|
|
3187
|
+
for (const row of data.routes) {
|
|
3188
|
+
m[row.route_key] = row.required_child_groups ?? [];
|
|
3189
|
+
}
|
|
3190
|
+
return m;
|
|
3191
|
+
}, [data]);
|
|
3192
|
+
const policiesMap = React.useMemo(() => {
|
|
3193
|
+
const m = {};
|
|
3194
|
+
for (const row of fallbackPolicies) {
|
|
3195
|
+
m[row.policy_key] = row.required_child_groups ?? [];
|
|
3196
|
+
}
|
|
3197
|
+
if (data?.policies?.length) {
|
|
3198
|
+
for (const row of data.policies) {
|
|
3199
|
+
m[row.policy_key] = row.required_child_groups ?? [];
|
|
3200
|
+
}
|
|
3201
|
+
}
|
|
3202
|
+
return m;
|
|
3203
|
+
}, [data, fallbackPolicies]);
|
|
3204
|
+
const value = React.useMemo(() => ({
|
|
3205
|
+
rulesMap,
|
|
3206
|
+
policiesMap,
|
|
3207
|
+
isLoading,
|
|
3208
|
+
isError,
|
|
3209
|
+
updatedAt: data?.updated_at ?? null,
|
|
3210
|
+
}), [rulesMap, policiesMap, isLoading, isError, data?.updated_at]);
|
|
3211
|
+
return (jsxRuntime.jsx(PermissionRulesContext.Provider, { value: value, children: children }));
|
|
2112
3212
|
}
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
});
|
|
2128
|
-
}
|
|
2129
|
-
if (error instanceof Error) {
|
|
2130
|
-
return new ApiError(error.message);
|
|
3213
|
+
/**
|
|
3214
|
+
* Read the permission rules. Outside the provider, report loading so
|
|
3215
|
+
* fail-closed consumers (nav / gates) do not treat empty maps as "allow all".
|
|
3216
|
+
*/
|
|
3217
|
+
function usePermissionRules() {
|
|
3218
|
+
const ctx = React.useContext(PermissionRulesContext);
|
|
3219
|
+
if (!ctx) {
|
|
3220
|
+
return {
|
|
3221
|
+
rulesMap: {},
|
|
3222
|
+
policiesMap: {},
|
|
3223
|
+
isLoading: true,
|
|
3224
|
+
isError: false,
|
|
3225
|
+
updatedAt: null,
|
|
3226
|
+
};
|
|
2131
3227
|
}
|
|
2132
|
-
return
|
|
2133
|
-
}
|
|
3228
|
+
return ctx;
|
|
3229
|
+
}
|
|
2134
3230
|
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
3231
|
+
/**
|
|
3232
|
+
* Strip the org `[code]` prefix from a pathname so we can look it up in the
|
|
3233
|
+
* permission rules map. `/O12345/setting/foo` → `/setting/foo`. Single-segment
|
|
3234
|
+
* paths (e.g. `/select-organization`) keep their leading slash.
|
|
3235
|
+
*
|
|
3236
|
+
* Also normalizes the user-role subtab aliases — both `purchase-doc` and
|
|
3237
|
+
* `sales-doc` are gated by the same `members` rule because the UI shows
|
|
3238
|
+
* the same management screen with different filters.
|
|
3239
|
+
*/
|
|
3240
|
+
function normalizePathnameForPermissionRules(pathname) {
|
|
3241
|
+
const parts = pathname.split("/").filter(Boolean);
|
|
3242
|
+
const path = parts.length <= 1 ? "/" : "/" + parts.slice(1).join("/");
|
|
3243
|
+
return path.replace(/\/user-role\/(purchase-doc|sales-doc)$/, "/user-role/members");
|
|
3244
|
+
}
|
|
3245
|
+
function hasRule(rulesMap, routeKey) {
|
|
3246
|
+
return Object.prototype.hasOwnProperty.call(rulesMap, routeKey);
|
|
3247
|
+
}
|
|
3248
|
+
/**
|
|
3249
|
+
* Resolve UI route permissions for a pathname.
|
|
3250
|
+
*
|
|
3251
|
+
* - Exact / `[param]` pattern match → matched
|
|
3252
|
+
* - Parent-prefix inherit only when the parent rule has a **non-empty**
|
|
3253
|
+
* role list (restrictive inherit). Explicitly public parents (`[]`) do
|
|
3254
|
+
* not open all children.
|
|
3255
|
+
* - No match → `{ matched: false }` (fail closed — never treat as public)
|
|
3256
|
+
*/
|
|
3257
|
+
function resolveUiRoutePermission(pathname, rulesMap) {
|
|
3258
|
+
const normalized = normalizePathnameForPermissionRules(pathname);
|
|
3259
|
+
if (hasRule(rulesMap, normalized)) {
|
|
3260
|
+
return { matched: true, roles: rulesMap[normalized] ?? [] };
|
|
2143
3261
|
}
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
3262
|
+
const pathSegments = normalized.split("/").filter(Boolean);
|
|
3263
|
+
for (const [pattern, roles] of Object.entries(rulesMap)) {
|
|
3264
|
+
const patternSegments = pattern.split("/").filter(Boolean);
|
|
3265
|
+
if (patternSegments.length !== pathSegments.length)
|
|
3266
|
+
continue;
|
|
3267
|
+
let matches = true;
|
|
3268
|
+
for (let i = 0; i < patternSegments.length; i++) {
|
|
3269
|
+
const patternSegment = patternSegments[i];
|
|
3270
|
+
const pathSegment = pathSegments[i];
|
|
3271
|
+
if (patternSegment.startsWith("[") && patternSegment.endsWith("]")) {
|
|
3272
|
+
if (!pathSegment) {
|
|
3273
|
+
matches = false;
|
|
3274
|
+
break;
|
|
3275
|
+
}
|
|
3276
|
+
}
|
|
3277
|
+
else if (patternSegment !== pathSegment) {
|
|
3278
|
+
matches = false;
|
|
3279
|
+
break;
|
|
3280
|
+
}
|
|
2157
3281
|
}
|
|
2158
|
-
if (
|
|
2159
|
-
|
|
2160
|
-
return;
|
|
3282
|
+
if (matches) {
|
|
3283
|
+
return { matched: true, roles: roles ?? [] };
|
|
2161
3284
|
}
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
3285
|
+
}
|
|
3286
|
+
// Parent-prefix: /leave/types/abc → /leave/types (non-empty roles only)
|
|
3287
|
+
for (let len = pathSegments.length - 1; len >= 1; len--) {
|
|
3288
|
+
const parent = "/" + pathSegments.slice(0, len).join("/");
|
|
3289
|
+
if (hasRule(rulesMap, parent) && (rulesMap[parent]?.length ?? 0) > 0) {
|
|
3290
|
+
return { matched: true, roles: rulesMap[parent] ?? [] };
|
|
2165
3291
|
}
|
|
3292
|
+
}
|
|
3293
|
+
return { matched: false, roles: [] };
|
|
3294
|
+
}
|
|
3295
|
+
/**
|
|
3296
|
+
* Returns the required child-group segments for a page pathname.
|
|
3297
|
+
*
|
|
3298
|
+
* Prefer {@link resolveUiRoutePermission} when you need to distinguish
|
|
3299
|
+
* "explicitly public (`[]`)" from "no rule (deny)". This helper returns
|
|
3300
|
+
* `[]` for both cases and is kept for backward compatibility.
|
|
3301
|
+
*/
|
|
3302
|
+
function getRequiredChildGroups(pathname, rulesMap) {
|
|
3303
|
+
return resolveUiRoutePermission(pathname, rulesMap).roles;
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
function DeniedCard() {
|
|
3307
|
+
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" }));
|
|
3308
|
+
}
|
|
3309
|
+
/**
|
|
3310
|
+
* Permission gate for a page subtree. Looks up the current pathname in the
|
|
3311
|
+
* permission rules map (live rules from `PermissionRulesProvider`, layered
|
|
3312
|
+
* over the optional `fallbackRulesMap`), passes the resolved required roles
|
|
3313
|
+
* into the host's `usePermission` probe, and renders one of:
|
|
3314
|
+
*
|
|
3315
|
+
* 1. rules / probe still loading → `null` on FIRST load only (let Suspense /
|
|
3316
|
+
* loading.tsx show); once this subtree has been granted access, a later
|
|
3317
|
+
* loading state keeps the children mounted — see the note below
|
|
3318
|
+
* 2. no rule covers this path → deny (fail closed)
|
|
3319
|
+
* 3. rule is explicit public (`[]`) → children
|
|
3320
|
+
* 4. probe says no access → deny card
|
|
3321
|
+
* 5. otherwise → children
|
|
3322
|
+
*/
|
|
3323
|
+
function PermissionProvider({ children, usePermission, fallbackRulesMap, }) {
|
|
3324
|
+
const pathname = navigation.usePathname();
|
|
3325
|
+
const { rulesMap, isLoading: rulesLoading } = usePermissionRules();
|
|
3326
|
+
const mergedMap = React.useMemo(() => {
|
|
3327
|
+
if (!fallbackRulesMap)
|
|
3328
|
+
return rulesMap;
|
|
3329
|
+
// live rules win over fallback when both define the same key
|
|
3330
|
+
return { ...fallbackRulesMap, ...rulesMap };
|
|
3331
|
+
}, [rulesMap, fallbackRulesMap]);
|
|
3332
|
+
const lookup = React.useMemo(() => resolveUiRoutePermission(pathname, mergedMap), [pathname, mergedMap]);
|
|
3333
|
+
// Hook must run unconditionally (rules of hooks).
|
|
3334
|
+
const { hasPermission, isLoading } = usePermission({
|
|
3335
|
+
requiredRoles: lookup.roles,
|
|
2166
3336
|
});
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
3337
|
+
// Returning null unmounts the page. That is fine before anything has been
|
|
3338
|
+
// shown, and destructive afterwards: next-auth flips its status back to
|
|
3339
|
+
// "loading" on every session update() — one runs every 4 minutes — and a
|
|
3340
|
+
// rules hook that reports loading on that transient took the page down with
|
|
3341
|
+
// it. Users filling a long form watched everything they had typed vanish,
|
|
3342
|
+
// which reads as a spontaneous page refresh.
|
|
3343
|
+
//
|
|
3344
|
+
// So a loading state only blanks the subtree until access has been granted
|
|
3345
|
+
// once. After that the children stay mounted while the check re-settles; the
|
|
3346
|
+
// API still enforces access on every request, and a genuine loss of
|
|
3347
|
+
// permission surfaces as a denied response rather than a wiped form.
|
|
3348
|
+
//
|
|
3349
|
+
// The grant is remembered per pathname, not per mount. This provider lives in
|
|
3350
|
+
// the layout, so it survives every in-app navigation — a bare boolean would
|
|
3351
|
+
// carry one page's grant onto the next route and flash a page the user may
|
|
3352
|
+
// not be allowed to see while its own check is still running.
|
|
3353
|
+
const grantedForPathRef = React.useRef(null);
|
|
3354
|
+
const grantedHere = grantedForPathRef.current === pathname;
|
|
3355
|
+
if (rulesLoading && !grantedHere) {
|
|
3356
|
+
return null;
|
|
2172
3357
|
}
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
const method = (config.method || "get").toLowerCase();
|
|
2177
|
-
const isWriteMethod = ["post", "put", "patch"].includes(method);
|
|
2178
|
-
if (!isWriteMethod || config.data === undefined || config.data === null) {
|
|
2179
|
-
return { valid: true };
|
|
3358
|
+
// Unknown route → deny. Only explicit `required_child_groups: []` is public.
|
|
3359
|
+
if (!lookup.matched) {
|
|
3360
|
+
return jsxRuntime.jsx(DeniedCard, {});
|
|
2180
3361
|
}
|
|
2181
|
-
if (
|
|
2182
|
-
|
|
3362
|
+
if (lookup.roles.length === 0) {
|
|
3363
|
+
grantedForPathRef.current = pathname;
|
|
3364
|
+
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
|
|
2183
3365
|
}
|
|
2184
|
-
|
|
2185
|
-
|
|
3366
|
+
if (isLoading && !grantedHere) {
|
|
3367
|
+
return null;
|
|
3368
|
+
}
|
|
3369
|
+
if (!hasPermission && !isLoading) {
|
|
3370
|
+
return jsxRuntime.jsx(DeniedCard, {});
|
|
3371
|
+
}
|
|
3372
|
+
grantedForPathRef.current = pathname;
|
|
3373
|
+
return jsxRuntime.jsx(jsxRuntime.Fragment, { children: children });
|
|
3374
|
+
}
|
|
2186
3375
|
|
|
2187
|
-
|
|
2188
|
-
const
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
3376
|
+
/** Cookie name for the cross-subdomain theme choice. */
|
|
3377
|
+
const THEME_COOKIE = "erp-theme";
|
|
3378
|
+
/**
|
|
3379
|
+
* Parent domain to scope the cookie to, so every `*.antniti.com` app reads the
|
|
3380
|
+
* same value. Returns `null` on localhost / bare IPs (host-only cookie).
|
|
3381
|
+
*/
|
|
3382
|
+
function sharedCookieDomain() {
|
|
3383
|
+
if (typeof window === "undefined")
|
|
2192
3384
|
return null;
|
|
2193
|
-
const
|
|
2194
|
-
|
|
2195
|
-
if (!orgCookie)
|
|
3385
|
+
const host = window.location.hostname;
|
|
3386
|
+
if (host === "localhost" || /^[0-9.]+$/.test(host))
|
|
2196
3387
|
return null;
|
|
2197
|
-
const
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
3388
|
+
const parts = host.split(".");
|
|
3389
|
+
if (parts.length < 2)
|
|
3390
|
+
return null;
|
|
3391
|
+
return "." + parts.slice(-2).join(".");
|
|
3392
|
+
}
|
|
3393
|
+
function readThemeCookie() {
|
|
3394
|
+
if (typeof document === "undefined")
|
|
3395
|
+
return null;
|
|
3396
|
+
const match = document.cookie.match(new RegExp("(?:^|; )" + THEME_COOKIE + "=([^;]*)"));
|
|
3397
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
3398
|
+
}
|
|
3399
|
+
function writeThemeCookie(value) {
|
|
3400
|
+
if (typeof document === "undefined")
|
|
2202
3401
|
return;
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
3402
|
+
const domain = sharedCookieDomain();
|
|
3403
|
+
const parts = [
|
|
3404
|
+
`${THEME_COOKIE}=${encodeURIComponent(value)}`,
|
|
3405
|
+
"path=/",
|
|
3406
|
+
"max-age=31536000", // 1 year
|
|
3407
|
+
"SameSite=Lax",
|
|
3408
|
+
];
|
|
3409
|
+
if (domain)
|
|
3410
|
+
parts.push(`domain=${domain}`);
|
|
3411
|
+
document.cookie = parts.join("; ");
|
|
3412
|
+
}
|
|
3413
|
+
/**
|
|
3414
|
+
* Bridges next-themes (per-origin localStorage) with a cookie scoped to the
|
|
3415
|
+
* parent domain, so the light/dark/system choice is shared across the
|
|
3416
|
+
* accounting / HR / platform subdomains.
|
|
3417
|
+
*
|
|
3418
|
+
* Local storage wins when it already has a value (H57): adopting the shared
|
|
3419
|
+
* cookie first used to overwrite an explicit choice — including one set via
|
|
3420
|
+
* DevTools — and write `theme=light` back on every load whenever the cookie
|
|
3421
|
+
* still said light. Push the local value into the cookie instead so sibling
|
|
3422
|
+
* apps catch up.
|
|
3423
|
+
*/
|
|
3424
|
+
function ThemeCookieSync() {
|
|
3425
|
+
const { theme, setTheme } = nextThemes.useTheme();
|
|
3426
|
+
const skipFirstWrite = React__namespace.useRef(true);
|
|
3427
|
+
React__namespace.useEffect(() => {
|
|
3428
|
+
const local = typeof window !== "undefined"
|
|
3429
|
+
? window.localStorage.getItem("theme")
|
|
3430
|
+
: null;
|
|
3431
|
+
const cookie = readThemeCookie();
|
|
3432
|
+
if (local) {
|
|
3433
|
+
if (local !== theme)
|
|
3434
|
+
setTheme(local);
|
|
3435
|
+
writeThemeCookie(local);
|
|
3436
|
+
}
|
|
3437
|
+
else if (cookie) {
|
|
3438
|
+
if (cookie !== theme)
|
|
3439
|
+
setTheme(cookie);
|
|
3440
|
+
}
|
|
3441
|
+
else if (theme) {
|
|
3442
|
+
writeThemeCookie(theme);
|
|
3443
|
+
}
|
|
3444
|
+
// Run once on mount — `theme` is intentionally not a dependency here.
|
|
3445
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3446
|
+
}, []);
|
|
3447
|
+
React__namespace.useEffect(() => {
|
|
3448
|
+
// Skip the initial render; only persist genuine changes (incl. the
|
|
3449
|
+
// setTheme() triggered by adopting storage above — harmless re-write).
|
|
3450
|
+
if (skipFirstWrite.current) {
|
|
3451
|
+
skipFirstWrite.current = false;
|
|
3452
|
+
return;
|
|
3453
|
+
}
|
|
3454
|
+
if (theme)
|
|
3455
|
+
writeThemeCookie(theme);
|
|
3456
|
+
}, [theme]);
|
|
3457
|
+
return null;
|
|
3458
|
+
}
|
|
3459
|
+
function ThemeProvider({ children, ...props }) {
|
|
3460
|
+
return (jsxRuntime.jsxs(nextThemes.ThemeProvider, { ...props, children: [jsxRuntime.jsx(ThemeCookieSync, {}), children] }));
|
|
3461
|
+
}
|
|
3462
|
+
|
|
3463
|
+
const defaultFlags = {
|
|
3464
|
+
enableAdvancedReporting: true,
|
|
3465
|
+
enableMultiCurrency: false,
|
|
3466
|
+
enableThaiTaxCalculation: true,
|
|
3467
|
+
enableProcurementWorkflow: true,
|
|
3468
|
+
enableInventoryTracking: true,
|
|
3469
|
+
enableAuditTrail: true,
|
|
3470
|
+
enableAdvancedFilter: true,
|
|
3471
|
+
filterFlag: false,
|
|
3472
|
+
setFilterFlag: () => { },
|
|
2208
3473
|
};
|
|
2209
|
-
const
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
3474
|
+
const FeatureFlagsContext = React.createContext(defaultFlags);
|
|
3475
|
+
function FeatureFlagsProvider({ children, flags = {}, }) {
|
|
3476
|
+
const [filterFlag, setFilterFlag] = React.useState(false);
|
|
3477
|
+
const mergedFlags = {
|
|
3478
|
+
...defaultFlags,
|
|
3479
|
+
...flags,
|
|
3480
|
+
filterFlag: flags.filterFlag ?? filterFlag,
|
|
3481
|
+
setFilterFlag: (flag) => {
|
|
3482
|
+
setFilterFlag(flag);
|
|
3483
|
+
if (typeof flags.setFilterFlag === "function") {
|
|
3484
|
+
flags.setFilterFlag(flag);
|
|
2220
3485
|
}
|
|
2221
|
-
}
|
|
2222
|
-
|
|
2223
|
-
}
|
|
2224
|
-
}
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
if (config.data instanceof FormData && config.headers) {
|
|
2230
|
-
delete config.headers["Content-Type"];
|
|
2231
|
-
}
|
|
2232
|
-
if (!isBrowser())
|
|
2233
|
-
return config;
|
|
2234
|
-
const token = await getAccessToken(false);
|
|
2235
|
-
if (token) {
|
|
2236
|
-
config.headers.Authorization = `Bearer ${token}`;
|
|
2237
|
-
}
|
|
2238
|
-
// localStorage first, cookie second.
|
|
2239
|
-
//
|
|
2240
|
-
// Both are written by the same state machine, but not at the same moment:
|
|
2241
|
-
// setOrganizationId writes localStorage synchronously and then awaits a
|
|
2242
|
-
// server action for the cookie. Preferring the cookie meant every request
|
|
2243
|
-
// fired during that round trip carried the previous organisation — while
|
|
2244
|
-
// the query params, built from React state, already carried the new one.
|
|
2245
|
-
//
|
|
2246
|
-
// The backend scopes by this header and ignores organization_id in the
|
|
2247
|
-
// query, so those requests asked for organisation A and were handed B, and
|
|
2248
|
-
// the answer landed in the cache under A's key. No amount of cache clearing
|
|
2249
|
-
// reaches that: the entry is wrong, not stale.
|
|
2250
|
-
const organizationId = getOrganizationId() || getOrgFromCookie();
|
|
2251
|
-
if (organizationId) {
|
|
2252
|
-
config.headers["X-Organization-ID"] = organizationId;
|
|
3486
|
+
},
|
|
3487
|
+
};
|
|
3488
|
+
return (jsxRuntime.jsx(FeatureFlagsContext.Provider, { value: mergedFlags, children: children }));
|
|
3489
|
+
}
|
|
3490
|
+
function useFeatureFlags() {
|
|
3491
|
+
const context = React.useContext(FeatureFlagsContext);
|
|
3492
|
+
if (!context) {
|
|
3493
|
+
throw new Error("useFeatureFlags must be used within a FeatureFlagsProvider");
|
|
2253
3494
|
}
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
3495
|
+
return context;
|
|
3496
|
+
}
|
|
3497
|
+
function useFeatureFlag(flag) {
|
|
3498
|
+
const flags = useFeatureFlags();
|
|
3499
|
+
return flags[flag];
|
|
3500
|
+
}
|
|
3501
|
+
|
|
3502
|
+
const getAxiosErrorMessage$1 = (error) => {
|
|
3503
|
+
if (error.response?.data?.message) {
|
|
3504
|
+
return error.response.data.message;
|
|
2257
3505
|
}
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
let refreshPromise = null;
|
|
2261
|
-
const refreshTokenSingleFlight = async () => {
|
|
2262
|
-
if (!refreshPromise) {
|
|
2263
|
-
refreshPromise = getAccessToken(true).finally(() => {
|
|
2264
|
-
refreshPromise = null;
|
|
2265
|
-
});
|
|
3506
|
+
if (error.message) {
|
|
3507
|
+
return error.message;
|
|
2266
3508
|
}
|
|
2267
|
-
return
|
|
3509
|
+
return "An unknown error occurred";
|
|
2268
3510
|
};
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
const originalRequest = error.config;
|
|
2280
|
-
const requestUrl = originalRequest?.url || error.config?.url;
|
|
2281
|
-
if (error.response?.status === 401 &&
|
|
2282
|
-
originalRequest &&
|
|
2283
|
-
!originalRequest._retry &&
|
|
2284
|
-
!isAuthEndpoint(requestUrl) &&
|
|
2285
|
-
!isAuthPage()) {
|
|
2286
|
-
originalRequest._retry = true;
|
|
2287
|
-
const newToken = await refreshTokenSingleFlight();
|
|
2288
|
-
if (newToken && originalRequest.headers) {
|
|
2289
|
-
originalRequest.headers.Authorization = `Bearer ${newToken}`;
|
|
2290
|
-
return instance(originalRequest);
|
|
2291
|
-
}
|
|
2292
|
-
await redirectToLogout();
|
|
2293
|
-
return Promise.reject(toApiError(error));
|
|
2294
|
-
}
|
|
2295
|
-
// The server refuses every organisation-scoped call when the account has
|
|
2296
|
-
// no employee record. Announce it once so a gate can explain it, instead
|
|
2297
|
-
// of letting each failed query raise its own toast.
|
|
2298
|
-
if (error.response?.status === 403) {
|
|
2299
|
-
const body = error.response.data;
|
|
2300
|
-
if (body?.code === "EMPLOYEE_RECORD_REQUIRED" && typeof window !== "undefined") {
|
|
2301
|
-
window.dispatchEvent(new CustomEvent("erp:employee-record-required"));
|
|
2302
|
-
}
|
|
3511
|
+
const formatError = (handlerName, error) => {
|
|
3512
|
+
const axiosError = error;
|
|
3513
|
+
const message = getAxiosErrorMessage$1(axiosError);
|
|
3514
|
+
const isOrgRequired = message.includes("Organization context is required") ||
|
|
3515
|
+
message.includes("organization_id") ||
|
|
3516
|
+
message.includes("X-Organization-ID");
|
|
3517
|
+
if (isOrgRequired) {
|
|
3518
|
+
// Expected when no org selected yet (e.g. select-organization page).
|
|
3519
|
+
console.warn(`[React Query] ${handlerName} skipped (no organization context): ${message}`);
|
|
3520
|
+
return;
|
|
2303
3521
|
}
|
|
2304
|
-
|
|
3522
|
+
};
|
|
3523
|
+
/**
|
|
3524
|
+
* Default React Query config used across ERP apps:
|
|
3525
|
+
* - 2 retries on queries / 1 on mutations, exponential backoff
|
|
3526
|
+
* - 5 min staleTime, refetchOnWindowFocus
|
|
3527
|
+
* - swallows "no org context" errors to silence noise on the
|
|
3528
|
+
* select-organization page
|
|
3529
|
+
*
|
|
3530
|
+
* Apps can compose on top of this — pass the returned config to `new QueryClient(...)`
|
|
3531
|
+
* directly or spread + override default options.
|
|
3532
|
+
*/
|
|
3533
|
+
const getQueryClientConfig = () => ({
|
|
3534
|
+
defaultOptions: {
|
|
3535
|
+
queries: {
|
|
3536
|
+
retry: 2,
|
|
3537
|
+
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
|
|
3538
|
+
staleTime: 5 * 60 * 1000,
|
|
3539
|
+
refetchOnWindowFocus: true,
|
|
3540
|
+
},
|
|
3541
|
+
mutations: {
|
|
3542
|
+
// NEVER auto-retry a mutation. Unlike a query, a mutation is a side
|
|
3543
|
+
// effect on a non-idempotent endpoint (submit, create, approve), and
|
|
3544
|
+
// React Query cannot tell "the network dropped" from "the server
|
|
3545
|
+
// rejected this on business grounds".
|
|
3546
|
+
//
|
|
3547
|
+
// With retry: 1 every failed mutation fired twice. Two visible
|
|
3548
|
+
// consequences: a rejected submit showed up as two failed POSTs in the
|
|
3549
|
+
// network tab, and — far worse — a create whose response was lost in
|
|
3550
|
+
// flight would be re-sent and duplicate the record server-side.
|
|
3551
|
+
//
|
|
3552
|
+
// Retrying is the caller's decision, not a global default: the user
|
|
3553
|
+
// sees the error and presses the button again.
|
|
3554
|
+
retry: 0,
|
|
3555
|
+
},
|
|
3556
|
+
},
|
|
3557
|
+
queryCache: new reactQuery.QueryCache({
|
|
3558
|
+
onError: error => formatError("Query", error),
|
|
3559
|
+
}),
|
|
3560
|
+
mutationCache: new reactQuery.MutationCache({
|
|
3561
|
+
onError: error => formatError("Mutation", error),
|
|
3562
|
+
}),
|
|
2305
3563
|
});
|
|
2306
3564
|
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
2312
|
-
|
|
2313
|
-
|
|
2314
|
-
|
|
2315
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
2319
|
-
|
|
2320
|
-
|
|
2321
|
-
|
|
3565
|
+
function ReactQueryProvider({ children }) {
|
|
3566
|
+
// Create a new QueryClient instance for each render to avoid SSR issues.
|
|
3567
|
+
const [queryClient] = React.useState(() => new reactQuery.QueryClient(getQueryClientConfig()));
|
|
3568
|
+
return (jsxRuntime.jsxs(reactQuery.QueryClientProvider, { client: queryClient, children: [children, jsxRuntime.jsx(reactQueryDevtools.ReactQueryDevtools, { client: queryClient, initialIsOpen: false })] }));
|
|
3569
|
+
}
|
|
3570
|
+
|
|
3571
|
+
/**
|
|
3572
|
+
* Renders nothing. Runs during render (after SessionProvider, inside
|
|
3573
|
+
* QueryClientProvider) to seed the cached profile slot from the JWT-backed
|
|
3574
|
+
* session — so `useMyProfile` doesn't fire a redundant GET on initial load.
|
|
3575
|
+
*
|
|
3576
|
+
* Generic over the profile shape; each app passes its own queryKey + mapper.
|
|
3577
|
+
*/
|
|
3578
|
+
function SessionBackedProfileCacheSeeder({ queryKey, mapper, }) {
|
|
3579
|
+
const { data: session, status } = react.useSession();
|
|
3580
|
+
const queryClient = reactQuery.useQueryClient();
|
|
3581
|
+
if (status === "authenticated" && session) {
|
|
3582
|
+
const mapped = mapper(session);
|
|
3583
|
+
if (mapped) {
|
|
3584
|
+
const existing = queryClient.getQueryData(queryKey);
|
|
3585
|
+
if (!existing) {
|
|
3586
|
+
// updatedAt: 0 → immediately stale so observers still GET /myprofile
|
|
3587
|
+
// for fields the JWT seed omits (avatar/signature paths).
|
|
3588
|
+
queryClient.setQueryData(queryKey, mapped, { updatedAt: 0 });
|
|
3589
|
+
}
|
|
3590
|
+
else {
|
|
3591
|
+
// Only overwrite when the mapped id changed — we don't know T's shape
|
|
3592
|
+
// so we rely on a shallow id check via JSON tag if present.
|
|
3593
|
+
const existingId = existing?.id;
|
|
3594
|
+
const mappedId = mapped?.id;
|
|
3595
|
+
if (existingId !== mappedId) {
|
|
3596
|
+
queryClient.setQueryData(queryKey, mapped, { updatedAt: 0 });
|
|
3597
|
+
}
|
|
3598
|
+
}
|
|
3599
|
+
}
|
|
2322
3600
|
}
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
record.data !== undefined &&
|
|
2326
|
-
isEnvelope(record)) {
|
|
2327
|
-
return record.data;
|
|
3601
|
+
if (status === "unauthenticated") {
|
|
3602
|
+
queryClient.removeQueries({ queryKey });
|
|
2328
3603
|
}
|
|
2329
|
-
return
|
|
2330
|
-
}
|
|
3604
|
+
return null;
|
|
3605
|
+
}
|
|
2331
3606
|
|
|
2332
3607
|
// Hooks
|
|
2333
3608
|
const updateOrganizationId = (organizationId) => {
|
|
@@ -2358,15 +3633,19 @@ exports.EMPLOYEE_RECORD_REQUIRED_EVENT = EMPLOYEE_RECORD_REQUIRED_EVENT;
|
|
|
2358
3633
|
exports.EmployeeRequiredGate = EmployeeRequiredGate;
|
|
2359
3634
|
exports.FeatureFlagsProvider = FeatureFlagsProvider;
|
|
2360
3635
|
exports.HeaderBackground = HeaderBackground;
|
|
3636
|
+
exports.HelpButton = HelpButton;
|
|
3637
|
+
exports.HelpDialogHost = HelpDialogHost;
|
|
2361
3638
|
exports.NavMain = NavMain;
|
|
2362
3639
|
exports.NavTools = NavTools;
|
|
2363
3640
|
exports.NavUser = NavUser;
|
|
3641
|
+
exports.OWNER_CHIP = OWNER_CHIP;
|
|
2364
3642
|
exports.OrganizationSwitcher = OrganizationSwitcher;
|
|
2365
3643
|
exports.PLAN_FEATURES = PLAN_FEATURES;
|
|
2366
3644
|
exports.PermissionGateShell = PermissionGateShell;
|
|
2367
3645
|
exports.PermissionProvider = PermissionProvider;
|
|
2368
3646
|
exports.PermissionRulesProvider = PermissionRulesProvider;
|
|
2369
3647
|
exports.ReactQueryProvider = ReactQueryProvider;
|
|
3648
|
+
exports.SHELL_VERSION = SHELL_VERSION;
|
|
2370
3649
|
exports.SessionBackedProfileCacheSeeder = SessionBackedProfileCacheSeeder;
|
|
2371
3650
|
exports.SessionRefreshTrigger = SessionRefreshTrigger;
|
|
2372
3651
|
exports.SidebarContent = SidebarContent;
|
|
@@ -2387,7 +3666,11 @@ exports.buildUrlWithCode = buildUrlWithCode;
|
|
|
2387
3666
|
exports.canAccessNavPermissionRoute = canAccessNavPermissionRoute;
|
|
2388
3667
|
exports.clearAccessTokenCache = clearAccessTokenCache;
|
|
2389
3668
|
exports.clearOrganizationId = clearOrganizationId;
|
|
3669
|
+
exports.closeHelp = closeHelp;
|
|
2390
3670
|
exports.decodeJWT = decodeJWT;
|
|
3671
|
+
exports.describeAttached = describeAttached;
|
|
3672
|
+
exports.describeBrowser = describeBrowser;
|
|
3673
|
+
exports.detectHelpApp = detectHelpApp;
|
|
2391
3674
|
exports.filterNavigationModulesByPermission = filterNavigationModulesByPermission;
|
|
2392
3675
|
exports.filterNavigationToolsByPermission = filterNavigationToolsByPermission;
|
|
2393
3676
|
exports.getAccessToken = getAccessToken;
|
|
@@ -2400,8 +3683,12 @@ exports.getRequiredChildGroups = getRequiredChildGroups;
|
|
|
2400
3683
|
exports.hasPlanFeature = hasPlanFeature;
|
|
2401
3684
|
exports.isLikelyTransientNetworkError = isLikelyTransientNetworkError;
|
|
2402
3685
|
exports.isPathActive = isPathActive;
|
|
3686
|
+
exports.myRolesQueryKey = myRolesQueryKey;
|
|
2403
3687
|
exports.normalizePathnameForPermissionRules = normalizePathnameForPermissionRules;
|
|
3688
|
+
exports.openHelp = openHelp;
|
|
3689
|
+
exports.parseMyRoles = parseMyRoles;
|
|
2404
3690
|
exports.resolveUiRoutePermission = resolveUiRoutePermission;
|
|
3691
|
+
exports.roleSummary = roleSummary;
|
|
2405
3692
|
exports.runGlobalRequestPrecheck = runGlobalRequestPrecheck;
|
|
2406
3693
|
exports.runWithTransientRetry = runWithTransientRetry;
|
|
2407
3694
|
exports.setOrganizationId = setOrganizationId;
|
|
@@ -2413,6 +3700,7 @@ exports.useFeatureFlag = useFeatureFlag;
|
|
|
2413
3700
|
exports.useFeatureFlags = useFeatureFlags;
|
|
2414
3701
|
exports.useInvalidateQueriesOnOrgChange = useInvalidateQueriesOnOrgChange;
|
|
2415
3702
|
exports.useKeycloakRoles = useKeycloakRoles;
|
|
3703
|
+
exports.useMyRoles = useMyRoles;
|
|
2416
3704
|
exports.useOrganizationIdState = useOrganizationIdState;
|
|
2417
3705
|
exports.usePermissionRules = usePermissionRules;
|
|
2418
3706
|
exports.useSessionRefresh = useSessionRefresh;
|