@eintrek/erp-shell 0.1.52 → 0.1.54
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 +53 -3
- package/dist/help/help-attach.d.ts +145 -0
- package/dist/help/help-dialog.d.ts +4 -2
- 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 +13 -1
- package/dist/http/platform-api.d.ts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.esm.js +1661 -818
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1663 -815
- package/dist/index.js.map +1 -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 +1 -1
- package/package.json +3 -2
package/dist/index.js
CHANGED
|
@@ -595,13 +595,13 @@ const TapMenu = ({ tabDatas, starterTab, currentTabIndex, variant = "main", }) =
|
|
|
595
595
|
tabDatas.map((tab, index) => {
|
|
596
596
|
if (isMainTab) {
|
|
597
597
|
// Main tab style: text with border-bottom indicator
|
|
598
|
-
return (jsxRuntime.jsx("button", { type: "button", onClick: () => handleTabClick(index, tab.route), className: `w-fit lg:!w-41 h-10 p-2 text-
|
|
598
|
+
return (jsxRuntime.jsx("button", { type: "button", onClick: () => handleTabClick(index, tab.route), className: `w-fit lg:!w-41 h-10 p-2 text-xs lg:text-base text-white ${currentTab === index
|
|
599
599
|
? "border-b-4 border-white text-white"
|
|
600
600
|
: ""}`, children: tab.label }, index));
|
|
601
601
|
}
|
|
602
602
|
else {
|
|
603
603
|
// Sub-tab style: rounded button style
|
|
604
|
-
return (jsxRuntime.jsx(erpTheme.Button, { type: "button", variant: currentTab === index ? "default" : "outline", onClick: () => handleTabClick(index, tab.route), className: "h-7 lg:!h-10 text-
|
|
604
|
+
return (jsxRuntime.jsx(erpTheme.Button, { type: "button", variant: currentTab === index ? "default" : "outline", onClick: () => handleTabClick(index, tab.route), className: "h-7 lg:!h-10 text-xs lg:text-base p-1 lg:p-2 min-w-[20px] lg:min-w-41 w-fit rounded-[12px] lg:rounded-[40px]", children: tab.label }, index));
|
|
605
605
|
}
|
|
606
606
|
}) }), tabDatas[currentTab]?.page] }));
|
|
607
607
|
};
|
|
@@ -800,14 +800,14 @@ function AppBreadcrumb({ labels = {}, homeLabel = "แดชบอร์ด", ro
|
|
|
800
800
|
if (segments.length === 0) {
|
|
801
801
|
return jsxRuntime.jsx("h1", { className: "text-xl font-semibold", children: homeLabel });
|
|
802
802
|
}
|
|
803
|
-
return (jsxRuntime.jsx(erpTheme.Breadcrumb, { children: jsxRuntime.jsxs(erpTheme.BreadcrumbList, { children: [jsxRuntime.jsx(erpTheme.BreadcrumbItem, { children: jsxRuntime.jsx(erpTheme.BreadcrumbLink, { asChild: true, children: jsxRuntime.jsx(Link, { href: "/", className: "font-thai text-
|
|
803
|
+
return (jsxRuntime.jsx(erpTheme.Breadcrumb, { children: jsxRuntime.jsxs(erpTheme.BreadcrumbList, { children: [jsxRuntime.jsx(erpTheme.BreadcrumbItem, { children: jsxRuntime.jsx(erpTheme.BreadcrumbLink, { asChild: true, children: jsxRuntime.jsx(Link, { href: "/", className: "font-thai text-xs font-normal text-[var(--header-fg-muted,rgb(255_255_255/0.8))] hover:text-[var(--header-fg,white)] transition-colors", children: rootLabel }) }) }), segments.map((segment, index) => {
|
|
804
804
|
const href = "/" + segments.slice(0, index + 1).join("/");
|
|
805
805
|
const isLast = index === segments.length - 1;
|
|
806
806
|
// A record id is not a place. Falling back to the raw segment put a
|
|
807
807
|
// 36-character uuid in the trail, which pushed everything before it
|
|
808
808
|
// off a phone and told the reader nothing they could use.
|
|
809
809
|
const label = labels[segment] ?? (isRecordID(segment) ? "รายละเอียด" : segment);
|
|
810
|
-
return (jsxRuntime.jsxs(React.Fragment, { children: [jsxRuntime.jsx(erpTheme.BreadcrumbSeparator, { className: "text-[var(--header-fg-muted,rgb(255_255_255/0.8))]", children: jsxRuntime.jsx(lucideReact.ChevronRight, { strokeWidth: 2, className: "h-4 w-4" }) }), jsxRuntime.jsx(erpTheme.BreadcrumbItem, { children: isLast ? (jsxRuntime.jsx(erpTheme.BreadcrumbPage, { className: "font-thai text-
|
|
810
|
+
return (jsxRuntime.jsxs(React.Fragment, { children: [jsxRuntime.jsx(erpTheme.BreadcrumbSeparator, { className: "text-[var(--header-fg-muted,rgb(255_255_255/0.8))]", children: jsxRuntime.jsx(lucideReact.ChevronRight, { strokeWidth: 2, className: "h-4 w-4" }) }), jsxRuntime.jsx(erpTheme.BreadcrumbItem, { children: isLast ? (jsxRuntime.jsx(erpTheme.BreadcrumbPage, { className: "font-thai text-xs font-normal text-[var(--header-fg,rgb(255_255_255/0.95))] leading-none", children: decodeURIComponent(label) })) : (jsxRuntime.jsx(erpTheme.BreadcrumbLink, { asChild: true, children: jsxRuntime.jsx(Link, { href: href, className: "font-thai text-xs font-normal text-[var(--header-fg-muted,rgb(255_255_255/0.8))] hover:text-[var(--header-fg,white)] transition-colors leading-none", children: decodeURIComponent(label) }) })) })] }, `${segment}-${index}`));
|
|
811
811
|
})] }) }));
|
|
812
812
|
}
|
|
813
813
|
/**
|
|
@@ -825,6 +825,9 @@ function isRecordID(segment) {
|
|
|
825
825
|
let open = false;
|
|
826
826
|
let hosts = 0;
|
|
827
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;
|
|
828
831
|
const listeners = new Set();
|
|
829
832
|
function emit() {
|
|
830
833
|
listeners.forEach(l => l());
|
|
@@ -843,14 +846,35 @@ function openHelp() {
|
|
|
843
846
|
}
|
|
844
847
|
}
|
|
845
848
|
function closeHelp() {
|
|
849
|
+
pendingRef = null;
|
|
846
850
|
if (open) {
|
|
847
851
|
open = false;
|
|
848
852
|
emit();
|
|
849
853
|
}
|
|
850
854
|
}
|
|
851
|
-
/**
|
|
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
|
+
}
|
|
869
|
+
/**
|
|
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.
|
|
873
|
+
*/
|
|
852
874
|
function registerHelpHost() {
|
|
853
875
|
hosts += 1;
|
|
876
|
+
if (pendingRef)
|
|
877
|
+
open = true;
|
|
854
878
|
emit();
|
|
855
879
|
return () => {
|
|
856
880
|
hosts -= 1;
|
|
@@ -880,569 +904,378 @@ function declaredHelpApp() {
|
|
|
880
904
|
return declaredApp;
|
|
881
905
|
}
|
|
882
906
|
|
|
883
|
-
function isExternalHref(href) {
|
|
884
|
-
return /^https?:\/\//i.test(href) || href.startsWith("//");
|
|
885
|
-
}
|
|
886
|
-
function NavUser({ organization, avatarSrc, profileHref, selectOrganizationHref = "/select-organization", signInRedirectHref = "/auth/signin", systemLinks, showSelectOrganization = true, showLanguageSwitch = true, variant = "brand", } = {}) {
|
|
887
|
-
const { data: session, status } = react.useSession();
|
|
888
|
-
const router = navigation.useRouter();
|
|
889
|
-
const { theme, setTheme } = nextThemes.useTheme();
|
|
890
|
-
const [isSigningOut, setIsSigningOut] = React.useState(false);
|
|
891
|
-
const [isOpen, setIsOpen] = React.useState(false);
|
|
892
|
-
const [showThemeMenu, setShowThemeMenu] = React.useState(false);
|
|
893
|
-
const [showLanguageMenu, setShowLanguageMenu] = React.useState(false);
|
|
894
|
-
const [showAppsMenu, setShowAppsMenu] = React.useState(false);
|
|
895
|
-
const buttonRef = React.useRef(null);
|
|
896
|
-
const [position, setPosition] = React.useState({ top: 0, left: 0 });
|
|
897
|
-
// Shown where a layout has mounted the help dialog (AppLayoutShell).
|
|
898
|
-
const helpAvailable = useHelpAvailable();
|
|
899
|
-
const user = session?.user
|
|
900
|
-
? {
|
|
901
|
-
name: session.user.name || "ผู้ใช้",
|
|
902
|
-
email: session.user.email || "",
|
|
903
|
-
// avatarSrc first: the NextAuth session carries no picture unless the
|
|
904
|
-
// host app deliberately puts one in the JWT, so relying on
|
|
905
|
-
// session.user.image alone left every user on their initials forever.
|
|
906
|
-
avatar: avatarSrc || session.user.image || undefined,
|
|
907
|
-
}
|
|
908
|
-
: null;
|
|
909
|
-
const getInitials = (name) => {
|
|
910
|
-
if (!name)
|
|
911
|
-
return "U";
|
|
912
|
-
return name
|
|
913
|
-
.split(" ")
|
|
914
|
-
.map(n => n[0])
|
|
915
|
-
.join("")
|
|
916
|
-
.toUpperCase()
|
|
917
|
-
.slice(0, 2);
|
|
918
|
-
};
|
|
919
|
-
const handleSignOut = async () => {
|
|
920
|
-
setIsSigningOut(true);
|
|
921
|
-
try {
|
|
922
|
-
await react.signOut({ callbackUrl: signInRedirectHref, redirect: true });
|
|
923
|
-
}
|
|
924
|
-
catch (error) {
|
|
925
|
-
console.error("Sign out error:", error);
|
|
926
|
-
window.location.href = signInRedirectHref;
|
|
927
|
-
}
|
|
928
|
-
finally {
|
|
929
|
-
setIsSigningOut(false);
|
|
930
|
-
}
|
|
931
|
-
};
|
|
932
|
-
const handleSelectOrganization = () => {
|
|
933
|
-
setIsOpen(false);
|
|
934
|
-
router.push(selectOrganizationHref);
|
|
935
|
-
};
|
|
936
|
-
const handleProfile = () => {
|
|
937
|
-
setIsOpen(false);
|
|
938
|
-
if (profileHref)
|
|
939
|
-
router.push(profileHref);
|
|
940
|
-
};
|
|
941
|
-
const handleSystemLink = (href) => {
|
|
942
|
-
setIsOpen(false);
|
|
943
|
-
if (isExternalHref(href)) {
|
|
944
|
-
window.location.assign(href);
|
|
945
|
-
return;
|
|
946
|
-
}
|
|
947
|
-
router.push(href);
|
|
948
|
-
};
|
|
949
|
-
const handleThemeChange = (newTheme) => {
|
|
950
|
-
setTheme(newTheme);
|
|
951
|
-
setShowThemeMenu(false);
|
|
952
|
-
};
|
|
953
|
-
const handleLanguageChange = (language) => {
|
|
954
|
-
console.log("Change language to:", language);
|
|
955
|
-
setShowLanguageMenu(false);
|
|
956
|
-
};
|
|
957
|
-
React.useEffect(() => {
|
|
958
|
-
if (isOpen && buttonRef.current) {
|
|
959
|
-
const rect = buttonRef.current.getBoundingClientRect();
|
|
960
|
-
setPosition({ top: rect.bottom, left: rect.right + 8 });
|
|
961
|
-
}
|
|
962
|
-
}, [isOpen]);
|
|
963
|
-
React.useEffect(() => {
|
|
964
|
-
if (!isOpen)
|
|
965
|
-
return;
|
|
966
|
-
const handleClickOutside = (event) => {
|
|
967
|
-
if (buttonRef.current &&
|
|
968
|
-
!buttonRef.current.contains(event.target) &&
|
|
969
|
-
!event.target.closest('[data-dropdown="nav-user"]')) {
|
|
970
|
-
setIsOpen(false);
|
|
971
|
-
}
|
|
972
|
-
};
|
|
973
|
-
document.addEventListener("mousedown", handleClickOutside);
|
|
974
|
-
return () => document.removeEventListener("mousedown", handleClickOutside);
|
|
975
|
-
}, [isOpen]);
|
|
976
|
-
if (status === "loading") {
|
|
977
|
-
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" })] })] }) }));
|
|
978
|
-
}
|
|
979
|
-
if (!user)
|
|
980
|
-
return null;
|
|
981
|
-
const visibleSystemLinks = (systemLinks ?? []).filter(link => link.href && link.label);
|
|
982
|
-
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 items-center gap-3", children: [organization.logoUrl ? (
|
|
983
|
-
// eslint-disable-next-line @next/next/no-img-element
|
|
984
|
-
jsxRuntime.jsx("img", { src: organization.logoUrl, alt: organization.name ?? "โลโก้บริษัท", className: "h-8 w-8 flex-shrink-0 rounded object-contain bg-white",
|
|
985
|
-
// A dead logo URL must not leave a broken-image glyph in the
|
|
986
|
-
// sidebar; drop back to the generic icon's empty slot.
|
|
987
|
-
onError: e => {
|
|
988
|
-
e.currentTarget.style.display = "none";
|
|
989
|
-
} })) : (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: () => {
|
|
990
|
-
setShowThemeMenu(!showThemeMenu);
|
|
991
|
-
setShowLanguageMenu(false);
|
|
992
|
-
setShowAppsMenu(false);
|
|
993
|
-
}, 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: [
|
|
994
|
-
{ key: "light", label: "สว่าง", Icon: lucideReact.Sun },
|
|
995
|
-
{ key: "dark", label: "มืด", Icon: lucideReact.Moon },
|
|
996
|
-
{ key: "system", label: "ตามระบบ", Icon: lucideReact.Monitor },
|
|
997
|
-
].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: () => {
|
|
998
|
-
setShowLanguageMenu(!showLanguageMenu);
|
|
999
|
-
setShowThemeMenu(false);
|
|
1000
|
-
setShowAppsMenu(false);
|
|
1001
|
-
}, 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: [
|
|
1002
|
-
{ code: "th", label: "ไทย" },
|
|
1003
|
-
{ code: "en", label: "English" },
|
|
1004
|
-
{ code: "zh", label: "中文" },
|
|
1005
|
-
{ code: "ja", label: "日本語" },
|
|
1006
|
-
].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: () => {
|
|
1007
|
-
setShowAppsMenu(!showAppsMenu);
|
|
1008
|
-
setShowThemeMenu(false);
|
|
1009
|
-
setShowLanguageMenu(false);
|
|
1010
|
-
}, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx(lucideReact.LayoutGrid, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E41\u0E2D\u0E1B\u0E1E\u0E25\u0E34\u0E40\u0E04\u0E0A\u0E31\u0E19" }), jsxRuntime.jsx(lucideReact.ChevronUp, { className: `h-4 w-4 transition-transform ${showAppsMenu ? "rotate-180" : ""}` })] }), showAppsMenu && (jsxRuntime.jsx("div", { className: "mt-1 ml-4 space-y-1", children: visibleSystemLinks.map(link => (jsxRuntime.jsxs("button", { type: "button", onClick: () => handleSystemLink(link.href), className: "flex w-full items-center gap-3 px-3 py-2 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [link.icon ?? jsxRuntime.jsx(lucideReact.ExternalLink, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: link.label }), link.active && (jsxRuntime.jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, `${link.label}:${link.href}`))) }))] })), helpAvailable && (jsxRuntime.jsxs("button", { type: "button", onClick: () => {
|
|
1011
|
-
setIsOpen(false);
|
|
1012
|
-
openHelp();
|
|
1013
|
-
}, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx(lucideReact.CircleHelp, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D" })] })), jsxRuntime.jsx("button", { onClick: handleSignOut, disabled: isSigningOut, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed mt-1", children: isSigningOut ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(lucideReact.Loader2, { className: "h-4 w-4 animate-spin" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E23\u0E30\u0E1A\u0E1A..." })] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(lucideReact.LogOut, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E23\u0E30\u0E1A\u0E1A" })] })) })] })] })) : null;
|
|
1014
|
-
if (variant === "workspace") {
|
|
1015
|
-
return (jsxRuntime.jsx("div", { className: "mt-3.5 border-t border-sidebar-border pt-3", children: jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsxs("button", { ref: buttonRef, onClick: () => setIsOpen(!isOpen), "aria-expanded": isOpen, className: "flex w-full items-center gap-[11px] rounded-lg px-3 py-1.5 text-left transition-colors hover:bg-sidebar-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring", children: [jsxRuntime.jsxs(erpTheme.Avatar, { className: "size-[34px] shrink-0 rounded-full border-[3px] border-sidebar-accent", children: [user.avatar ? (jsxRuntime.jsx(erpTheme.AvatarImage, { src: user.avatar, alt: user.name })) : null, jsxRuntime.jsx(erpTheme.AvatarFallback, { className: "rounded-full bg-sidebar-accent text-sm font-medium text-sidebar-accent-foreground", children: getInitials(user.name) })] }), jsxRuntime.jsxs("span", { className: "grid min-w-0 flex-1 leading-tight", children: [jsxRuntime.jsx("strong", { className: "truncate text-[13px] font-semibold", title: user.name, children: user.name }), jsxRuntime.jsx("small", { className: "truncate text-[11px] text-muted-foreground", title: user.email, children: user.email })] }), jsxRuntime.jsx(lucideReact.ChevronsUpDown, { className: "size-3.5 shrink-0 text-muted-foreground" })] }), typeof window !== "undefined" &&
|
|
1016
|
-
reactDom.createPortal(dropdownContent, document.body)] }) }));
|
|
1017
|
-
}
|
|
1018
|
-
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" &&
|
|
1019
|
-
reactDom.createPortal(dropdownContent, document.body)] }) }));
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
907
|
/**
|
|
1023
|
-
*
|
|
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.
|
|
1024
910
|
*
|
|
1025
|
-
*
|
|
1026
|
-
* private copies of the same list drift: the app-switcher list did exactly
|
|
1027
|
-
* that before it moved here.
|
|
911
|
+
* No runtime imports: the tests run this file in Node as it is.
|
|
1028
912
|
*/
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
BASIC_ACCOUNTING_HR: "basic_accounting_hr",
|
|
1035
|
-
MODULE_PLATFORM: "module.platform",
|
|
1036
|
-
MODULE_HR: "module.hr",
|
|
1037
|
-
MODULE_ACCOUNTING: "module.accounting",
|
|
1038
|
-
MODULE_SALES: "module.sales",
|
|
1039
|
-
MODULE_PURCHASING: "module.purchasing",
|
|
1040
|
-
MODULE_INVENTORY: "module.inventory",
|
|
1041
|
-
MODULE_PROJECT: "module.project",
|
|
1042
|
-
MODULE_SECRETARIAT: "module.secretariat",
|
|
1043
|
-
/** AI pack (paid or trial): AI calls on AntNiti's key while credits remain. */
|
|
1044
|
-
AI: "ai",
|
|
1045
|
-
};
|
|
1046
|
-
/** Keys the Accounx add-on carries with it (same alias as org_effective_features). */
|
|
1047
|
-
const ACCOUNTING_SUB_KEYS = [
|
|
1048
|
-
PLAN_FEATURES.MODULE_SALES,
|
|
1049
|
-
PLAN_FEATURES.MODULE_PURCHASING,
|
|
1050
|
-
PLAN_FEATURES.MODULE_SECRETARIAT,
|
|
1051
|
-
];
|
|
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);
|
|
1052
918
|
/**
|
|
1053
|
-
*
|
|
1054
|
-
*
|
|
1055
|
-
*
|
|
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.
|
|
1056
922
|
*/
|
|
1057
|
-
function
|
|
1058
|
-
if (!
|
|
1059
|
-
return
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
if ((
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
return true;
|
|
1067
|
-
}
|
|
1068
|
-
if (ACCOUNTING_SUB_KEYS.includes(feature) &&
|
|
1069
|
-
features.includes(PLAN_FEATURES.MODULE_ACCOUNTING)) {
|
|
1070
|
-
return true;
|
|
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;
|
|
1071
932
|
}
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
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
|
+
});
|
|
1076
950
|
}
|
|
1077
|
-
return
|
|
951
|
+
return { organizationId, isOwner: data.is_owner === true, roles };
|
|
1078
952
|
}
|
|
1079
|
-
|
|
1080
|
-
function
|
|
1081
|
-
|
|
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) };
|
|
1082
957
|
}
|
|
1083
|
-
/**
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
const APPS = [
|
|
1091
|
-
{
|
|
1092
|
-
id: "platform",
|
|
1093
|
-
label: "แพลตฟอร์ม",
|
|
1094
|
-
icon: jsxRuntime.jsx(lucideReact.Building2, { className: "h-4 w-4 shrink-0" }),
|
|
1095
|
-
features: null,
|
|
1096
|
-
baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_PLATFORM_URL, "https://platform.antniti.com"),
|
|
1097
|
-
},
|
|
1098
|
-
{
|
|
1099
|
-
id: "accounx",
|
|
1100
|
-
label: "Accounx",
|
|
1101
|
-
icon: jsxRuntime.jsx(lucideReact.Calculator, { className: "h-4 w-4 shrink-0" }),
|
|
1102
|
-
features: [PLAN_FEATURES.MODULE_ACCOUNTING],
|
|
1103
|
-
baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_ACCOUNTING_URL, "https://accounx.antniti.com"),
|
|
1104
|
-
},
|
|
1105
|
-
{
|
|
1106
|
-
id: "invenx",
|
|
1107
|
-
label: "Invenx",
|
|
1108
|
-
icon: jsxRuntime.jsx(lucideReact.Warehouse, { className: "h-4 w-4 shrink-0" }),
|
|
1109
|
-
features: [PLAN_FEATURES.MODULE_INVENTORY],
|
|
1110
|
-
baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_INVENX_URL, "https://invenx.antniti.com"),
|
|
1111
|
-
},
|
|
1112
|
-
{
|
|
1113
|
-
id: "plannex",
|
|
1114
|
-
label: "Plannex",
|
|
1115
|
-
icon: jsxRuntime.jsx(lucideReact.FolderKanban, { className: "h-4 w-4 shrink-0" }),
|
|
1116
|
-
// Tasks, day-logs and work records are free with HR; projects need the add-on.
|
|
1117
|
-
features: [PLAN_FEATURES.MODULE_HR, PLAN_FEATURES.MODULE_PROJECT],
|
|
1118
|
-
baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_PLANNEX_URL, "https://plannex.antniti.com"),
|
|
1119
|
-
},
|
|
1120
|
-
{
|
|
1121
|
-
id: "peoplx",
|
|
1122
|
-
label: "Peoplx",
|
|
1123
|
-
icon: jsxRuntime.jsx(lucideReact.Users, { className: "h-4 w-4 shrink-0" }),
|
|
1124
|
-
features: [PLAN_FEATURES.MODULE_HR],
|
|
1125
|
-
baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_HR_URL, "https://peoplx.antniti.com"),
|
|
1126
|
-
},
|
|
1127
|
-
];
|
|
1128
|
-
/**
|
|
1129
|
-
* Builds the cross-app link list for <NavUser systemLinks>.
|
|
1130
|
-
*
|
|
1131
|
-
* The current app is always present even when the org's plan no longer covers
|
|
1132
|
-
* it — the user is looking at it, so hiding the link would only make the menu
|
|
1133
|
-
* disagree with the screen.
|
|
1134
|
-
*/
|
|
1135
|
-
function buildSystemLinks({ orgCode, activeApp, features, }) {
|
|
1136
|
-
// The app names itself here; the help dialog says which app a message
|
|
1137
|
-
// came from by it.
|
|
1138
|
-
declareHelpApp(activeApp);
|
|
1139
|
-
if (!orgCode)
|
|
1140
|
-
return [];
|
|
1141
|
-
return APPS.filter(app => app.id === activeApp ||
|
|
1142
|
-
app.features === null ||
|
|
1143
|
-
app.features.some(key => hasPlanFeature(features, key))).map(app => ({
|
|
1144
|
-
label: app.label,
|
|
1145
|
-
icon: app.icon,
|
|
1146
|
-
// Same-app link stays relative so it never leaves the current origin.
|
|
1147
|
-
href: app.id === activeApp ? `/${orgCode}` : `${app.baseUrl()}/${orgCode}`,
|
|
1148
|
-
...(app.id === activeApp ? { active: true } : {}),
|
|
1149
|
-
}));
|
|
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);
|
|
1150
965
|
}
|
|
1151
966
|
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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;
|
|
1159
985
|
}
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
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
|
+
});
|
|
1166
996
|
}
|
|
1167
|
-
return
|
|
1168
|
-
}
|
|
997
|
+
return inFlightTokenPromise;
|
|
998
|
+
};
|
|
999
|
+
const clearAccessTokenCache = () => {
|
|
1000
|
+
cachedToken = null;
|
|
1001
|
+
cachedAt = 0;
|
|
1002
|
+
};
|
|
1169
1003
|
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
const workspace = chrome === "workspace";
|
|
1179
|
-
const params = navigation.useParams();
|
|
1180
|
-
const pathname = navigation.usePathname();
|
|
1181
|
-
const { setOpen } = useSidebar();
|
|
1182
|
-
const handleNavLinkClick = () => {
|
|
1183
|
-
if (closeOnNavigation)
|
|
1184
|
-
setOpen(false);
|
|
1185
|
-
};
|
|
1186
|
-
const code = orgCode ?? params?.code;
|
|
1187
|
-
// Which module (and sub-item) the current page belongs to.
|
|
1188
|
-
const states = items.map(item => {
|
|
1189
|
-
const itemUrl = buildUrlWithCode(code, item.url);
|
|
1190
|
-
// Most specific (longest) matching sub-item URL so only one link is active.
|
|
1191
|
-
const activeSubItemUrl = item.subModules?.reduce((best, subItem) => {
|
|
1192
|
-
const subItemUrl = buildUrlWithCode(code, subItem.url);
|
|
1193
|
-
const matches = isPathActive(pathname, subItemUrl) ||
|
|
1194
|
-
isPathActive(pathname, subItem.url);
|
|
1195
|
-
const subPageMatches = subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
|
|
1196
|
-
isPathActive(pathname, subPage.url));
|
|
1197
|
-
if (!matches && !subPageMatches)
|
|
1198
|
-
return best;
|
|
1199
|
-
const candidateUrl = subItemUrl;
|
|
1200
|
-
if (!best)
|
|
1201
|
-
return candidateUrl;
|
|
1202
|
-
return candidateUrl.length > best.length ? candidateUrl : best;
|
|
1203
|
-
}, null) ?? null;
|
|
1204
|
-
const hasActiveSubItem = !!activeSubItemUrl ||
|
|
1205
|
-
!!item.subModules?.some(subItem => subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
|
|
1206
|
-
isPathActive(pathname, subPage.url)));
|
|
1207
|
-
// Parent only active when path matches its URL exactly AND no child
|
|
1208
|
-
// is active (so parent+child don't both light up).
|
|
1209
|
-
const isItemActive = (highlightActiveModule && !item.subModules?.length && !!item.isActive) ||
|
|
1210
|
-
((workspace ? samePage(pathname, itemUrl) : pathname === itemUrl) &&
|
|
1211
|
-
!hasActiveSubItem);
|
|
1212
|
-
return { itemUrl, activeSubItemUrl, hasActiveSubItem, isItemActive };
|
|
1213
|
-
});
|
|
1214
|
-
// A group opens when its page becomes current and otherwise stays as the
|
|
1215
|
-
// user left it. defaultOpen alone decided once, on the first render — and
|
|
1216
|
-
// on a direct load the sub-items arrive after permissions do, so the group
|
|
1217
|
-
// holding the current page stayed shut.
|
|
1218
|
-
const activeKey = items
|
|
1219
|
-
.filter((_, i) => states[i].isItemActive || states[i].hasActiveSubItem)
|
|
1220
|
-
.map(item => item.id)
|
|
1221
|
-
.join(",");
|
|
1222
|
-
const [openIds, setOpenIds] = React__namespace.useState(() => new Set(activeKey ? activeKey.split(",") : []));
|
|
1223
|
-
React__namespace.useEffect(() => {
|
|
1224
|
-
if (!activeKey)
|
|
1225
|
-
return;
|
|
1226
|
-
setOpenIds(prev => {
|
|
1227
|
-
const ids = activeKey.split(",");
|
|
1228
|
-
if (ids.every(id => prev.has(id)))
|
|
1229
|
-
return prev;
|
|
1230
|
-
const next = new Set(prev);
|
|
1231
|
-
ids.forEach(id => next.add(id));
|
|
1232
|
-
return next;
|
|
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
|
|
1233
1012
|
});
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
const icon = workspace && IconComp ? (jsxRuntime.jsx(IconComp, { className: "size-[18px] shrink-0" })) : null;
|
|
1259
|
-
const badge = workspace && item.badge !== undefined && item.badge !== "" ? (jsxRuntime.jsx("span", { className: "ml-auto min-w-5 rounded-[5px] bg-primary/15 px-1 text-center text-[10px] leading-5 font-bold tabular-nums text-primary", children: item.badge })) : null;
|
|
1260
|
-
return (jsxRuntime.jsxs(React__namespace.Fragment, { children: [groupHeading, jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: open => setItemOpen(item.id, open), className: "group/collapsible", children: jsxRuntime.jsxs(SidebarMenuItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsx(SidebarMenuButton, { asChild: !item.subModules || item.subModules.length === 0, isActive: isItemActive, className: "data-[state=open]:text-sidebar-accent-foreground " +
|
|
1261
|
-
workspaceButton, children: item.subModules && item.subModules.length > 0 ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [icon, jsxRuntime.jsx("span", { id: item.id, children: item.titleTh }), jsxRuntime.jsx(lucideReact.ChevronRight, { className: "ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" })] })) : (jsxRuntime.jsxs(Link, { href: itemUrl, prefetch: false, className: "flex items-center w-full min-w-0 flex-1 " +
|
|
1262
|
-
(workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, children: [icon, jsxRuntime.jsx("span", { id: item.id, className: "flex-1 truncate", title: item.titleTh, children: item.titleTh }), badge] })) }) }), item.subModules && item.subModules.length > 0 && (jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: workspace
|
|
1263
|
-
? "mx-0 mt-1 mb-3 ml-[21px] gap-0.5 border-l border-sidebar-border pl-2.5 [&_a]:h-auto [&_a]:gap-[11px] [&_a]:px-3 [&_a]:py-2 [&_a]:text-[13px] [&_svg]:!size-[17px]"
|
|
1264
|
-
: undefined, children: item.subModules.map(subItem => {
|
|
1265
|
-
const subItemUrl = buildUrlWithCode(code, subItem.url);
|
|
1266
|
-
const isSubItemActive = subItemUrl === activeSubItemUrl;
|
|
1267
|
-
const SubIcon = subItem.icon;
|
|
1268
|
-
const approvalCount = approvalCounts && subItem.approvalCountKey
|
|
1269
|
-
? (approvalCounts[subItem.approvalCountKey] ?? 0)
|
|
1270
|
-
: 0;
|
|
1271
|
-
if (subItem.isCollapsible &&
|
|
1272
|
-
subItem.subPages &&
|
|
1273
|
-
subItem.subPages.length > 0) {
|
|
1274
|
-
const hasActiveSubPage = subItem.subPages.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)));
|
|
1275
|
-
return (jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, defaultOpen: isSubItemActive || hasActiveSubPage, className: "group/sub-collapsible", children: jsxRuntime.jsxs(SidebarMenuSubItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsxs(SidebarMenuSubButton, { className: "w-full", isActive: isSubItemActive || hasActiveSubPage, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), jsxRuntime.jsx(lucideReact.ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map(subPage => {
|
|
1276
|
-
const subPageUrl = buildUrlWithCode(code, subPage.url);
|
|
1277
|
-
const isSubPageActive = isPathActive(pathname, subPageUrl);
|
|
1278
|
-
const SubPageIcon = subPage.icon;
|
|
1279
|
-
return (jsxRuntime.jsx(SidebarMenuSubItem, { children: jsxRuntime.jsx(SidebarMenuSubButton, { asChild: true, className: `font-thai w-full text-sm ${isSubPageActive
|
|
1280
|
-
? "text-sidebar-accent-foreground font-medium"
|
|
1281
|
-
: "text-sidebar-foreground"}`, isActive: false, children: jsxRuntime.jsxs(Link, { href: subPageUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubPageIcon && (jsxRuntime.jsx(SubPageIcon, { className: "sidebar-subpage-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", title: subPage.titleTh, children: subPage.titleTh })] }) }) }, subPage.id));
|
|
1282
|
-
}) }) })] }) }, subItem.id));
|
|
1283
|
-
}
|
|
1284
|
-
return (jsxRuntime.jsx(SidebarMenuSubItem, { children: jsxRuntime.jsx(SidebarMenuSubButton, { asChild: true, className: "w-full" +
|
|
1285
|
-
(workspace && isSubItemActive
|
|
1286
|
-
? " font-semibold"
|
|
1287
|
-
: ""), isActive: isSubItemActive, children: jsxRuntime.jsxs(Link, { href: subItemUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), approvalCount > 0 && (jsxRuntime.jsx(erpTheme.Badge, { variant: "destructive", className: "ml-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums", children: approvalCount > 99
|
|
1288
|
-
? "99+"
|
|
1289
|
-
: approvalCount }))] }) }) }, subItem.id));
|
|
1290
|
-
}) }) }))] }) })] }, item.id));
|
|
1291
|
-
}) }) }));
|
|
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
|
+
}
|
|
1292
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
|
+
};
|
|
1293
1059
|
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
? "flex aspect-square size-8 shrink-0 items-center justify-center rounded-[9px] border border-[color:var(--header-mark-border,color-mix(in_oklab,var(--header-fg,currentColor)_28%,transparent))] bg-[var(--header-mark-bg,color-mix(in_oklab,var(--header-fg,currentColor)_10%,transparent))] text-[var(--header-mark-fg,var(--header-fg,currentColor))] overflow-hidden max-sm:size-7"
|
|
1300
|
-
: "flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground overflow-hidden";
|
|
1301
|
-
const icon = size === "sm" ? "size-3.5 shrink-0" : "size-4";
|
|
1302
|
-
if (logoUrl && !failed) {
|
|
1303
|
-
return (jsxRuntime.jsx("div", { className: erpTheme.cn(box, size === "md" && "bg-white"), children: jsxRuntime.jsx("img", { src: logoUrl, alt: "", className: "size-full object-contain p-0.5", onError: () => setFailed(true) }) }));
|
|
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;
|
|
1304
1065
|
}
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
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)));
|
|
1341
1142
|
}
|
|
1342
|
-
else {
|
|
1343
|
-
|
|
1143
|
+
else if (value !== undefined && value !== null) {
|
|
1144
|
+
searchParams.append(key, String(value));
|
|
1344
1145
|
}
|
|
1345
1146
|
});
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
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);
|
|
1353
1216
|
}
|
|
1354
|
-
|
|
1217
|
+
await redirectToLogout();
|
|
1218
|
+
return Promise.reject(toApiError(error));
|
|
1355
1219
|
}
|
|
1356
|
-
|
|
1357
|
-
|
|
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
|
+
}
|
|
1358
1228
|
}
|
|
1359
|
-
|
|
1360
|
-
|
|
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(/\/$/, "");
|
|
1361
1247
|
}
|
|
1362
|
-
return
|
|
1363
|
-
"bg-accent"), children: [jsxRuntime.jsx(OrgMark, { logoUrl: org.logoUrl, size: "sm" }), jsxRuntime.jsx("span", { className: "truncate", children: org.name })] }, org.id))), jsxRuntime.jsx(erpTheme.DropdownMenuSeparator, {}), jsxRuntime.jsxs(erpTheme.DropdownMenuItem, { className: "gap-2 p-2 cursor-pointer", onClick: () => router.push(registerOrganizationHref), children: [jsxRuntime.jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-transparent", children: jsxRuntime.jsx(lucideReact.Plus, { className: "size-4" }) }), jsxRuntime.jsx("span", { className: "text-muted-foreground font-medium", children: "\u0E40\u0E1E\u0E34\u0E48\u0E21\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" })] })] })] }) }) }));
|
|
1248
|
+
return DEFAULT_PLATFORM_API_URL;
|
|
1364
1249
|
}
|
|
1365
1250
|
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
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";
|
|
1369
1256
|
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
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 {
|
|
1377
1272
|
return null;
|
|
1378
|
-
if (chrome === "workspace") {
|
|
1379
|
-
return (jsxRuntime.jsxs("div", { className: erpTheme.cn("flex h-screen w-[244px] shrink-0 flex-col overflow-hidden border-r border-sidebar-border bg-sidebar text-sidebar-foreground",
|
|
1380
|
-
// As a drawer (≤1024px) it floats over the page and needs the lift.
|
|
1381
|
-
"max-[1024px]:shadow-2xl", className), children: [header ? jsxRuntime.jsx("div", { className: "shrink-0 px-[18px] pt-8 pb-6", children: header }) : null, jsxRuntime.jsxs(SidebarContent, { className: "flex-1 overflow-y-auto px-[18px]", children: [jsxRuntime.jsx(NavMain, { items: modulesWithActiveState, orgCode: orgCode, approvalCounts: approvalCounts, closeOnNavigation: closeOnNavigation, chrome: "workspace", highlightActiveModule: highlightActiveModule }), jsxRuntime.jsx(NavTools, { tools: tools, closeOnNavigation: closeOnNavigation })] }), bottom ? jsxRuntime.jsx("div", { className: "shrink-0 px-[18px] pt-6", children: bottom }) : null, jsxRuntime.jsx("div", { className: "shrink-0 px-[18px] pb-3", children: footer })] }));
|
|
1382
1273
|
}
|
|
1383
|
-
return (jsxRuntime.jsxs("div", { className: erpTheme.cn("bg-sidebar shrink-0 flex flex-col overflow-hidden shadow-2xl h-screen w-[var(--sidebar-width-mobile)]", "!w-76 xl:rounded-b-4xl xl:rounded-tl-4xl xl:m-5 xl:h-[calc(100vh-2.5rem)]", className), children: [jsxRuntime.jsx(SidebarHeader, { children: header }), jsxRuntime.jsxs(SidebarContent, { className: "pl-2 pr-2 flex-1 overflow-y-auto", children: [jsxRuntime.jsx(NavMain, { items: modulesWithActiveState, orgCode: orgCode, approvalCounts: approvalCounts, closeOnNavigation: closeOnNavigation }), jsxRuntime.jsx(NavTools, { tools: tools, closeOnNavigation: closeOnNavigation })] }), jsxRuntime.jsx(SidebarFooter, { className: "pl-2 pr-2", children: footer })] }));
|
|
1384
1274
|
}
|
|
1385
|
-
|
|
1386
1275
|
/**
|
|
1387
|
-
*
|
|
1388
|
-
*
|
|
1389
|
-
*
|
|
1390
|
-
*/
|
|
1391
|
-
const WORKSPACE_HEADER_VARS = {
|
|
1392
|
-
"--header-fg": "#fff",
|
|
1393
|
-
"--header-fg-muted": "rgb(255 255 255 / 0.78)",
|
|
1394
|
-
"--header-hover": "rgb(255 255 255 / 0.12)",
|
|
1395
|
-
"--header-title-px": "0px",
|
|
1396
|
-
"--header-title-py": "0px",
|
|
1397
|
-
};
|
|
1398
|
-
const WORKSPACE_LIGHT_HEADER_VARS = {
|
|
1399
|
-
"--header-fg": "var(--foreground)",
|
|
1400
|
-
"--header-fg-muted": "var(--muted-foreground)",
|
|
1401
|
-
"--header-hover": "var(--accent)",
|
|
1402
|
-
"--header-mark-bg": "var(--accent)",
|
|
1403
|
-
"--header-mark-fg": "var(--primary)",
|
|
1404
|
-
"--header-mark-border": "transparent",
|
|
1405
|
-
"--header-title-px": "0px",
|
|
1406
|
-
"--header-title-py": "0px",
|
|
1407
|
-
};
|
|
1408
|
-
|
|
1409
|
-
/**
|
|
1410
|
-
* The ช่วยเหลือ button in the top bar. Drawn in the header's ink (the
|
|
1411
|
-
* --header-* variables of the workspace chrome, white on the brand chrome).
|
|
1412
|
-
*/
|
|
1413
|
-
function HelpButton({ className }) {
|
|
1414
|
-
return (jsxRuntime.jsx("button", { type: "button", onClick: openHelp, "aria-label": "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D", title: "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D", className: "grid size-[34px] shrink-0 place-items-center rounded-lg text-[var(--header-fg-muted,#ffffffc7)] transition-colors hover:bg-[var(--header-hover,#ffffff1f)] hover:text-[var(--header-fg,#ffffff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--header-fg-muted,#ffffffc7)]" +
|
|
1415
|
-
(className ? ` ${className}` : ""), children: jsxRuntime.jsx(lucideReact.CircleHelp, { className: "size-5", strokeWidth: 1.65 }) }));
|
|
1416
|
-
}
|
|
1417
|
-
|
|
1418
|
-
/**
|
|
1419
|
-
* This package's version, attached to help messages so the back office can
|
|
1420
|
-
* tell which shell a report came from. Keep in step with package.json.
|
|
1421
|
-
*/
|
|
1422
|
-
const SHELL_VERSION = "0.1.52";
|
|
1423
|
-
|
|
1424
|
-
const HELP_APP_LABEL = {
|
|
1425
|
-
platform: "แพลตฟอร์ม",
|
|
1426
|
-
accounx: "Accounx",
|
|
1427
|
-
peoplx: "Peoplx",
|
|
1428
|
-
plannex: "Plannex",
|
|
1429
|
-
invenx: "Invenx",
|
|
1430
|
-
other: "อื่น ๆ",
|
|
1431
|
-
};
|
|
1432
|
-
function originOf(value) {
|
|
1433
|
-
if (!value)
|
|
1434
|
-
return null;
|
|
1435
|
-
try {
|
|
1436
|
-
return new URL(value).origin;
|
|
1437
|
-
}
|
|
1438
|
-
catch {
|
|
1439
|
-
return null;
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
/**
|
|
1443
|
-
* Which app this page is: what the host said, what the app declared to the
|
|
1444
|
-
* app switcher, then the production sub-domain, then the app URLs the build
|
|
1445
|
-
* was given (local development). "other" when none of them tells.
|
|
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.
|
|
1446
1279
|
*/
|
|
1447
1280
|
function detectHelpApp(explicit) {
|
|
1448
1281
|
if (explicit)
|
|
@@ -1601,270 +1434,632 @@ function describeAttached(app, ctx, email) {
|
|
|
1601
1434
|
.join(" · ");
|
|
1602
1435
|
}
|
|
1603
1436
|
|
|
1604
|
-
const
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
let
|
|
1608
|
-
|
|
1609
|
-
const
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
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
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
});
|
|
1656
|
-
Object.defineProperty(this, "field", {
|
|
1657
|
-
enumerable: true,
|
|
1658
|
-
configurable: true,
|
|
1659
|
-
writable: true,
|
|
1660
|
-
value: void 0
|
|
1661
|
-
});
|
|
1662
|
-
Object.defineProperty(this, "details", {
|
|
1663
|
-
enumerable: true,
|
|
1664
|
-
configurable: true,
|
|
1665
|
-
writable: true,
|
|
1666
|
-
value: void 0
|
|
1667
|
-
});
|
|
1668
|
-
this.name = "ApiError";
|
|
1669
|
-
this.status = options?.status;
|
|
1670
|
-
this.code = options?.code;
|
|
1671
|
-
this.field = options?.field;
|
|
1672
|
-
this.details = options?.details;
|
|
1673
|
-
}
|
|
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);
|
|
1674
1488
|
}
|
|
1675
|
-
const toApiError = (error) => {
|
|
1676
|
-
if (error instanceof ApiError)
|
|
1677
|
-
return error;
|
|
1678
|
-
if (axios.isAxiosError(error)) {
|
|
1679
|
-
const axiosErr = error;
|
|
1680
|
-
const message = axiosErr.response?.data?.message ||
|
|
1681
|
-
axiosErr.response?.data?.error ||
|
|
1682
|
-
axiosErr.message ||
|
|
1683
|
-
"Request failed";
|
|
1684
|
-
return new ApiError(message, {
|
|
1685
|
-
status: axiosErr.response?.status,
|
|
1686
|
-
code: axiosErr.response?.data?.code,
|
|
1687
|
-
field: axiosErr.response?.data?.field,
|
|
1688
|
-
details: axiosErr.response?.data?.details,
|
|
1689
|
-
});
|
|
1690
|
-
}
|
|
1691
|
-
if (error instanceof Error) {
|
|
1692
|
-
return new ApiError(error.message);
|
|
1693
|
-
}
|
|
1694
|
-
return new ApiError(String(error));
|
|
1695
|
-
};
|
|
1696
1489
|
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
}
|
|
1709
|
-
const
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
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-xs 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,
|
|
1719
1551
|
}
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
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 });
|
|
1723
1567
|
}
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1568
|
+
catch (error) {
|
|
1569
|
+
console.error("Sign out error:", error);
|
|
1570
|
+
window.location.href = signInRedirectHref;
|
|
1727
1571
|
}
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
const
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
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)
|
|
1758
1624
|
return null;
|
|
1759
|
-
const
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
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-xs font-semibold ${cardTall ? "pr-5" : ""}`, title: user.name, children: user.name }), myRoles ? (jsxRuntime.jsx(CardRoles, { my: myRoles, className: "text-xs 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-xs 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",
|
|
1770
1691
|
};
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
return
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
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;
|
|
1790
1713
|
}
|
|
1791
|
-
if (
|
|
1792
|
-
|
|
1714
|
+
if (ACCOUNTING_SUB_KEYS.includes(feature) &&
|
|
1715
|
+
features.includes(PLAN_FEATURES.MODULE_ACCOUNTING)) {
|
|
1716
|
+
return true;
|
|
1793
1717
|
}
|
|
1794
|
-
if (
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
config.headers.Authorization = `Bearer ${token}`;
|
|
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;
|
|
1799
1722
|
}
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
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
|
|
1801
|
+
function isPathActive(pathname, url) {
|
|
1802
|
+
// Exact match
|
|
1803
|
+
if (pathname === url) {
|
|
1804
|
+
return true;
|
|
1815
1805
|
}
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1806
|
+
// Check if pathname starts with url + "/" to ensure exact segment matching
|
|
1807
|
+
// This prevents /reports/sales from matching /sales
|
|
1808
|
+
// Example: /org123/reports/sales should NOT match /org123/sales
|
|
1809
|
+
// But /org123/sales/overview SHOULD match /org123/sales
|
|
1810
|
+
if (pathname.startsWith(url + "/")) {
|
|
1811
|
+
return true;
|
|
1819
1812
|
}
|
|
1820
|
-
return
|
|
1821
|
-
}
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1813
|
+
return false;
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
/** "/O1/" and "/O1" are the same page; a query string never is. */
|
|
1817
|
+
function samePage(pathname, url) {
|
|
1818
|
+
if (url.includes("?"))
|
|
1819
|
+
return false;
|
|
1820
|
+
const strip = (p) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
|
|
1821
|
+
return strip(pathname) === strip(url);
|
|
1822
|
+
}
|
|
1823
|
+
function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, chrome = "brand", highlightActiveModule = false, }) {
|
|
1824
|
+
const workspace = chrome === "workspace";
|
|
1825
|
+
const params = navigation.useParams();
|
|
1826
|
+
const pathname = navigation.usePathname();
|
|
1827
|
+
const { setOpen } = useSidebar();
|
|
1828
|
+
const handleNavLinkClick = () => {
|
|
1829
|
+
if (closeOnNavigation)
|
|
1830
|
+
setOpen(false);
|
|
1831
|
+
};
|
|
1832
|
+
const code = orgCode ?? params?.code;
|
|
1833
|
+
// Which module (and sub-item) the current page belongs to.
|
|
1834
|
+
const states = items.map(item => {
|
|
1835
|
+
const itemUrl = buildUrlWithCode(code, item.url);
|
|
1836
|
+
// Most specific (longest) matching sub-item URL so only one link is active.
|
|
1837
|
+
const activeSubItemUrl = item.subModules?.reduce((best, subItem) => {
|
|
1838
|
+
const subItemUrl = buildUrlWithCode(code, subItem.url);
|
|
1839
|
+
const matches = isPathActive(pathname, subItemUrl) ||
|
|
1840
|
+
isPathActive(pathname, subItem.url);
|
|
1841
|
+
const subPageMatches = subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
|
|
1842
|
+
isPathActive(pathname, subPage.url));
|
|
1843
|
+
if (!matches && !subPageMatches)
|
|
1844
|
+
return best;
|
|
1845
|
+
const candidateUrl = subItemUrl;
|
|
1846
|
+
if (!best)
|
|
1847
|
+
return candidateUrl;
|
|
1848
|
+
return candidateUrl.length > best.length ? candidateUrl : best;
|
|
1849
|
+
}, null) ?? null;
|
|
1850
|
+
const hasActiveSubItem = !!activeSubItemUrl ||
|
|
1851
|
+
!!item.subModules?.some(subItem => subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
|
|
1852
|
+
isPathActive(pathname, subPage.url)));
|
|
1853
|
+
// Parent only active when path matches its URL exactly AND no child
|
|
1854
|
+
// is active (so parent+child don't both light up).
|
|
1855
|
+
const isItemActive = (highlightActiveModule && !item.subModules?.length && !!item.isActive) ||
|
|
1856
|
+
((workspace ? samePage(pathname, itemUrl) : pathname === itemUrl) &&
|
|
1857
|
+
!hasActiveSubItem);
|
|
1858
|
+
return { itemUrl, activeSubItemUrl, hasActiveSubItem, isItemActive };
|
|
1859
|
+
});
|
|
1860
|
+
// A group opens when its page becomes current and otherwise stays as the
|
|
1861
|
+
// user left it. defaultOpen alone decided once, on the first render — and
|
|
1862
|
+
// on a direct load the sub-items arrive after permissions do, so the group
|
|
1863
|
+
// holding the current page stayed shut.
|
|
1864
|
+
const activeKey = items
|
|
1865
|
+
.filter((_, i) => states[i].isItemActive || states[i].hasActiveSubItem)
|
|
1866
|
+
.map(item => item.id)
|
|
1867
|
+
.join(",");
|
|
1868
|
+
const [openIds, setOpenIds] = React__namespace.useState(() => new Set(activeKey ? activeKey.split(",") : []));
|
|
1869
|
+
React__namespace.useEffect(() => {
|
|
1870
|
+
if (!activeKey)
|
|
1871
|
+
return;
|
|
1872
|
+
setOpenIds(prev => {
|
|
1873
|
+
const ids = activeKey.split(",");
|
|
1874
|
+
if (ids.every(id => prev.has(id)))
|
|
1875
|
+
return prev;
|
|
1876
|
+
const next = new Set(prev);
|
|
1877
|
+
ids.forEach(id => next.add(id));
|
|
1878
|
+
return next;
|
|
1827
1879
|
});
|
|
1880
|
+
}, [activeKey]);
|
|
1881
|
+
const setItemOpen = (id, open) => setOpenIds(prev => {
|
|
1882
|
+
if (prev.has(id) === open)
|
|
1883
|
+
return prev;
|
|
1884
|
+
const next = new Set(prev);
|
|
1885
|
+
if (open)
|
|
1886
|
+
next.add(id);
|
|
1887
|
+
else
|
|
1888
|
+
next.delete(id);
|
|
1889
|
+
return next;
|
|
1890
|
+
});
|
|
1891
|
+
return (jsxRuntime.jsx(SidebarGroup, { "data-sidebar": true, className: workspace ? "p-0" : undefined, children: jsxRuntime.jsx(SidebarMenu, { className: workspace ? "gap-[3px]" : undefined, children: items.map((item, index) => {
|
|
1892
|
+
const groupHeading = workspace &&
|
|
1893
|
+
item.group &&
|
|
1894
|
+
item.group !== items[index - 1]?.group ? (jsxRuntime.jsx("li", { className: "list-none px-3 pb-2.5 text-xs text-muted-foreground" +
|
|
1895
|
+
(index > 0 ? " pt-5" : ""), children: item.group }, `group-${item.id}`)) : null;
|
|
1896
|
+
const { itemUrl, activeSubItemUrl, isItemActive } = states[index];
|
|
1897
|
+
const IconComp = item.icon;
|
|
1898
|
+
const workspaceButton = workspace
|
|
1899
|
+
? "h-auto gap-3 rounded-[10px] px-3 py-2.5 text-xs " +
|
|
1900
|
+
(isItemActive
|
|
1901
|
+
? "bg-sidebar-accent font-bold text-sidebar-accent-foreground"
|
|
1902
|
+
: "font-medium text-sidebar-foreground/70")
|
|
1903
|
+
: "";
|
|
1904
|
+
const icon = workspace && IconComp ? (jsxRuntime.jsx(IconComp, { className: "size-[18px] shrink-0" })) : null;
|
|
1905
|
+
const badge = workspace && item.badge !== undefined && item.badge !== "" ? (jsxRuntime.jsx("span", { className: "ml-auto min-w-5 rounded-[5px] bg-primary/15 px-1 text-center text-xs leading-5 font-bold tabular-nums text-primary", children: item.badge })) : null;
|
|
1906
|
+
return (jsxRuntime.jsxs(React__namespace.Fragment, { children: [groupHeading, jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: open => setItemOpen(item.id, open), className: "group/collapsible", children: jsxRuntime.jsxs(SidebarMenuItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsx(SidebarMenuButton, { asChild: !item.subModules || item.subModules.length === 0, isActive: isItemActive, className: "data-[state=open]:text-sidebar-accent-foreground " +
|
|
1907
|
+
workspaceButton, children: item.subModules && item.subModules.length > 0 ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [icon, jsxRuntime.jsx("span", { id: item.id, children: item.titleTh }), jsxRuntime.jsx(lucideReact.ChevronRight, { className: "ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" })] })) : (jsxRuntime.jsxs(Link, { href: itemUrl, prefetch: false, className: "flex items-center w-full min-w-0 flex-1 " +
|
|
1908
|
+
(workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, children: [icon, jsxRuntime.jsx("span", { id: item.id, className: "flex-1 truncate", title: item.titleTh, children: item.titleTh }), badge] })) }) }), item.subModules && item.subModules.length > 0 && (jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: workspace
|
|
1909
|
+
? "mx-0 mt-1 mb-3 ml-[21px] gap-0.5 border-l border-sidebar-border pl-2.5 [&_a]:h-auto [&_a]:gap-[11px] [&_a]:px-3 [&_a]:py-2 [&_a]:text-xs [&_svg]:!size-[17px]"
|
|
1910
|
+
: undefined, children: item.subModules.map(subItem => {
|
|
1911
|
+
const subItemUrl = buildUrlWithCode(code, subItem.url);
|
|
1912
|
+
const isSubItemActive = subItemUrl === activeSubItemUrl;
|
|
1913
|
+
const SubIcon = subItem.icon;
|
|
1914
|
+
const approvalCount = approvalCounts && subItem.approvalCountKey
|
|
1915
|
+
? (approvalCounts[subItem.approvalCountKey] ?? 0)
|
|
1916
|
+
: 0;
|
|
1917
|
+
if (subItem.isCollapsible &&
|
|
1918
|
+
subItem.subPages &&
|
|
1919
|
+
subItem.subPages.length > 0) {
|
|
1920
|
+
const hasActiveSubPage = subItem.subPages.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)));
|
|
1921
|
+
return (jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, defaultOpen: isSubItemActive || hasActiveSubPage, className: "group/sub-collapsible", children: jsxRuntime.jsxs(SidebarMenuSubItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsxs(SidebarMenuSubButton, { className: "w-full", isActive: isSubItemActive || hasActiveSubPage, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), jsxRuntime.jsx(lucideReact.ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map(subPage => {
|
|
1922
|
+
const subPageUrl = buildUrlWithCode(code, subPage.url);
|
|
1923
|
+
const isSubPageActive = isPathActive(pathname, subPageUrl);
|
|
1924
|
+
const SubPageIcon = subPage.icon;
|
|
1925
|
+
return (jsxRuntime.jsx(SidebarMenuSubItem, { children: jsxRuntime.jsx(SidebarMenuSubButton, { asChild: true, className: `font-thai w-full text-sm ${isSubPageActive
|
|
1926
|
+
? "text-sidebar-accent-foreground font-medium"
|
|
1927
|
+
: "text-sidebar-foreground"}`, isActive: false, children: jsxRuntime.jsxs(Link, { href: subPageUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubPageIcon && (jsxRuntime.jsx(SubPageIcon, { className: "sidebar-subpage-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", title: subPage.titleTh, children: subPage.titleTh })] }) }) }, subPage.id));
|
|
1928
|
+
}) }) })] }) }, subItem.id));
|
|
1929
|
+
}
|
|
1930
|
+
return (jsxRuntime.jsx(SidebarMenuSubItem, { children: jsxRuntime.jsx(SidebarMenuSubButton, { asChild: true, className: "w-full" +
|
|
1931
|
+
(workspace && isSubItemActive
|
|
1932
|
+
? " font-semibold"
|
|
1933
|
+
: ""), isActive: isSubItemActive, children: jsxRuntime.jsxs(Link, { href: subItemUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), approvalCount > 0 && (jsxRuntime.jsx(erpTheme.Badge, { variant: "destructive", className: "ml-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums", children: approvalCount > 99
|
|
1934
|
+
? "99+"
|
|
1935
|
+
: approvalCount }))] }) }) }, subItem.id));
|
|
1936
|
+
}) }) }))] }) })] }, item.id));
|
|
1937
|
+
}) }) }));
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
function OrgMark({ logoUrl, size = "md", }) {
|
|
1941
|
+
const [failed, setFailed] = React.useState(false);
|
|
1942
|
+
const box = size === "sm"
|
|
1943
|
+
? "flex size-6 items-center justify-center rounded-md border overflow-hidden bg-background"
|
|
1944
|
+
: size === "header"
|
|
1945
|
+
? "flex aspect-square size-8 shrink-0 items-center justify-center rounded-[9px] border border-[color:var(--header-mark-border,color-mix(in_oklab,var(--header-fg,currentColor)_28%,transparent))] bg-[var(--header-mark-bg,color-mix(in_oklab,var(--header-fg,currentColor)_10%,transparent))] text-[var(--header-mark-fg,var(--header-fg,currentColor))] overflow-hidden max-sm:size-7"
|
|
1946
|
+
: "flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground overflow-hidden";
|
|
1947
|
+
const icon = size === "sm" ? "size-3.5 shrink-0" : "size-4";
|
|
1948
|
+
if (logoUrl && !failed) {
|
|
1949
|
+
return (jsxRuntime.jsx("div", { className: erpTheme.cn(box, size === "md" && "bg-white"), children: jsxRuntime.jsx("img", { src: logoUrl, alt: "", className: "size-full object-contain p-0.5", onError: () => setFailed(true) }) }));
|
|
1828
1950
|
}
|
|
1829
|
-
return
|
|
1830
|
-
}
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
const
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1951
|
+
return (jsxRuntime.jsx("div", { className: box, children: jsxRuntime.jsx(lucideReact.Building2, { className: icon }) }));
|
|
1952
|
+
}
|
|
1953
|
+
function OrganizationSwitcher({ activeOrganization, isContextLoading = false, organizations, isLoadingOrgs = false, setOrganizationCookie, registerOrganizationHref = "/register-organization", placement = "sidebar", className, }) {
|
|
1954
|
+
const { isMobile } = useSidebarToggle();
|
|
1955
|
+
const router = navigation.useRouter();
|
|
1956
|
+
const params = navigation.useParams();
|
|
1957
|
+
const [isPending, startTransition] = React.useTransition();
|
|
1958
|
+
const currentCode = params?.code
|
|
1959
|
+
? Array.isArray(params.code)
|
|
1960
|
+
? params.code[0]
|
|
1961
|
+
: params.code
|
|
1962
|
+
: undefined;
|
|
1963
|
+
const handleOrganizationChange = (org) => {
|
|
1964
|
+
startTransition(async () => {
|
|
1965
|
+
const orgCode = org.code || String(org.id);
|
|
1966
|
+
// Write storage + cookie before navigation — Layout will re-hydrate
|
|
1967
|
+
// Context on the new URL.
|
|
1968
|
+
try {
|
|
1969
|
+
setOrganizationId(String(org.id));
|
|
1970
|
+
await setOrganizationCookie(org.id);
|
|
1971
|
+
}
|
|
1972
|
+
catch {
|
|
1973
|
+
// ignore: navigation will re-attempt context resolution
|
|
1974
|
+
}
|
|
1975
|
+
if (currentCode) {
|
|
1976
|
+
const currentPath = window.location.pathname;
|
|
1977
|
+
const parts = currentPath.split("/");
|
|
1978
|
+
const decodedParts = parts.map(p => decodeURIComponent(p));
|
|
1979
|
+
const codeIndex = decodedParts.indexOf(currentCode);
|
|
1980
|
+
if (codeIndex !== -1) {
|
|
1981
|
+
parts[codeIndex] = orgCode;
|
|
1982
|
+
router.push(parts.join("/"));
|
|
1983
|
+
}
|
|
1984
|
+
else {
|
|
1985
|
+
router.push(`/${orgCode}`);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
else {
|
|
1989
|
+
router.push(`/${orgCode}`);
|
|
1990
|
+
}
|
|
1991
|
+
});
|
|
1992
|
+
};
|
|
1993
|
+
const isLoading = isLoadingOrgs || isContextLoading;
|
|
1994
|
+
if (placement === "header") {
|
|
1995
|
+
const face = (interactive) => (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(OrgMark, { logoUrl: activeOrganization?.logoUrl, size: "header" }), jsxRuntime.jsxs("span", { className: "min-w-0 text-left", children: [jsxRuntime.jsx("small", { className: "block text-xs leading-tight text-[var(--header-fg-muted,currentColor)] max-sm:text-xs", children: "\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23\u0E17\u0E35\u0E48\u0E01\u0E33\u0E25\u0E31\u0E07\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19" }), isLoading ? (jsxRuntime.jsx(erpTheme.Skeleton, { className: "mt-1 h-3.5 w-28" })) : (jsxRuntime.jsx("strong", { className: "block max-w-[220px] truncate text-xs font-semibold text-[var(--header-fg,currentColor)] max-sm:max-w-[140px] max-sm:text-xs", children: activeOrganization?.name || "เลือกองค์กร" }))] }), interactive ? (isPending ? (jsxRuntime.jsx(lucideReact.Loader2, { className: "ml-1 size-3.5 shrink-0 animate-spin text-[var(--header-fg-muted,currentColor)]" })) : (jsxRuntime.jsx(lucideReact.ChevronsUpDown, { className: "ml-1 size-3.5 shrink-0 text-[var(--header-fg-muted,currentColor)]" }))) : null] }));
|
|
1996
|
+
const faceClass = erpTheme.cn("flex items-center gap-2.5 rounded-lg py-1.5 max-sm:gap-2", className);
|
|
1997
|
+
if (isLoading || organizations.length <= 1) {
|
|
1998
|
+
return jsxRuntime.jsx("div", { className: faceClass, children: face(false) });
|
|
1853
1999
|
}
|
|
1854
|
-
|
|
1855
|
-
return Promise.reject(toApiError(error));
|
|
2000
|
+
return (jsxRuntime.jsxs(erpTheme.DropdownMenu, { children: [jsxRuntime.jsx(erpTheme.DropdownMenuTrigger, { className: erpTheme.cn(faceClass, "px-1.5 outline-none transition-colors hover:bg-[var(--header-hover,var(--accent))] focus-visible:ring-2 focus-visible:ring-[var(--header-fg-muted,var(--ring))] data-[state=open]:bg-[var(--header-hover,var(--accent))]"), "aria-label": "\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23", children: face(true) }), jsxRuntime.jsxs(erpTheme.DropdownMenuContent, { className: "min-w-64 rounded-lg", align: "end", side: "bottom", sideOffset: 6, children: [jsxRuntime.jsx(erpTheme.DropdownMenuLabel, { className: "text-xs text-muted-foreground", children: "\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" }), organizations.map(org => (jsxRuntime.jsxs(erpTheme.DropdownMenuItem, { onClick: () => handleOrganizationChange(org), disabled: isPending, className: erpTheme.cn("gap-2 p-2 cursor-pointer", String(activeOrganization?.id) === String(org.id) && "bg-accent"), children: [jsxRuntime.jsx(OrgMark, { logoUrl: org.logoUrl, size: "sm" }), jsxRuntime.jsx("span", { className: "truncate", children: org.name })] }, org.id))), jsxRuntime.jsx(erpTheme.DropdownMenuSeparator, {}), jsxRuntime.jsxs(erpTheme.DropdownMenuItem, { className: "gap-2 p-2 cursor-pointer", onClick: () => router.push(registerOrganizationHref), children: [jsxRuntime.jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-transparent", children: jsxRuntime.jsx(lucideReact.Plus, { className: "size-4" }) }), jsxRuntime.jsx("span", { className: "text-muted-foreground font-medium", children: "\u0E40\u0E1E\u0E34\u0E48\u0E21\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" })] })] })] }));
|
|
1856
2001
|
}
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
if (
|
|
1861
|
-
|
|
1862
|
-
if (body?.code === "EMPLOYEE_RECORD_REQUIRED" && typeof window !== "undefined") {
|
|
1863
|
-
window.dispatchEvent(new CustomEvent("erp:employee-record-required"));
|
|
1864
|
-
}
|
|
2002
|
+
if (isLoading) {
|
|
2003
|
+
return (jsxRuntime.jsx(SidebarMenu, { className: className, children: jsxRuntime.jsx(SidebarMenuItem, { children: jsxRuntime.jsxs("div", { className: "flex items-center gap-2 p-2", children: [jsxRuntime.jsx(erpTheme.Skeleton, { className: "size-8 rounded-lg" }), jsxRuntime.jsx(erpTheme.Skeleton, { className: "h-4 w-24" })] }) }) }));
|
|
2004
|
+
}
|
|
2005
|
+
if (organizations.length <= 1) {
|
|
2006
|
+
return (jsxRuntime.jsx(SidebarMenu, { className: className, children: jsxRuntime.jsx(SidebarMenuItem, { children: jsxRuntime.jsxs("div", { className: "flex items-center gap-2 p-2", children: [jsxRuntime.jsx(OrgMark, { logoUrl: activeOrganization?.logoUrl }), jsxRuntime.jsx("span", { className: "truncate font-semibold text-sm min-w-0 flex-1", children: activeOrganization?.name || "ไม่มีองค์กร" })] }) }) }));
|
|
1865
2007
|
}
|
|
1866
|
-
return
|
|
1867
|
-
});
|
|
2008
|
+
return (jsxRuntime.jsx(SidebarMenu, { className: className, children: jsxRuntime.jsx(SidebarMenuItem, { children: jsxRuntime.jsxs(erpTheme.DropdownMenu, { children: [jsxRuntime.jsx(erpTheme.DropdownMenuTrigger, { asChild: true, children: jsxRuntime.jsxs(SidebarMenuButton, { size: "lg", className: "data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground", children: [jsxRuntime.jsx(OrgMark, { logoUrl: activeOrganization?.logoUrl }), jsxRuntime.jsx("span", { className: "truncate font-semibold text-sm flex-1 min-w-0 text-left", children: activeOrganization?.name || "เลือกองค์กร" }), isPending ? (jsxRuntime.jsx(lucideReact.Loader2, { className: "ml-auto size-4 animate-spin" })) : (jsxRuntime.jsx(lucideReact.ChevronsUpDown, { className: "ml-auto size-4" }))] }) }), jsxRuntime.jsxs(erpTheme.DropdownMenuContent, { className: "w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg", align: "start", side: isMobile ? "bottom" : "right", sideOffset: 4, children: [jsxRuntime.jsx(erpTheme.DropdownMenuLabel, { className: "text-xs text-muted-foreground", children: "\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" }), organizations.map(org => (jsxRuntime.jsxs(erpTheme.DropdownMenuItem, { onClick: () => handleOrganizationChange(org), disabled: isPending, className: erpTheme.cn("gap-2 p-2 cursor-pointer", String(activeOrganization?.id) === String(org.id) &&
|
|
2009
|
+
"bg-accent"), children: [jsxRuntime.jsx(OrgMark, { logoUrl: org.logoUrl, size: "sm" }), jsxRuntime.jsx("span", { className: "truncate", children: org.name })] }, org.id))), jsxRuntime.jsx(erpTheme.DropdownMenuSeparator, {}), jsxRuntime.jsxs(erpTheme.DropdownMenuItem, { className: "gap-2 p-2 cursor-pointer", onClick: () => router.push(registerOrganizationHref), children: [jsxRuntime.jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-transparent", children: jsxRuntime.jsx(lucideReact.Plus, { className: "size-4" }) }), jsxRuntime.jsx("span", { className: "text-muted-foreground font-medium", children: "\u0E40\u0E1E\u0E34\u0E48\u0E21\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" })] })] })] }) }) }));
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
function SidebarHeader({ children, className }) {
|
|
2013
|
+
return jsxRuntime.jsx("div", { className: erpTheme.cn("px-2 py-3", className), children: children });
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
function AppSidebar({ modules, tools, activeModule, orgCode, approvalCounts, closeOnNavigation = false, highlightActiveModule = false, header, footer, bottom, chrome = "brand", className, }) {
|
|
2017
|
+
const { open } = useSidebar();
|
|
2018
|
+
const modulesWithActiveState = React__namespace.useMemo(() => modules.map(m => ({
|
|
2019
|
+
...m,
|
|
2020
|
+
isActive: m.id === activeModule,
|
|
2021
|
+
})), [modules, activeModule]);
|
|
2022
|
+
if (!open)
|
|
2023
|
+
return null;
|
|
2024
|
+
if (chrome === "workspace") {
|
|
2025
|
+
return (jsxRuntime.jsxs("div", { className: erpTheme.cn("flex h-screen w-[244px] shrink-0 flex-col overflow-hidden border-r border-sidebar-border bg-sidebar text-sidebar-foreground",
|
|
2026
|
+
// As a drawer (≤1024px) it floats over the page and needs the lift.
|
|
2027
|
+
"max-[1024px]:shadow-2xl", className), children: [header ? jsxRuntime.jsx("div", { className: "shrink-0 px-[18px] pt-8 pb-6", children: header }) : null, jsxRuntime.jsxs(SidebarContent, { className: "flex-1 overflow-y-auto px-[18px]", children: [jsxRuntime.jsx(NavMain, { items: modulesWithActiveState, orgCode: orgCode, approvalCounts: approvalCounts, closeOnNavigation: closeOnNavigation, chrome: "workspace", highlightActiveModule: highlightActiveModule }), jsxRuntime.jsx(NavTools, { tools: tools, closeOnNavigation: closeOnNavigation })] }), bottom ? jsxRuntime.jsx("div", { className: "shrink-0 px-[18px] pt-6", children: bottom }) : null, jsxRuntime.jsx("div", { className: "shrink-0 px-[18px] pb-3", children: footer })] }));
|
|
2028
|
+
}
|
|
2029
|
+
return (jsxRuntime.jsxs("div", { className: erpTheme.cn("bg-sidebar shrink-0 flex flex-col overflow-hidden shadow-2xl h-screen w-[var(--sidebar-width-mobile)]", "!w-76 xl:rounded-b-4xl xl:rounded-tl-4xl xl:m-5 xl:h-[calc(100vh-2.5rem)]", className), children: [jsxRuntime.jsx(SidebarHeader, { children: header }), jsxRuntime.jsxs(SidebarContent, { className: "pl-2 pr-2 flex-1 overflow-y-auto", children: [jsxRuntime.jsx(NavMain, { items: modulesWithActiveState, orgCode: orgCode, approvalCounts: approvalCounts, closeOnNavigation: closeOnNavigation }), jsxRuntime.jsx(NavTools, { tools: tools, closeOnNavigation: closeOnNavigation })] }), jsxRuntime.jsx(SidebarFooter, { className: "pl-2 pr-2", children: footer })] }));
|
|
2030
|
+
}
|
|
2031
|
+
|
|
2032
|
+
/**
|
|
2033
|
+
* Header ink as CSS variables. Header-borne pieces — breadcrumb, sidebar
|
|
2034
|
+
* trigger, the org switcher, the app's own page title — read these, with
|
|
2035
|
+
* white as the fallback, so the brand chrome looks exactly as before.
|
|
2036
|
+
*/
|
|
2037
|
+
const WORKSPACE_HEADER_VARS = {
|
|
2038
|
+
"--header-fg": "#fff",
|
|
2039
|
+
"--header-fg-muted": "rgb(255 255 255 / 0.78)",
|
|
2040
|
+
"--header-hover": "rgb(255 255 255 / 0.12)",
|
|
2041
|
+
"--header-title-px": "0px",
|
|
2042
|
+
"--header-title-py": "0px",
|
|
2043
|
+
};
|
|
2044
|
+
const WORKSPACE_LIGHT_HEADER_VARS = {
|
|
2045
|
+
"--header-fg": "var(--foreground)",
|
|
2046
|
+
"--header-fg-muted": "var(--muted-foreground)",
|
|
2047
|
+
"--header-hover": "var(--accent)",
|
|
2048
|
+
"--header-mark-bg": "var(--accent)",
|
|
2049
|
+
"--header-mark-fg": "var(--primary)",
|
|
2050
|
+
"--header-mark-border": "transparent",
|
|
2051
|
+
"--header-title-px": "0px",
|
|
2052
|
+
"--header-title-py": "0px",
|
|
2053
|
+
};
|
|
2054
|
+
|
|
2055
|
+
/**
|
|
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
|
+
}
|
|
1868
2063
|
|
|
1869
2064
|
const hasPaginationShape = (payload) => {
|
|
1870
2065
|
return ("total" in payload ||
|
|
@@ -1892,46 +2087,358 @@ const unwrapApiResponse = (payload) => {
|
|
|
1892
2087
|
};
|
|
1893
2088
|
|
|
1894
2089
|
/**
|
|
1895
|
-
* The help
|
|
1896
|
-
*
|
|
1897
|
-
*
|
|
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.
|
|
2092
|
+
*
|
|
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.
|
|
2099
|
+
*/
|
|
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);
|
|
2152
|
+
}
|
|
2153
|
+
}
|
|
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 });
|
|
2289
|
+
}
|
|
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 });
|
|
2329
|
+
}
|
|
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
|
+
};
|
|
2370
|
+
}
|
|
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();
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
/**
|
|
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
|
|
1898
2393
|
* refreshes a stale token.
|
|
1899
2394
|
*/
|
|
1900
|
-
const DEFAULT_PLATFORM_API_URL = "https://platform-api.antniti.com/api/v1";
|
|
1901
2395
|
function helpApiBase(app) {
|
|
1902
|
-
|
|
1903
|
-
if (explicit)
|
|
1904
|
-
return explicit.replace(/\/$/, "");
|
|
1905
|
-
// Platform's own API is platform-api.
|
|
1906
|
-
if (app === "platform") {
|
|
1907
|
-
const own = process.env.NEXT_PUBLIC_API_URL?.trim();
|
|
1908
|
-
if (own)
|
|
1909
|
-
return own.replace(/\/$/, "");
|
|
1910
|
-
}
|
|
1911
|
-
return DEFAULT_PLATFORM_API_URL;
|
|
2396
|
+
return platformApiBase(app);
|
|
1912
2397
|
}
|
|
1913
|
-
/**
|
|
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. */
|
|
1914
2405
|
class HelpSendError extends Error {
|
|
1915
|
-
constructor(message, status) {
|
|
2406
|
+
constructor(message, status, attachmentId) {
|
|
1916
2407
|
super(message);
|
|
1917
2408
|
Object.defineProperty(this, "status", {
|
|
1918
2409
|
enumerable: true,
|
|
1919
2410
|
configurable: true,
|
|
1920
2411
|
writable: true,
|
|
1921
|
-
value:
|
|
2412
|
+
value: void 0
|
|
2413
|
+
});
|
|
2414
|
+
Object.defineProperty(this, "attachmentId", {
|
|
2415
|
+
enumerable: true,
|
|
2416
|
+
configurable: true,
|
|
2417
|
+
writable: true,
|
|
2418
|
+
value: void 0
|
|
1922
2419
|
});
|
|
1923
2420
|
this.name = "HelpSendError";
|
|
2421
|
+
this.status = status;
|
|
2422
|
+
this.attachmentId = attachmentId;
|
|
1924
2423
|
}
|
|
1925
2424
|
}
|
|
2425
|
+
function attachmentIdOf(err) {
|
|
2426
|
+
const data = err?.response?.data?.data;
|
|
2427
|
+
return typeof data?.attachment_id === "string" ? data.attachment_id : undefined;
|
|
2428
|
+
}
|
|
1926
2429
|
function friendly(err, fallback) {
|
|
1927
2430
|
const e = toApiError(err);
|
|
2431
|
+
const attachmentId = attachmentIdOf(err);
|
|
1928
2432
|
if (e.status === 429) {
|
|
1929
|
-
return new HelpSendError("ส่งบ่อยเกินไป กรุณาลองใหม่ภายหลัง", 429);
|
|
2433
|
+
return new HelpSendError(e.message && e.message !== "Request failed with status code 429" ? e.message : "ส่งบ่อยเกินไป กรุณาลองใหม่ภายหลัง", 429);
|
|
1930
2434
|
}
|
|
1931
|
-
if (e.status && e.status >= 400 && e.status < 500 && e.message && e.status !== 401 && e.status !== 404) {
|
|
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) {
|
|
1932
2439
|
return new HelpSendError(e.message, e.status);
|
|
1933
2440
|
}
|
|
1934
|
-
return new HelpSendError(fallback, e.status);
|
|
2441
|
+
return new HelpSendError(fallback, e.status, attachmentId);
|
|
1935
2442
|
}
|
|
1936
2443
|
async function sendHelpMessage(input) {
|
|
1937
2444
|
try {
|
|
@@ -1940,6 +2447,7 @@ async function sendHelpMessage(input) {
|
|
|
1940
2447
|
message: input.message,
|
|
1941
2448
|
app: input.app === "other" ? "" : input.app,
|
|
1942
2449
|
context: input.context,
|
|
2450
|
+
attachment_ids: input.attachmentIds ?? [],
|
|
1943
2451
|
});
|
|
1944
2452
|
return unwrapApiResponse(res.data);
|
|
1945
2453
|
}
|
|
@@ -1947,6 +2455,83 @@ async function sendHelpMessage(input) {
|
|
|
1947
2455
|
throw friendly(err, "ส่งไม่สำเร็จ");
|
|
1948
2456
|
}
|
|
1949
2457
|
}
|
|
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
|
+
}
|
|
2469
|
+
}
|
|
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);
|
|
2490
|
+
};
|
|
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
|
+
});
|
|
2501
|
+
}
|
|
2502
|
+
/**
|
|
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.
|
|
2506
|
+
*/
|
|
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
|
+
}
|
|
1950
2535
|
async function listMyHelpMessages(app) {
|
|
1951
2536
|
try {
|
|
1952
2537
|
const res = await instance.get(`${helpApiBase(app)}/support/messages`, {
|
|
@@ -1960,6 +2545,167 @@ async function listMyHelpMessages(app) {
|
|
|
1960
2545
|
}
|
|
1961
2546
|
}
|
|
1962
2547
|
|
|
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
|
+
}
|
|
2574
|
+
/**
|
|
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.
|
|
2577
|
+
*/
|
|
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`;
|
|
2629
|
+
}
|
|
2630
|
+
/**
|
|
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.
|
|
2634
|
+
*/
|
|
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-xs" : "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-xs 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);
|
|
2684
|
+
}
|
|
2685
|
+
catch {
|
|
2686
|
+
return null;
|
|
2687
|
+
}
|
|
2688
|
+
const raw = (params.get(HELP_LINK_PARAM) ?? "").trim().toUpperCase();
|
|
2689
|
+
return REF.test(raw) ? raw : null;
|
|
2690
|
+
}
|
|
2691
|
+
/**
|
|
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.
|
|
2694
|
+
*/
|
|
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();
|
|
2707
|
+
}
|
|
2708
|
+
|
|
1963
2709
|
const KINDS = [
|
|
1964
2710
|
{ value: "help", label: "ขอความช่วยเหลือ", placeholder: "ต้องการให้ช่วยเรื่องอะไร" },
|
|
1965
2711
|
{ value: "problem", label: "แจ้งปัญหา", placeholder: "เกิดอะไรขึ้น และกำลังทำอะไรอยู่" },
|
|
@@ -1977,6 +2723,7 @@ const STATUS_TONE = {
|
|
|
1977
2723
|
closed: "bg-muted text-muted-foreground",
|
|
1978
2724
|
};
|
|
1979
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";
|
|
1980
2727
|
function when(iso) {
|
|
1981
2728
|
const d = new Date(iso);
|
|
1982
2729
|
if (Number.isNaN(d.getTime()))
|
|
@@ -1990,10 +2737,34 @@ function when(iso) {
|
|
|
1990
2737
|
hour12: false,
|
|
1991
2738
|
});
|
|
1992
2739
|
}
|
|
2740
|
+
function errorText(e, fallback) {
|
|
2741
|
+
return e instanceof Error && e.message ? e.message : fallback;
|
|
2742
|
+
}
|
|
2743
|
+
/**
|
|
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.
|
|
2746
|
+
*/
|
|
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;
|
|
2755
|
+
}
|
|
2756
|
+
catch (err) {
|
|
2757
|
+
if (err instanceof HelpSendError && err.attachmentId)
|
|
2758
|
+
state.attach.refuse(err.attachmentId, err.message);
|
|
2759
|
+
throw err;
|
|
2760
|
+
}
|
|
2761
|
+
}
|
|
1993
2762
|
/**
|
|
1994
2763
|
* The ช่วยเหลือ dialog. AppLayoutShell mounts one; the top-bar button and
|
|
1995
|
-
* the user menu open it with openHelp()
|
|
1996
|
-
*
|
|
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.
|
|
1997
2768
|
*/
|
|
1998
2769
|
function HelpDialogHost({ app, orgCode }) {
|
|
1999
2770
|
const open = useHelpOpen();
|
|
@@ -2006,24 +2777,60 @@ function HelpDialogHost({ app, orgCode }) {
|
|
|
2006
2777
|
const [sent, setSent] = React__namespace.useState(null);
|
|
2007
2778
|
const [mine, setMine] = React__namespace.useState(null);
|
|
2008
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);
|
|
2009
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
|
+
}, []);
|
|
2010
2798
|
// Read when the dialog opens: the page and the window as they are now.
|
|
2011
2799
|
// open is a dependency on purpose: the app is read again each time.
|
|
2012
2800
|
const appId = React__namespace.useMemo(() => detectHelpApp(app), [app, open]);
|
|
2013
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]);
|
|
2014
2816
|
React__namespace.useEffect(() => {
|
|
2015
|
-
if (open)
|
|
2817
|
+
if (!open)
|
|
2818
|
+
return;
|
|
2819
|
+
setError(null);
|
|
2820
|
+
const key = pendingHelpRef();
|
|
2821
|
+
if (key)
|
|
2822
|
+
openThread(key);
|
|
2823
|
+
else
|
|
2016
2824
|
setView("form");
|
|
2017
|
-
|
|
2018
|
-
}
|
|
2019
|
-
}, [open]);
|
|
2825
|
+
}, [open, openThread]);
|
|
2020
2826
|
const loadMine = React__namespace.useCallback(() => {
|
|
2827
|
+
clearPendingHelpRef();
|
|
2021
2828
|
setView("mine");
|
|
2022
2829
|
setMine(null);
|
|
2023
2830
|
setMineError(null);
|
|
2024
2831
|
listMyHelpMessages(appId)
|
|
2025
2832
|
.then(setMine)
|
|
2026
|
-
.catch((e) => setMineError(e
|
|
2833
|
+
.catch((e) => setMineError(errorText(e, "โหลดข้อมูลไม่สำเร็จ")));
|
|
2027
2834
|
}, [appId]);
|
|
2028
2835
|
const trimmed = message.trim();
|
|
2029
2836
|
const canSend = trimmed.length > 0 && !sending;
|
|
@@ -2034,25 +2841,61 @@ function HelpDialogHost({ app, orgCode }) {
|
|
|
2034
2841
|
setSending(true);
|
|
2035
2842
|
setError(null);
|
|
2036
2843
|
try {
|
|
2037
|
-
const res = await sendHelpMessage({ app: appId, kind, message: trimmed, context });
|
|
2844
|
+
const res = await sendWithImages(images, ids => sendHelpMessage({ app: appId, kind, message: trimmed, context, attachmentIds: ids }));
|
|
2038
2845
|
setSent(res);
|
|
2039
2846
|
setMessage("");
|
|
2040
2847
|
setKind("help");
|
|
2041
2848
|
setView("sent");
|
|
2042
2849
|
}
|
|
2043
2850
|
catch (err) {
|
|
2044
|
-
setError(err
|
|
2851
|
+
setError(errorText(err, "ส่งไม่สำเร็จ"));
|
|
2045
2852
|
}
|
|
2046
2853
|
finally {
|
|
2047
2854
|
setSending(false);
|
|
2048
2855
|
}
|
|
2049
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
|
+
};
|
|
2050
2877
|
const email = session?.user?.email ?? null;
|
|
2051
2878
|
const placeholder = KINDS.find(k => k.value === kind)?.placeholder;
|
|
2052
|
-
|
|
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 " +
|
|
2053
2881
|
(kind === k.value
|
|
2054
2882
|
? "bg-background text-foreground shadow-sm"
|
|
2055
|
-
: "text-muted-foreground hover:text-foreground"), children: k.label }, k.value))) })] }), jsxRuntime.jsxs("div", { className: "grid gap-2", children: [jsxRuntime.jsx(erpTheme.Label, { htmlFor: "erp-help-message", children: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21" }), jsxRuntime.jsx(erpTheme.Textarea, { id: "erp-help-message", rows: 6, maxLength: MAX_MESSAGE, required: true, value: message, placeholder: placeholder, onChange: e => setMessage(e.target.value), className: "min-h-[140px] resize-y" })] }), context ? (jsxRuntime.jsxs("p", { className: "break-words text-xs leading-relaxed text-muted-foreground", children: ["\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E17\u0E35\u0E48\u0E41\u0E19\u0E1A: ", describeAttached(appId, context, email)] })) : null, error ? (jsxRuntime.jsx("p", { role: "alert", className: "text-sm text-destructive", children: error })) : null, jsxRuntime.jsxs(erpTheme.DialogFooter, { className: "flex-row items-center gap-2 sm:justify-between", children: [jsxRuntime.jsx("button", { type: "button", onClick: loadMine, className: "mr-auto
|
|
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 })] }) }));
|
|
2056
2899
|
}
|
|
2057
2900
|
|
|
2058
2901
|
/**
|
|
@@ -2229,7 +3072,7 @@ function cx(...parts) {
|
|
|
2229
3072
|
* name, and a spaced caption beneath it.
|
|
2230
3073
|
*/
|
|
2231
3074
|
function WorkspaceBrand({ name, caption, mark, href, compact = false, className, }) {
|
|
2232
|
-
const body = (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("span", { className: erpTheme.cn("grid shrink-0 place-items-center overflow-hidden rounded-[14px] [&>img]:size-full [&>img]:object-cover", compact ? "size-9 rounded-[11px]" : "size-11"), children: mark }), compact ? (jsxRuntime.jsx("span", { className: "sr-only", children: name })) : (jsxRuntime.jsxs("span", { className: "min-w-0", children: [jsxRuntime.jsx("span", { className: "block truncate text-
|
|
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-2xl leading-tight font-extrabold tracking-[-0.05em]", children: name }), caption ? (jsxRuntime.jsx("small", { className: "-mt-0.5 block truncate text-xs font-semibold tracking-[0.29em] text-muted-foreground", children: caption })) : null] }))] }));
|
|
2233
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);
|
|
2234
3077
|
return href ? (jsxRuntime.jsx(Link, { href: href, className: cls, "aria-label": compact ? name : undefined, children: body })) : (jsxRuntime.jsx("div", { className: cls, children: body }));
|
|
2235
3078
|
}
|
|
@@ -2795,6 +3638,7 @@ exports.HelpDialogHost = HelpDialogHost;
|
|
|
2795
3638
|
exports.NavMain = NavMain;
|
|
2796
3639
|
exports.NavTools = NavTools;
|
|
2797
3640
|
exports.NavUser = NavUser;
|
|
3641
|
+
exports.OWNER_CHIP = OWNER_CHIP;
|
|
2798
3642
|
exports.OrganizationSwitcher = OrganizationSwitcher;
|
|
2799
3643
|
exports.PLAN_FEATURES = PLAN_FEATURES;
|
|
2800
3644
|
exports.PermissionGateShell = PermissionGateShell;
|
|
@@ -2839,9 +3683,12 @@ exports.getRequiredChildGroups = getRequiredChildGroups;
|
|
|
2839
3683
|
exports.hasPlanFeature = hasPlanFeature;
|
|
2840
3684
|
exports.isLikelyTransientNetworkError = isLikelyTransientNetworkError;
|
|
2841
3685
|
exports.isPathActive = isPathActive;
|
|
3686
|
+
exports.myRolesQueryKey = myRolesQueryKey;
|
|
2842
3687
|
exports.normalizePathnameForPermissionRules = normalizePathnameForPermissionRules;
|
|
2843
3688
|
exports.openHelp = openHelp;
|
|
3689
|
+
exports.parseMyRoles = parseMyRoles;
|
|
2844
3690
|
exports.resolveUiRoutePermission = resolveUiRoutePermission;
|
|
3691
|
+
exports.roleSummary = roleSummary;
|
|
2845
3692
|
exports.runGlobalRequestPrecheck = runGlobalRequestPrecheck;
|
|
2846
3693
|
exports.runWithTransientRetry = runWithTransientRetry;
|
|
2847
3694
|
exports.setOrganizationId = setOrganizationId;
|
|
@@ -2853,6 +3700,7 @@ exports.useFeatureFlag = useFeatureFlag;
|
|
|
2853
3700
|
exports.useFeatureFlags = useFeatureFlags;
|
|
2854
3701
|
exports.useInvalidateQueriesOnOrgChange = useInvalidateQueriesOnOrgChange;
|
|
2855
3702
|
exports.useKeycloakRoles = useKeycloakRoles;
|
|
3703
|
+
exports.useMyRoles = useMyRoles;
|
|
2856
3704
|
exports.useOrganizationIdState = useOrganizationIdState;
|
|
2857
3705
|
exports.usePermissionRules = usePermissionRules;
|
|
2858
3706
|
exports.useSessionRefresh = useSessionRefresh;
|