@eintrek/erp-shell 0.1.52 → 0.1.53

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -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
- /** Called by the dialog host on mount; returns its unmount. */
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,562 +904,371 @@ 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
- * Plan feature keys (billable entitlements). Mirror of platform-apis plan.go.
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
- * This lives in the shell because every app gates on the same keys, and five
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
- const PLAN_FEATURES = {
1030
- ORG_CHART: "org_chart",
1031
- APPROVAL_WORKFLOW: "approval_workflow",
1032
- API_ACCESS: "api_access",
1033
- SSO: "sso",
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
- * True when plan features include key. Aliases, mirroring the backend:
1054
- * legacy basic_accounting_hr → module.hr + module.accounting (+ its sub-keys);
1055
- * module.accounting → module.sales, module.purchasing, module.secretariat.
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 hasPlanFeature(features, feature) {
1058
- if (!features?.length)
1059
- return false;
1060
- if (features.includes(feature))
1061
- return true;
1062
- if ((feature === PLAN_FEATURES.MODULE_HR ||
1063
- feature === PLAN_FEATURES.MODULE_ACCOUNTING ||
1064
- ACCOUNTING_SUB_KEYS.includes(feature)) &&
1065
- features.includes(PLAN_FEATURES.BASIC_ACCOUNTING_HR)) {
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
- if (feature === PLAN_FEATURES.BASIC_ACCOUNTING_HR &&
1073
- features.includes(PLAN_FEATURES.MODULE_HR) &&
1074
- features.includes(PLAN_FEATURES.MODULE_ACCOUNTING)) {
1075
- return true;
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 false;
951
+ return { organizationId, isOwner: data.is_owner === true, roles };
1078
952
  }
1079
-
1080
- function normaliseBase(value, fallback) {
1081
- return (value ?? fallback).replace(/\/$/, "");
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
- * One list, in display order, for the whole suite.
1085
- *
1086
- * It used to be copied into every app's own nav-user.tsx, which is how Invenx
1087
- * shipped without a link to Plannex: five copies, one of them edited by hand.
1088
- * Adding the sixth app is now a change here plus a version bump.
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
- // Helper function to check if pathname matches a URL pattern
1153
- // This prevents false matches like /reports/sales matching /sales
1154
- // Handles paths with or without [code] prefix
1155
- function isPathActive(pathname, url) {
1156
- // Exact match
1157
- if (pathname === url) {
1158
- return true;
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
- // Check if pathname starts with url + "/" to ensure exact segment matching
1161
- // This prevents /reports/sales from matching /sales
1162
- // Example: /org123/reports/sales should NOT match /org123/sales
1163
- // But /org123/sales/overview SHOULD match /org123/sales
1164
- if (pathname.startsWith(url + "/")) {
1165
- return true;
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 false;
1168
- }
997
+ return inFlightTokenPromise;
998
+ };
999
+ const clearAccessTokenCache = () => {
1000
+ cachedToken = null;
1001
+ cachedAt = 0;
1002
+ };
1169
1003
 
1170
- /** "/O1/" and "/O1" are the same page; a query string never is. */
1171
- function samePage(pathname, url) {
1172
- if (url.includes("?"))
1173
- return false;
1174
- const strip = (p) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
1175
- return strip(pathname) === strip(url);
1176
- }
1177
- function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, chrome = "brand", highlightActiveModule = false, }) {
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
- }, [activeKey]);
1235
- const setItemOpen = (id, open) => setOpenIds(prev => {
1236
- if (prev.has(id) === open)
1237
- return prev;
1238
- const next = new Set(prev);
1239
- if (open)
1240
- next.add(id);
1241
- else
1242
- next.delete(id);
1243
- return next;
1244
- });
1245
- 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) => {
1246
- const groupHeading = workspace &&
1247
- item.group &&
1248
- item.group !== items[index - 1]?.group ? (jsxRuntime.jsx("li", { className: "list-none px-3 pb-2.5 text-xs text-muted-foreground" +
1249
- (index > 0 ? " pt-5" : ""), children: item.group }, `group-${item.id}`)) : null;
1250
- const { itemUrl, activeSubItemUrl, isItemActive } = states[index];
1251
- const IconComp = item.icon;
1252
- const workspaceButton = workspace
1253
- ? "h-auto gap-3 rounded-[10px] px-3 py-2.5 text-[13px] " +
1254
- (isItemActive
1255
- ? "bg-sidebar-accent font-bold text-sidebar-accent-foreground"
1256
- : "font-medium text-sidebar-foreground/70")
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
- function OrgMark({ logoUrl, size = "md", }) {
1295
- const [failed, setFailed] = React.useState(false);
1296
- const box = size === "sm"
1297
- ? "flex size-6 items-center justify-center rounded-md border overflow-hidden bg-background"
1298
- : size === "header"
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
- return (jsxRuntime.jsx("div", { className: box, children: jsxRuntime.jsx(lucideReact.Building2, { className: icon }) }));
1306
- }
1307
- function OrganizationSwitcher({ activeOrganization, isContextLoading = false, organizations, isLoadingOrgs = false, setOrganizationCookie, registerOrganizationHref = "/register-organization", placement = "sidebar", className, }) {
1308
- const { isMobile } = useSidebarToggle();
1309
- const router = navigation.useRouter();
1310
- const params = navigation.useParams();
1311
- const [isPending, startTransition] = React.useTransition();
1312
- const currentCode = params?.code
1313
- ? Array.isArray(params.code)
1314
- ? params.code[0]
1315
- : params.code
1316
- : undefined;
1317
- const handleOrganizationChange = (org) => {
1318
- startTransition(async () => {
1319
- const orgCode = org.code || String(org.id);
1320
- // Write storage + cookie before navigation — Layout will re-hydrate
1321
- // Context on the new URL.
1322
- try {
1323
- setOrganizationId(String(org.id));
1324
- await setOrganizationCookie(org.id);
1325
- }
1326
- catch {
1327
- // ignore: navigation will re-attempt context resolution
1328
- }
1329
- if (currentCode) {
1330
- const currentPath = window.location.pathname;
1331
- const parts = currentPath.split("/");
1332
- const decodedParts = parts.map(p => decodeURIComponent(p));
1333
- const codeIndex = decodedParts.indexOf(currentCode);
1334
- if (codeIndex !== -1) {
1335
- parts[codeIndex] = orgCode;
1336
- router.push(parts.join("/"));
1337
- }
1338
- else {
1339
- router.push(`/${orgCode}`);
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
- router.push(`/${orgCode}`);
1143
+ else if (value !== undefined && value !== null) {
1144
+ searchParams.append(key, String(value));
1344
1145
  }
1345
1146
  });
1346
- };
1347
- const isLoading = isLoadingOrgs || isContextLoading;
1348
- if (placement === "header") {
1349
- 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-[10px] leading-tight text-[var(--header-fg-muted,currentColor)] max-sm:text-[9px]", 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-[11px]", 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] }));
1350
- const faceClass = erpTheme.cn("flex items-center gap-2.5 rounded-lg py-1.5 max-sm:gap-2", className);
1351
- if (isLoading || organizations.length <= 1) {
1352
- return jsxRuntime.jsx("div", { className: faceClass, children: face(false) });
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
- 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" })] })] })] }));
1217
+ await redirectToLogout();
1218
+ return Promise.reject(toApiError(error));
1355
1219
  }
1356
- if (isLoading) {
1357
- 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" })] }) }) }));
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
- if (organizations.length <= 1) {
1360
- 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 || "ไม่มีองค์กร" })] }) }) }));
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 (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) &&
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
- function SidebarHeader({ children, className }) {
1367
- return jsxRuntime.jsx("div", { className: erpTheme.cn("px-2 py-3", className), children: children });
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
- function AppSidebar({ modules, tools, activeModule, orgCode, approvalCounts, closeOnNavigation = false, highlightActiveModule = false, header, footer, bottom, chrome = "brand", className, }) {
1371
- const { open } = useSidebar();
1372
- const modulesWithActiveState = React__namespace.useMemo(() => modules.map(m => ({
1373
- ...m,
1374
- isActive: m.id === activeModule,
1375
- })), [modules, activeModule]);
1376
- if (!open)
1377
- 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
- }
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
- }
1385
-
1386
- /**
1387
- * Header ink as CSS variables. Header-borne pieces — breadcrumb, sidebar
1388
- * trigger, the org switcher, the app's own page title — read these, with
1389
- * white as the fallback, so the brand chrome looks exactly as before.
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 {
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 {
1439
1272
  return null;
1440
1273
  }
1441
1274
  }
@@ -1601,270 +1434,632 @@ function describeAttached(app, ctx, email) {
1601
1434
  .join(" · ");
1602
1435
  }
1603
1436
 
1604
- const TOKEN_TTL_MS = 30000;
1605
- let cachedToken = null;
1606
- let cachedAt = 0;
1607
- let inFlightTokenPromise = null;
1608
- const readSessionToken = async () => {
1609
- const response = await fetch("/api/auth/session", {
1610
- credentials: "include",
1611
- cache: "no-store",
1612
- });
1613
- if (!response.ok)
1614
- return null;
1615
- const session = (await response.json());
1616
- return session.accessToken ?? null;
1617
- };
1618
- const getAccessToken = async (forceRefresh = false) => {
1619
- const now = Date.now();
1620
- if (!forceRefresh && cachedToken && now - cachedAt < TOKEN_TTL_MS) {
1621
- return cachedToken;
1622
- }
1623
- if (!inFlightTokenPromise) {
1624
- inFlightTokenPromise = readSessionToken()
1625
- .then(token => {
1626
- cachedToken = token;
1627
- cachedAt = Date.now();
1628
- return token;
1629
- })
1630
- .finally(() => {
1631
- inFlightTokenPromise = null;
1632
- });
1633
- }
1634
- return inFlightTokenPromise;
1635
- };
1636
- const clearAccessTokenCache = () => {
1637
- cachedToken = null;
1638
- cachedAt = 0;
1639
- };
1640
-
1641
- class ApiError extends Error {
1642
- constructor(message, options) {
1643
- super(message);
1644
- Object.defineProperty(this, "status", {
1645
- enumerable: true,
1646
- configurable: true,
1647
- writable: true,
1648
- value: void 0
1649
- });
1650
- Object.defineProperty(this, "code", {
1651
- enumerable: true,
1652
- configurable: true,
1653
- writable: true,
1654
- value: void 0
1655
- });
1656
- Object.defineProperty(this, "field", {
1657
- enumerable: true,
1658
- configurable: true,
1659
- writable: true,
1660
- value: void 0
1661
- });
1662
- Object.defineProperty(this, "details", {
1663
- enumerable: true,
1664
- configurable: true,
1665
- writable: true,
1666
- value: void 0
1667
- });
1668
- this.name = "ApiError";
1669
- this.status = options?.status;
1670
- this.code = options?.code;
1671
- this.field = options?.field;
1672
- this.details = options?.details;
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
- const isPlainObject = (value) => Object.prototype.toString.call(value) === "[object Object]";
1698
- const visit = (value, cb) => {
1699
- if (Array.isArray(value)) {
1700
- value.forEach(item => visit(item, cb));
1701
- return;
1702
- }
1703
- if (!isPlainObject(value)) {
1704
- return;
1705
- }
1706
- cb(value);
1707
- Object.values(value).forEach(item => visit(item, cb));
1708
- };
1709
- const validateGoodsReceiptChecks = (data) => {
1710
- let invalidPath = null;
1711
- const isCheckStatus = (v) => v === undefined || v === null || v === "pass" || v === "fail";
1712
- const isCompareStatus = (v) => v === undefined || v === null || v === "match" || v === "mismatch";
1713
- visit(data, node => {
1714
- if (invalidPath)
1715
- return;
1716
- if ("quantity_check" in node && !isCheckStatus(node.quantity_check)) {
1717
- invalidPath = "quantity_check";
1718
- return;
1490
+ /** The owner's chip: the app's accent, tinted. */
1491
+ function OwnerChip({ size = "card" }) {
1492
+ return (jsxRuntime.jsx("span", { className: size === "menu"
1493
+ ? "inline-flex shrink-0 items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary"
1494
+ : "inline-flex shrink-0 items-center rounded-full bg-primary/10 px-1.5 text-[11px] font-semibold leading-[17px] text-primary", children: OWNER_CHIP }));
1495
+ }
1496
+ /**
1497
+ * The card's role line: the owner's chip, then two role names and a count of
1498
+ * the rest. Wraps between names rather than inside one, and truncates a name
1499
+ * that is wider than the card on its own; the tooltip has everything.
1500
+ */
1501
+ function CardRoles({ my, className }) {
1502
+ const { shown, more } = cardRoleLine(my.roles);
1503
+ return (jsxRuntime.jsxs("span", { className: `flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0.5 ${className}`, title: roleSummary(my), children: [my.isOwner ? jsxRuntime.jsx(OwnerChip, {}) : null, shown.map((name, i) => {
1504
+ const last = i === shown.length - 1;
1505
+ return (jsxRuntime.jsxs("span", { className: "flex min-w-0 max-w-full items-baseline", children: [jsxRuntime.jsx("span", { className: "truncate", children: name }), !last ? (jsxRuntime.jsx("span", { "aria-hidden": "true", className: "shrink-0 pl-1 opacity-60", children: "\u00B7" })) : null, last && more > 0 ? (jsxRuntime.jsxs("span", { className: "shrink-0 pl-1 opacity-70", children: ["+", more] })) : null] }, name));
1506
+ })] }));
1507
+ }
1508
+ /** Every role in full, for the menu. */
1509
+ function MenuRoles({ my }) {
1510
+ return (jsxRuntime.jsxs("div", { className: "flex flex-wrap gap-1.5", children: [my.isOwner ? jsxRuntime.jsx(OwnerChip, { size: "menu" }) : null, my.roles.map(r => (jsxRuntime.jsx("span", { className: "rounded-md border border-sidebar-border px-2 py-0.5 text-xs text-sidebar-foreground", children: r.name_th }, r.key || r.name_th)))] }));
1511
+ }
1512
+ function isExternalHref(href) {
1513
+ return /^https?:\/\//i.test(href) || href.startsWith("//");
1514
+ }
1515
+ function NavUser({ organization, avatarSrc, profileHref, selectOrganizationHref = "/select-organization", signInRedirectHref = "/auth/signin", systemLinks, showSelectOrganization = true, showLanguageSwitch = true, variant = "brand", showRoles, organizationId, } = {}) {
1516
+ const { data: session, status } = react.useSession();
1517
+ const storedOrganizationId = useStoredOrganizationId();
1518
+ const rolesOrganizationId = organizationId !== null &&
1519
+ organizationId !== undefined &&
1520
+ String(organizationId).trim() !== ""
1521
+ ? organizationId
1522
+ : storedOrganizationId;
1523
+ const rolesOn = (showRoles ?? showSelectOrganization) && status === "authenticated";
1524
+ const { data: rolesData, fetchStatus: rolesFetchStatus } = useMyRoles(rolesOrganizationId, rolesOn);
1525
+ const myRoles = rolesOn && hasRoleLine(rolesData) ? rolesData : null;
1526
+ // First load only: hold the line's place so the card does not jump when
1527
+ // the roles arrive. A failure drops it.
1528
+ const rolesLoading = rolesOn && !!rolesOrganizationId && rolesData === undefined && rolesFetchStatus === "fetching";
1529
+ // With a role line the card is three lines or more: the chevron moves up
1530
+ // to the name so the lines under it get the card's full width.
1531
+ const cardTall = !!myRoles || rolesLoading;
1532
+ const router = navigation.useRouter();
1533
+ const { theme, setTheme } = nextThemes.useTheme();
1534
+ const [isSigningOut, setIsSigningOut] = React.useState(false);
1535
+ const [isOpen, setIsOpen] = React.useState(false);
1536
+ const [showThemeMenu, setShowThemeMenu] = React.useState(false);
1537
+ const [showLanguageMenu, setShowLanguageMenu] = React.useState(false);
1538
+ const [showAppsMenu, setShowAppsMenu] = React.useState(false);
1539
+ const buttonRef = React.useRef(null);
1540
+ const [position, setPosition] = React.useState({ top: 0, left: 0 });
1541
+ // Shown where a layout has mounted the help dialog (AppLayoutShell).
1542
+ const helpAvailable = useHelpAvailable();
1543
+ const user = session?.user
1544
+ ? {
1545
+ name: session.user.name || "ผู้ใช้",
1546
+ email: session.user.email || "",
1547
+ // avatarSrc first: the NextAuth session carries no picture unless the
1548
+ // host app deliberately puts one in the JWT, so relying on
1549
+ // session.user.image alone left every user on their initials forever.
1550
+ avatar: avatarSrc || session.user.image || undefined,
1719
1551
  }
1720
- if ("quality_check" in node && !isCheckStatus(node.quality_check)) {
1721
- invalidPath = "quality_check";
1722
- return;
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
- if ("compare_with_reference" in node &&
1725
- !isCompareStatus(node.compare_with_reference)) {
1726
- invalidPath = "compare_with_reference";
1568
+ catch (error) {
1569
+ console.error("Sign out error:", error);
1570
+ window.location.href = signInRedirectHref;
1727
1571
  }
1728
- });
1729
- if (invalidPath) {
1730
- return {
1731
- valid: false,
1732
- message: "รูปแบบข้อมูลไม่ถูกต้อง: ฟิลด์ตรวจรับต้องเป็น enum string (quantity/quality: pass|fail, compare: match|mismatch)",
1733
- };
1734
- }
1735
- return { valid: true };
1736
- };
1737
- const runGlobalRequestPrecheck = (config) => {
1738
- const method = (config.method || "get").toLowerCase();
1739
- const isWriteMethod = ["post", "put", "patch"].includes(method);
1740
- if (!isWriteMethod || config.data === undefined || config.data === null) {
1741
- return { valid: true };
1742
- }
1743
- if (typeof FormData !== "undefined" && config.data instanceof FormData) {
1744
- return { valid: true };
1745
- }
1746
- return validateGoodsReceiptChecks(config.data);
1747
- };
1748
-
1749
- const isBrowser = () => typeof window !== "undefined";
1750
- const isAuthEndpoint = (url) => !!url && url.includes("/api/auth/");
1751
- const isAuthPage = () => isBrowser() && window.location.pathname.startsWith("/auth/");
1752
- const getOrgFromCookie = () => {
1753
- if (!isBrowser())
1754
- return null;
1755
- const cookies = document.cookie.split(";").map(cookie => cookie.trim());
1756
- const orgCookie = cookies.find(cookie => cookie.startsWith("erp.organizationId="));
1757
- if (!orgCookie)
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 raw = orgCookie.substring("erp.organizationId=".length);
1760
- return raw ? decodeURIComponent(raw) : null;
1761
- };
1762
- const redirectToLogout = async () => {
1763
- if (!isBrowser() || isAuthPage())
1764
- return;
1765
- localStorage.clear();
1766
- sessionStorage.clear();
1767
- setOrganizationId(null);
1768
- clearAccessTokenCache();
1769
- await react.signOut({ callbackUrl: "/auth/signin" });
1625
+ const visibleSystemLinks = (systemLinks ?? []).filter(link => link.href && link.label);
1626
+ const dropdownContent = isOpen ? (jsxRuntime.jsxs("div", { "data-dropdown": "nav-user", className: "fixed w-52 sm:w-60 md:w-80 rounded-lg bg-sidebar border border-sidebar-border shadow-xl z-50 overflow-hidden\n -translate-x-[65%] -translate-y-[110%] sm:translate-x-0 sm:-translate-y-full", style: { top: `${position.top}px`, left: `${position.left}px` }, children: [organization && (jsxRuntime.jsx("div", { className: "p-4 border-b border-sidebar-border bg-sidebar-accent/30", children: jsxRuntime.jsxs("div", { className: `flex gap-3 ${myRoles ? "items-start" : "items-center"}`, children: [organization.logoUrl ? (
1627
+ // eslint-disable-next-line @next/next/no-img-element
1628
+ jsxRuntime.jsx("img", { src: organization.logoUrl, alt: organization.name ?? "โลโก้บริษัท", className: "h-8 w-8 flex-shrink-0 rounded object-contain bg-white",
1629
+ // A dead logo URL must not leave a broken-image glyph in the
1630
+ // sidebar; drop back to the generic icon's empty slot.
1631
+ onError: e => {
1632
+ e.currentTarget.style.display = "none";
1633
+ } })) : (jsxRuntime.jsx(lucideReact.Building2, { className: "h-5 w-5 text-sidebar-foreground/70 flex-shrink-0" })), jsxRuntime.jsxs("div", { className: "flex-1 min-w-0", children: [jsxRuntime.jsx("div", { className: "font-medium text-sm sm:truncate", children: organization.name }), organization.description && (jsxRuntime.jsx("div", { className: "text-xs text-sidebar-foreground/70 truncate mt-0.5", children: organization.description })), myRoles ? (jsxRuntime.jsx("div", { className: "mt-2.5", children: jsxRuntime.jsx(MenuRoles, { my: myRoles }) })) : null] })] }) })), !organization && myRoles ? (jsxRuntime.jsx("div", { className: "p-4 border-b border-sidebar-border bg-sidebar-accent/30", children: jsxRuntime.jsx(MenuRoles, { my: myRoles }) })) : null, jsxRuntime.jsxs("div", { className: "p-2", children: [jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsxs("button", { onClick: () => {
1634
+ setShowThemeMenu(!showThemeMenu);
1635
+ setShowLanguageMenu(false);
1636
+ setShowAppsMenu(false);
1637
+ }, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx(lucideReact.Palette, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E18\u0E35\u0E21" }), jsxRuntime.jsx(lucideReact.ChevronUp, { className: `h-4 w-4 transition-transform ${showThemeMenu ? "rotate-180" : ""}` })] }), showThemeMenu && (jsxRuntime.jsx("div", { className: "mt-1 ml-4 space-y-1", children: [
1638
+ { key: "light", label: "สว่าง", Icon: lucideReact.Sun },
1639
+ { key: "dark", label: "มืด", Icon: lucideReact.Moon },
1640
+ { key: "system", label: "ตามระบบ", Icon: lucideReact.Monitor },
1641
+ ].map(({ key, label, Icon }) => (jsxRuntime.jsxs("button", { onClick: () => handleThemeChange(key), className: "flex w-full items-center gap-3 px-3 py-2 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx(Icon, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: label }), theme === key && (jsxRuntime.jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, key))) }))] }), showLanguageSwitch && (jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsxs("button", { onClick: () => {
1642
+ setShowLanguageMenu(!showLanguageMenu);
1643
+ setShowThemeMenu(false);
1644
+ setShowAppsMenu(false);
1645
+ }, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx(lucideReact.Globe, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E20\u0E32\u0E29\u0E32" }), jsxRuntime.jsx(lucideReact.ChevronUp, { className: `h-4 w-4 transition-transform ${showLanguageMenu ? "rotate-180" : ""}` })] }), showLanguageMenu && (jsxRuntime.jsx("div", { className: "mt-1 ml-4 space-y-1", children: [
1646
+ { code: "th", label: "ไทย" },
1647
+ { code: "en", label: "English" },
1648
+ { code: "zh", label: "中文" },
1649
+ { code: "ja", label: "日本語" },
1650
+ ].map(({ code, label }) => (jsxRuntime.jsxs("button", { onClick: () => handleLanguageChange(code), className: "flex w-full items-center gap-3 px-3 py-2 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx("span", { className: "flex-1 text-left", children: label }), organization?.language === code && (jsxRuntime.jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, code))) }))] })), profileHref && (jsxRuntime.jsxs("button", { onClick: handleProfile, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx(lucideReact.User, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E2A\u0E48\u0E27\u0E19\u0E15\u0E31\u0E27" })] })), showSelectOrganization && (jsxRuntime.jsxs("button", { onClick: handleSelectOrganization, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx(lucideReact.Building2, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E25\u0E37\u0E2D\u0E01\u0E18\u0E38\u0E23\u0E01\u0E34\u0E08" })] })), visibleSystemLinks.length > 0 && (jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsxs("button", { onClick: () => {
1651
+ setShowAppsMenu(!showAppsMenu);
1652
+ setShowThemeMenu(false);
1653
+ setShowLanguageMenu(false);
1654
+ }, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx(lucideReact.LayoutGrid, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E41\u0E2D\u0E1B\u0E1E\u0E25\u0E34\u0E40\u0E04\u0E0A\u0E31\u0E19" }), jsxRuntime.jsx(lucideReact.ChevronUp, { className: `h-4 w-4 transition-transform ${showAppsMenu ? "rotate-180" : ""}` })] }), showAppsMenu && (jsxRuntime.jsx("div", { className: "mt-1 ml-4 space-y-1", children: visibleSystemLinks.map(link => (jsxRuntime.jsxs("button", { type: "button", onClick: () => handleSystemLink(link.href), className: "flex w-full items-center gap-3 px-3 py-2 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [link.icon ?? jsxRuntime.jsx(lucideReact.ExternalLink, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: link.label }), link.active && (jsxRuntime.jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, `${link.label}:${link.href}`))) }))] })), helpAvailable && (jsxRuntime.jsxs("button", { type: "button", onClick: () => {
1655
+ setIsOpen(false);
1656
+ openHelp();
1657
+ }, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx(lucideReact.CircleHelp, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D" })] })), jsxRuntime.jsx("button", { onClick: handleSignOut, disabled: isSigningOut, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed mt-1", children: isSigningOut ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(lucideReact.Loader2, { className: "h-4 w-4 animate-spin" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E23\u0E30\u0E1A\u0E1A..." })] })) : (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(lucideReact.LogOut, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E23\u0E30\u0E1A\u0E1A" })] })) })] })] })) : null;
1658
+ if (variant === "workspace") {
1659
+ return (jsxRuntime.jsx("div", { className: "mt-3.5 border-t border-sidebar-border pt-3", children: jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsxs("button", { ref: buttonRef, onClick: () => setIsOpen(!isOpen), "aria-expanded": isOpen, className: `relative flex w-full gap-[11px] rounded-lg px-3 py-1.5 text-left transition-colors hover:bg-sidebar-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring ${cardTall ? "items-start" : "items-center"}`, children: [jsxRuntime.jsxs(erpTheme.Avatar, { className: "size-[34px] shrink-0 rounded-full border-[3px] border-sidebar-accent", children: [user.avatar ? (jsxRuntime.jsx(erpTheme.AvatarImage, { src: user.avatar, alt: user.name })) : null, jsxRuntime.jsx(erpTheme.AvatarFallback, { className: "rounded-full bg-sidebar-accent text-sm font-medium text-sidebar-accent-foreground", children: getInitials(user.name) })] }), jsxRuntime.jsxs("span", { className: `grid min-w-0 flex-1 leading-tight ${cardTall ? "gap-0.5" : ""}`, children: [jsxRuntime.jsx("strong", { className: `truncate text-[13px] font-semibold ${cardTall ? "pr-5" : ""}`, title: user.name, children: user.name }), myRoles ? (jsxRuntime.jsx(CardRoles, { my: myRoles, className: "text-[12px] font-medium leading-[17px] text-sidebar-foreground/80" })) : rolesLoading ? (jsxRuntime.jsx("span", { className: "my-[3px] block h-[11px] w-20 animate-pulse rounded bg-sidebar-accent" })) : null, jsxRuntime.jsx("small", { className: "truncate text-[11px] text-muted-foreground", title: user.email, children: user.email })] }), jsxRuntime.jsx(lucideReact.ChevronsUpDown, { className: cardTall
1660
+ ? "absolute right-3 top-[9px] size-3.5 text-muted-foreground"
1661
+ : "size-3.5 shrink-0 text-muted-foreground" })] }), typeof window !== "undefined" &&
1662
+ reactDom.createPortal(dropdownContent, document.body)] }) }));
1663
+ }
1664
+ return (jsxRuntime.jsx("div", { className: "p-2", children: jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsxs("button", { ref: buttonRef, onClick: () => setIsOpen(!isOpen), className: `relative flex w-full gap-2 p-2 rounded-md transition-colors ${cardTall ? "items-start" : "items-center"}`, children: [jsxRuntime.jsxs(erpTheme.Avatar, { className: "h-8 w-8 shrink-0 rounded-lg", children: [user.avatar ? (jsxRuntime.jsx(erpTheme.AvatarImage, { src: user.avatar, alt: user.name })) : null, jsxRuntime.jsx(erpTheme.AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground w-8 h-8", children: getInitials(user.name) })] }), jsxRuntime.jsxs("div", { className: `grid min-w-0 flex-1 text-left text-sm leading-tight ${cardTall ? "gap-0.5" : ""}`, children: [jsxRuntime.jsx("span", { className: `truncate font-medium ${cardTall ? "pr-6" : ""}`, title: user.name, children: user.name }), myRoles ? (jsxRuntime.jsx(CardRoles, { my: myRoles, className: "text-xs font-medium text-sidebar-foreground/85" })) : rolesLoading ? (jsxRuntime.jsx("span", { className: "my-0.5 block h-3 w-24 animate-pulse rounded bg-sidebar-accent" })) : null, jsxRuntime.jsx("span", { className: "truncate text-xs text-sidebar-foreground/70", title: user.email, children: user.email })] }), jsxRuntime.jsx(lucideReact.ChevronUp, { className: `size-4 transition-transform ${isOpen ? "rotate-180" : ""} ${cardTall ? "absolute right-2 top-2.5" : "ml-auto shrink-0"}` })] }), typeof window !== "undefined" &&
1665
+ reactDom.createPortal(dropdownContent, document.body)] }) }));
1666
+ }
1667
+
1668
+ /**
1669
+ * Plan feature keys (billable entitlements). Mirror of platform-apis plan.go.
1670
+ *
1671
+ * This lives in the shell because every app gates on the same keys, and five
1672
+ * private copies of the same list drift: the app-switcher list did exactly
1673
+ * that before it moved here.
1674
+ */
1675
+ const PLAN_FEATURES = {
1676
+ ORG_CHART: "org_chart",
1677
+ APPROVAL_WORKFLOW: "approval_workflow",
1678
+ API_ACCESS: "api_access",
1679
+ SSO: "sso",
1680
+ BASIC_ACCOUNTING_HR: "basic_accounting_hr",
1681
+ MODULE_PLATFORM: "module.platform",
1682
+ MODULE_HR: "module.hr",
1683
+ MODULE_ACCOUNTING: "module.accounting",
1684
+ MODULE_SALES: "module.sales",
1685
+ MODULE_PURCHASING: "module.purchasing",
1686
+ MODULE_INVENTORY: "module.inventory",
1687
+ MODULE_PROJECT: "module.project",
1688
+ MODULE_SECRETARIAT: "module.secretariat",
1689
+ /** AI pack (paid or trial): AI calls on AntNiti's key while credits remain. */
1690
+ AI: "ai",
1770
1691
  };
1771
- const instance = axios.create({
1772
- baseURL: process.env.NEXT_PUBLIC_API_URL,
1773
- headers: { "Content-Type": "application/json" },
1774
- paramsSerializer: params => {
1775
- const searchParams = new URLSearchParams();
1776
- Object.entries(params || {}).forEach(([key, value]) => {
1777
- if (Array.isArray(value)) {
1778
- value.forEach(v => searchParams.append(key, String(v)));
1779
- }
1780
- else if (value !== undefined && value !== null) {
1781
- searchParams.append(key, String(value));
1782
- }
1783
- });
1784
- return searchParams.toString();
1785
- },
1786
- });
1787
- instance.interceptors.request.use(async (config) => {
1788
- if (!config.headers) {
1789
- config.headers = new axios.AxiosHeaders();
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 (config.data instanceof FormData && config.headers) {
1792
- delete config.headers["Content-Type"];
1714
+ if (ACCOUNTING_SUB_KEYS.includes(feature) &&
1715
+ features.includes(PLAN_FEATURES.MODULE_ACCOUNTING)) {
1716
+ return true;
1793
1717
  }
1794
- if (!isBrowser())
1795
- return config;
1796
- const token = await getAccessToken(false);
1797
- if (token) {
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
- // localStorage first, cookie second.
1801
- //
1802
- // Both are written by the same state machine, but not at the same moment:
1803
- // setOrganizationId writes localStorage synchronously and then awaits a
1804
- // server action for the cookie. Preferring the cookie meant every request
1805
- // fired during that round trip carried the previous organisation — while
1806
- // the query params, built from React state, already carried the new one.
1807
- //
1808
- // The backend scopes by this header and ignores organization_id in the
1809
- // query, so those requests asked for organisation A and were handed B, and
1810
- // the answer landed in the cache under A's key. No amount of cache clearing
1811
- // reaches that: the entry is wrong, not stale.
1812
- const organizationId = getOrganizationId() || getOrgFromCookie();
1813
- if (organizationId) {
1814
- config.headers["X-Organization-ID"] = organizationId;
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
- const precheck = runGlobalRequestPrecheck(config);
1817
- if (!precheck.valid) {
1818
- throw new ApiError(precheck.message || "Invalid request payload");
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 config;
1821
- });
1822
- let refreshPromise = null;
1823
- const refreshTokenSingleFlight = async () => {
1824
- if (!refreshPromise) {
1825
- refreshPromise = getAccessToken(true).finally(() => {
1826
- refreshPromise = null;
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-[13px] " +
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-[10px] 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-[13px] [&_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 refreshPromise;
1830
- };
1831
- instance.interceptors.response.use(response => {
1832
- // Any successful call means the session can act in this organisation
1833
- // after all. Without this the gate, once raised, stayed up through a
1834
- // whole working session — including the seconds between an administrator
1835
- // adding somebody and the employee record existing.
1836
- if (typeof window !== "undefined") {
1837
- window.dispatchEvent(new CustomEvent("erp:employee-record-ok"));
1838
- }
1839
- return response;
1840
- }, async (error) => {
1841
- const originalRequest = error.config;
1842
- const requestUrl = originalRequest?.url || error.config?.url;
1843
- if (error.response?.status === 401 &&
1844
- originalRequest &&
1845
- !originalRequest._retry &&
1846
- !isAuthEndpoint(requestUrl) &&
1847
- !isAuthPage()) {
1848
- originalRequest._retry = true;
1849
- const newToken = await refreshTokenSingleFlight();
1850
- if (newToken && originalRequest.headers) {
1851
- originalRequest.headers.Authorization = `Bearer ${newToken}`;
1852
- return instance(originalRequest);
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-[10px] leading-tight text-[var(--header-fg-muted,currentColor)] max-sm:text-[9px]", 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-[11px]", 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
- await redirectToLogout();
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
- // The server refuses every organisation-scoped call when the account has
1858
- // no employee record. Announce it once so a gate can explain it, instead
1859
- // of letting each failed query raise its own toast.
1860
- if (error.response?.status === 403) {
1861
- const body = error.response.data;
1862
- if (body?.code === "EMPLOYEE_RECORD_REQUIRED" && typeof window !== "undefined") {
1863
- window.dispatchEvent(new CustomEvent("erp:employee-record-required"));
1864
- }
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 Promise.reject(toApiError(error));
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 channel lives on platform-api (POST /support/messages): every app
1896
- * already calls it with the user's token and the organisation header, and
1897
- * its CORS admits every app. The shared axios instance carries both and
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
- const explicit = process.env.NEXT_PUBLIC_PLATFORM_API_URL?.trim();
1903
- if (explicit)
1904
- return explicit.replace(/\/$/, "");
1905
- // Platform's own API is platform-api.
1906
- if (app === "platform") {
1907
- const own = process.env.NEXT_PUBLIC_API_URL?.trim();
1908
- if (own)
1909
- return own.replace(/\/$/, "");
1910
- }
1911
- return DEFAULT_PLATFORM_API_URL;
2396
+ return platformApiBase(app);
1912
2397
  }
1913
- /** A failure the dialog shows as is. */
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: status
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-[11px]" : "gap-1.5", children: [jsxRuntime.jsx(lucideReact.ImagePlus, { className: "h-4 w-4" }), "\u0E41\u0E19\u0E1A\u0E23\u0E39\u0E1B"] }), jsxRuntime.jsx("input", { ref: input, id: id, type: "file", accept: HELP_IMAGE_ACCEPT, multiple: true, tabIndex: -1, "aria-hidden": "true", className: "sr-only", onChange: e => {
2647
+ const files = e.currentTarget.files;
2648
+ if (files && files.length > 0)
2649
+ attach.add(Array.from(files));
2650
+ e.currentTarget.value = "";
2651
+ } })] }), problems.length > 0 ? (jsxRuntime.jsx("ul", { role: "alert", className: "grid gap-0.5 text-xs text-destructive", children: problems.map(p => (jsxRuntime.jsxs("li", { className: "break-words", children: [p.name, ": ", p.message] }, p.key))) })) : null] }));
2652
+ }
2653
+ function AttachThumb({ item, onRemove, disabled }) {
2654
+ const failed = item.status === "failed";
2655
+ return (jsxRuntime.jsxs("figure", { className: "relative m-0 w-16", children: [jsxRuntime.jsxs("div", { className: "relative h-16 w-16 overflow-hidden rounded-md border bg-muted " +
2656
+ (failed ? "border-destructive" : "border-border"), children: [item.preview ? (
2657
+ // eslint-disable-next-line @next/next/no-img-element
2658
+ jsxRuntime.jsx("img", { src: item.preview, alt: item.name, className: "h-full w-full object-cover" })) : null, item.status === "uploading" ? (jsxRuntime.jsx("div", { role: "progressbar", "aria-label": `กำลังอัปโหลด ${item.name}`, "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": Math.round(item.progress * 100), className: "absolute inset-x-1 bottom-1 h-1.5 overflow-hidden rounded-full bg-background/80", children: jsxRuntime.jsx("div", { className: "h-full bg-primary transition-[width]", style: { width: `${Math.round(item.progress * 100)}%` } }) })) : null] }), jsxRuntime.jsx("button", { type: "button", onClick: onRemove, disabled: disabled, "aria-label": `ลบรูป ${item.name}`, title: "\u0E25\u0E1A\u0E23\u0E39\u0E1B", className: "absolute -right-1.5 -top-1.5 grid size-5 place-items-center rounded-full border border-border bg-background text-muted-foreground shadow-sm hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:opacity-50", children: jsxRuntime.jsx(lucideReact.X, { className: "h-3 w-3" }) }), jsxRuntime.jsx("figcaption", { className: "mt-0.5 truncate text-[10px] leading-tight text-muted-foreground", title: item.name, children: failed ? jsxRuntime.jsx("span", { className: "text-destructive", children: "\u0E44\u0E21\u0E48\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08" }) : kb(item.size) })] }));
2659
+ }
2660
+ /** Images already sent: thumbnails that open the full image. */
2661
+ function HelpImageStrip({ images, size = "md" }) {
2662
+ if (!images || images.length === 0)
2663
+ return null;
2664
+ const box = size === "sm" ? "h-12 w-12" : "h-20 w-20";
2665
+ return (jsxRuntime.jsx("ul", { className: "mt-2 flex flex-wrap gap-2", children: images.map(img => (jsxRuntime.jsx("li", { children: img.url ? (jsxRuntime.jsx("a", { href: img.url, target: "_blank", rel: "noopener noreferrer", "aria-label": `เปิดรูป ${img.file_name || ""}`.trim(), className: `block ${box} overflow-hidden rounded-md border border-border bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring`, children: jsxRuntime.jsx("img", { src: img.url, alt: img.file_name || "รูปที่แนบ", loading: "lazy", className: "h-full w-full object-cover" }) })) : (jsxRuntime.jsx("span", { className: `block ${box} rounded-md border border-border bg-muted`, "aria-label": img.file_name })) }, img.id))) }));
2666
+ }
2667
+
2668
+ /**
2669
+ * The ตอบกลับ link in a staff answer e-mail opens the app with ?help=H1004:
2670
+ * the shell's ช่วยเหลือ dialog then opens on that message. Any app page
2671
+ * with the shell's layout works; the e-mail uses Platform.
2672
+ *
2673
+ * Kept free of imports: the tests load it straight from source.
2674
+ */
2675
+ const HELP_LINK_PARAM = "help";
2676
+ const REF = /^H\d{1,12}$/;
2677
+ /** The message a page address asks the dialog to open, or null. */
2678
+ function helpRefFromSearch(search) {
2679
+ if (!search)
2680
+ return null;
2681
+ let params;
2682
+ try {
2683
+ params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
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(). It sends to platform-api, which
1996
- * puts the message in the back office's queue on admin.antniti.com.
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
- setError(null);
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 instanceof Error ? e.message : "โหลดข้อมูลไม่สำเร็จ"));
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 instanceof Error ? err.message : "ส่งไม่สำเร็จ");
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
- return (jsxRuntime.jsx(erpTheme.Dialog, { open: open, onOpenChange: o => (o ? openHelp() : closeHelp()), children: jsxRuntime.jsxs(erpTheme.DialogContent, { className: "max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-lg", children: [view === "form" ? (jsxRuntime.jsxs("form", { onSubmit: send, className: "grid gap-4", children: [jsxRuntime.jsx(erpTheme.DialogHeader, { children: jsxRuntime.jsx(erpTheme.DialogTitle, { children: "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D" }) }), jsxRuntime.jsxs("div", { className: "grid gap-2", children: [jsxRuntime.jsx(erpTheme.Label, { id: "erp-help-kind", children: "\u0E1B\u0E23\u0E30\u0E40\u0E20\u0E17" }), jsxRuntime.jsx("div", { role: "radiogroup", "aria-labelledby": "erp-help-kind", className: "flex gap-1 rounded-lg bg-muted p-1", children: KINDS.map(k => (jsxRuntime.jsx("button", { type: "button", role: "radio", "aria-checked": kind === k.value, onClick: () => setKind(k.value), className: "flex-auto whitespace-nowrap rounded-md px-2 py-2 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:text-sm " +
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 text-sm text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded", children: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E17\u0E35\u0E48\u0E2A\u0E48\u0E07\u0E41\u0E25\u0E49\u0E27" }), jsxRuntime.jsxs("div", { className: "flex gap-2", children: [jsxRuntime.jsx(erpTheme.Button, { type: "button", variant: "outline", onClick: closeHelp, children: "\u0E22\u0E01\u0E40\u0E25\u0E34\u0E01" }), jsxRuntime.jsxs(erpTheme.Button, { type: "submit", disabled: !canSend, children: [sending ? jsxRuntime.jsx(lucideReact.Loader2, { className: "mr-1 h-4 w-4 animate-spin" }) : null, "\u0E2A\u0E48\u0E07"] })] })] })] })) : null, view === "sent" && sent ? (jsxRuntime.jsxs("div", { className: "grid gap-4", children: [jsxRuntime.jsxs("div", { className: "flex flex-col items-center gap-2 py-4 text-center", children: [jsxRuntime.jsx(lucideReact.CircleCheck, { className: "h-12 w-12 text-primary", strokeWidth: 1.6 }), jsxRuntime.jsx(erpTheme.DialogTitle, { className: "text-lg font-semibold", children: "\u0E2A\u0E48\u0E07\u0E41\u0E25\u0E49\u0E27" }), jsxRuntime.jsxs("p", { className: "text-sm text-muted-foreground", children: ["\u0E40\u0E25\u0E02\u0E17\u0E35\u0E48\u0E2D\u0E49\u0E32\u0E07\u0E2D\u0E34\u0E07", " ", jsxRuntime.jsx("span", { className: "font-mono text-base font-semibold text-foreground", children: sent.ref })] })] }), jsxRuntime.jsxs(erpTheme.DialogFooter, { className: "flex-row items-center gap-2 sm:justify-between", children: [jsxRuntime.jsx("button", { type: "button", onClick: loadMine, className: "mr-auto text-sm text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded", children: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E17\u0E35\u0E48\u0E2A\u0E48\u0E07\u0E41\u0E25\u0E49\u0E27" }), jsxRuntime.jsx(erpTheme.Button, { type: "button", onClick: closeHelp, children: "\u0E1B\u0E34\u0E14" })] })] })) : null, view === "mine" ? (jsxRuntime.jsxs("div", { className: "grid gap-4", children: [jsxRuntime.jsx(erpTheme.DialogHeader, { children: jsxRuntime.jsxs(erpTheme.DialogTitle, { className: "flex items-center gap-2", children: [jsxRuntime.jsx("button", { type: "button", "aria-label": "\u0E01\u0E25\u0E31\u0E1A", onClick: () => setView("form"), className: "-ml-1 grid size-8 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", children: jsxRuntime.jsx(lucideReact.ArrowLeft, { className: "h-4 w-4" }) }), "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E17\u0E35\u0E48\u0E2A\u0E48\u0E07\u0E41\u0E25\u0E49\u0E27"] }) }), mine === null && !mineError ? (jsxRuntime.jsxs("div", { className: "flex items-center gap-2 py-6 text-sm text-muted-foreground", children: [jsxRuntime.jsx(lucideReact.Loader2, { className: "h-4 w-4 animate-spin" }), "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14"] })) : null, mineError ? (jsxRuntime.jsx("p", { role: "alert", className: "text-sm text-destructive", children: mineError })) : null, mine && mine.length === 0 ? (jsxRuntime.jsx("p", { className: "py-6 text-sm text-muted-foreground", children: "\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21" })) : null, mine && mine.length > 0 ? (jsxRuntime.jsx("ul", { className: "grid gap-3", children: mine.map(m => (jsxRuntime.jsxs("li", { className: "rounded-lg border border-border p-3", children: [jsxRuntime.jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground", children: [jsxRuntime.jsx("span", { className: "font-mono font-semibold text-foreground", children: m.ref }), jsxRuntime.jsx("span", { children: KIND_LABEL[m.kind] ?? m.kind }), jsxRuntime.jsx("span", { children: when(m.created_at) }), jsxRuntime.jsx("span", { className: `ml-auto rounded-full px-2 py-0.5 font-medium ${STATUS_TONE[m.status] ?? ""}`, children: STATUS_LABEL[m.status] ?? m.status })] }), jsxRuntime.jsx("p", { className: "mt-2 line-clamp-4 whitespace-pre-wrap break-words text-sm", children: m.message }), m.replies.map((r, i) => (jsxRuntime.jsxs("div", { className: "mt-2 rounded-md bg-muted px-3 py-2", children: [jsxRuntime.jsxs("p", { className: "text-xs text-muted-foreground", children: ["\u0E17\u0E35\u0E21 AntNiti \u00B7 ", when(r.created_at)] }), jsxRuntime.jsx("p", { className: "mt-1 whitespace-pre-wrap break-words text-sm", children: r.body })] }, i)))] }, m.id))) })) : null] })) : null] }) }));
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
  /**
@@ -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;