@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.esm.js CHANGED
@@ -2,10 +2,10 @@
2
2
  import * as React from 'react';
3
3
  import React__default, { useRef, useEffect, useMemo, useState, useCallback, createContext, useContext, Fragment, useSyncExternalStore, useTransition, Suspense } from 'react';
4
4
  import { useSession, signOut } from 'next-auth/react';
5
- import { useQueryClient, MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query';
5
+ import { useQueryClient, useQuery, QueryClientContext, QueryClient, MutationCache, QueryCache, QueryClientProvider } from '@tanstack/react-query';
6
6
  import { jsx, jsxs, Fragment as Fragment$1 } from 'react/jsx-runtime';
7
7
  import { cn, Button, Breadcrumb, BreadcrumbList, BreadcrumbItem, BreadcrumbLink, BreadcrumbSeparator, BreadcrumbPage, Avatar, AvatarImage, AvatarFallback, Collapsible, CollapsibleTrigger, CollapsibleContent, Badge, DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuLabel, DropdownMenuItem, DropdownMenuSeparator, Skeleton, Dialog, DialogContent, DialogHeader, DialogTitle, Label, Textarea, DialogFooter, Card, CardHeader, CardContent } from '@eintrek/erp-theme';
8
- import { PanelLeftClose, Menu, ChevronRight, Building2, Palette, ChevronUp, Sun, Moon, Monitor, Globe, User, LayoutGrid, ExternalLink, CircleHelp, Loader2, LogOut, ChevronsUpDown, Calculator, Warehouse, FolderKanban, Users, ChevronDown, Plus, CircleCheck, ArrowLeft, ArrowRight, Compass, ArrowUpRight, Ban, Home, Headset, ContactRound, UserPlus } from 'lucide-react';
8
+ import { PanelLeftClose, Menu, ChevronRight, Building2, Palette, ChevronUp, Sun, Moon, Monitor, Globe, User, LayoutGrid, ExternalLink, CircleHelp, Loader2, LogOut, ChevronsUpDown, Calculator, Warehouse, FolderKanban, Users, ChevronDown, Plus, ImagePlus, X, CircleCheck, ArrowLeft, ArrowRight, Compass, ArrowUpRight, Ban, Home, Headset, ContactRound, UserPlus } from 'lucide-react';
9
9
  import { create } from 'zustand';
10
10
  import { persist, createJSONStorage } from 'zustand/middleware';
11
11
  import { useRouter, usePathname, useParams } from 'next/navigation';
@@ -806,6 +806,9 @@ function isRecordID(segment) {
806
806
  let open = false;
807
807
  let hosts = 0;
808
808
  let declaredApp = null;
809
+ // The message the dialog should open on (the ตอบกลับ link's ?help=H1004),
810
+ // until it is closed or left.
811
+ let pendingRef = null;
809
812
  const listeners = new Set();
810
813
  function emit() {
811
814
  listeners.forEach(l => l());
@@ -824,14 +827,35 @@ function openHelp() {
824
827
  }
825
828
  }
826
829
  function closeHelp() {
830
+ pendingRef = null;
827
831
  if (open) {
828
832
  open = false;
829
833
  emit();
830
834
  }
831
835
  }
832
- /** Called by the dialog host on mount; returns its unmount. */
836
+ /** Opens the dialog on one of the person's messages, by id or "H1004". */
837
+ function openHelpOn(ref) {
838
+ pendingRef = ref;
839
+ open = true;
840
+ emit();
841
+ }
842
+ /** The message the dialog was asked to open on, if it has not been left. */
843
+ function pendingHelpRef() {
844
+ return pendingRef;
845
+ }
846
+ /** The dialog moved on from the message it was opened on. */
847
+ function clearPendingHelpRef() {
848
+ pendingRef = null;
849
+ }
850
+ /**
851
+ * Called by the dialog host on mount; returns its unmount. A host that
852
+ * mounts while a message is waiting to be shown (a page that mounted its
853
+ * layout twice) opens the dialog again.
854
+ */
833
855
  function registerHelpHost() {
834
856
  hosts += 1;
857
+ if (pendingRef)
858
+ open = true;
835
859
  emit();
836
860
  return () => {
837
861
  hosts -= 1;
@@ -861,562 +885,371 @@ function declaredHelpApp() {
861
885
  return declaredApp;
862
886
  }
863
887
 
864
- function isExternalHref(href) {
865
- return /^https?:\/\//i.test(href) || href.startsWith("//");
866
- }
867
- function NavUser({ organization, avatarSrc, profileHref, selectOrganizationHref = "/select-organization", signInRedirectHref = "/auth/signin", systemLinks, showSelectOrganization = true, showLanguageSwitch = true, variant = "brand", } = {}) {
868
- const { data: session, status } = useSession();
869
- const router = useRouter();
870
- const { theme, setTheme } = useTheme();
871
- const [isSigningOut, setIsSigningOut] = useState(false);
872
- const [isOpen, setIsOpen] = useState(false);
873
- const [showThemeMenu, setShowThemeMenu] = useState(false);
874
- const [showLanguageMenu, setShowLanguageMenu] = useState(false);
875
- const [showAppsMenu, setShowAppsMenu] = useState(false);
876
- const buttonRef = useRef(null);
877
- const [position, setPosition] = useState({ top: 0, left: 0 });
878
- // Shown where a layout has mounted the help dialog (AppLayoutShell).
879
- const helpAvailable = useHelpAvailable();
880
- const user = session?.user
881
- ? {
882
- name: session.user.name || "ผู้ใช้",
883
- email: session.user.email || "",
884
- // avatarSrc first: the NextAuth session carries no picture unless the
885
- // host app deliberately puts one in the JWT, so relying on
886
- // session.user.image alone left every user on their initials forever.
887
- avatar: avatarSrc || session.user.image || undefined,
888
- }
889
- : null;
890
- const getInitials = (name) => {
891
- if (!name)
892
- return "U";
893
- return name
894
- .split(" ")
895
- .map(n => n[0])
896
- .join("")
897
- .toUpperCase()
898
- .slice(0, 2);
899
- };
900
- const handleSignOut = async () => {
901
- setIsSigningOut(true);
902
- try {
903
- await signOut({ callbackUrl: signInRedirectHref, redirect: true });
904
- }
905
- catch (error) {
906
- console.error("Sign out error:", error);
907
- window.location.href = signInRedirectHref;
908
- }
909
- finally {
910
- setIsSigningOut(false);
911
- }
912
- };
913
- const handleSelectOrganization = () => {
914
- setIsOpen(false);
915
- router.push(selectOrganizationHref);
916
- };
917
- const handleProfile = () => {
918
- setIsOpen(false);
919
- if (profileHref)
920
- router.push(profileHref);
921
- };
922
- const handleSystemLink = (href) => {
923
- setIsOpen(false);
924
- if (isExternalHref(href)) {
925
- window.location.assign(href);
926
- return;
927
- }
928
- router.push(href);
929
- };
930
- const handleThemeChange = (newTheme) => {
931
- setTheme(newTheme);
932
- setShowThemeMenu(false);
933
- };
934
- const handleLanguageChange = (language) => {
935
- console.log("Change language to:", language);
936
- setShowLanguageMenu(false);
937
- };
938
- useEffect(() => {
939
- if (isOpen && buttonRef.current) {
940
- const rect = buttonRef.current.getBoundingClientRect();
941
- setPosition({ top: rect.bottom, left: rect.right + 8 });
942
- }
943
- }, [isOpen]);
944
- useEffect(() => {
945
- if (!isOpen)
946
- return;
947
- const handleClickOutside = (event) => {
948
- if (buttonRef.current &&
949
- !buttonRef.current.contains(event.target) &&
950
- !event.target.closest('[data-dropdown="nav-user"]')) {
951
- setIsOpen(false);
952
- }
953
- };
954
- document.addEventListener("mousedown", handleClickOutside);
955
- return () => document.removeEventListener("mousedown", handleClickOutside);
956
- }, [isOpen]);
957
- if (status === "loading") {
958
- return (jsx("div", { className: "p-2", children: jsxs("div", { className: "flex items-center gap-2 p-2 rounded-md", children: [jsx("div", { className: "h-8 w-8 rounded-lg bg-sidebar-accent animate-pulse" }), jsxs("div", { className: "flex-1 space-y-1", children: [jsx("div", { className: "h-4 w-24 bg-sidebar-accent rounded animate-pulse" }), jsx("div", { className: "h-3 w-32 bg-sidebar-accent rounded animate-pulse" })] })] }) }));
959
- }
960
- if (!user)
961
- return null;
962
- const visibleSystemLinks = (systemLinks ?? []).filter(link => link.href && link.label);
963
- const dropdownContent = isOpen ? (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 && (jsx("div", { className: "p-4 border-b border-sidebar-border bg-sidebar-accent/30", children: jsxs("div", { className: "flex items-center gap-3", children: [organization.logoUrl ? (
964
- // eslint-disable-next-line @next/next/no-img-element
965
- jsx("img", { src: organization.logoUrl, alt: organization.name ?? "โลโก้บริษัท", className: "h-8 w-8 flex-shrink-0 rounded object-contain bg-white",
966
- // A dead logo URL must not leave a broken-image glyph in the
967
- // sidebar; drop back to the generic icon's empty slot.
968
- onError: e => {
969
- e.currentTarget.style.display = "none";
970
- } })) : (jsx(Building2, { className: "h-5 w-5 text-sidebar-foreground/70 flex-shrink-0" })), jsxs("div", { className: "flex-1 min-w-0", children: [jsx("div", { className: "font-medium text-sm sm:truncate", children: organization.name }), organization.description && (jsx("div", { className: "text-xs text-sidebar-foreground/70 truncate mt-0.5", children: organization.description }))] })] }) })), jsxs("div", { className: "p-2", children: [jsxs("div", { className: "relative", children: [jsxs("button", { onClick: () => {
971
- setShowThemeMenu(!showThemeMenu);
972
- setShowLanguageMenu(false);
973
- setShowAppsMenu(false);
974
- }, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsx(Palette, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E18\u0E35\u0E21" }), jsx(ChevronUp, { className: `h-4 w-4 transition-transform ${showThemeMenu ? "rotate-180" : ""}` })] }), showThemeMenu && (jsx("div", { className: "mt-1 ml-4 space-y-1", children: [
975
- { key: "light", label: "สว่าง", Icon: Sun },
976
- { key: "dark", label: "มืด", Icon: Moon },
977
- { key: "system", label: "ตามระบบ", Icon: Monitor },
978
- ].map(({ key, label, Icon }) => (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: [jsx(Icon, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: label }), theme === key && (jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, key))) }))] }), showLanguageSwitch && (jsxs("div", { className: "relative", children: [jsxs("button", { onClick: () => {
979
- setShowLanguageMenu(!showLanguageMenu);
980
- setShowThemeMenu(false);
981
- setShowAppsMenu(false);
982
- }, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsx(Globe, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E20\u0E32\u0E29\u0E32" }), jsx(ChevronUp, { className: `h-4 w-4 transition-transform ${showLanguageMenu ? "rotate-180" : ""}` })] }), showLanguageMenu && (jsx("div", { className: "mt-1 ml-4 space-y-1", children: [
983
- { code: "th", label: "ไทย" },
984
- { code: "en", label: "English" },
985
- { code: "zh", label: "中文" },
986
- { code: "ja", label: "日本語" },
987
- ].map(({ code, label }) => (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: [jsx("span", { className: "flex-1 text-left", children: label }), organization?.language === code && (jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, code))) }))] })), profileHref && (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: [jsx(User, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E2A\u0E48\u0E27\u0E19\u0E15\u0E31\u0E27" })] })), showSelectOrganization && (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: [jsx(Building2, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E25\u0E37\u0E2D\u0E01\u0E18\u0E38\u0E23\u0E01\u0E34\u0E08" })] })), visibleSystemLinks.length > 0 && (jsxs("div", { className: "relative", children: [jsxs("button", { onClick: () => {
988
- setShowAppsMenu(!showAppsMenu);
989
- setShowThemeMenu(false);
990
- setShowLanguageMenu(false);
991
- }, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsx(LayoutGrid, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E41\u0E2D\u0E1B\u0E1E\u0E25\u0E34\u0E40\u0E04\u0E0A\u0E31\u0E19" }), jsx(ChevronUp, { className: `h-4 w-4 transition-transform ${showAppsMenu ? "rotate-180" : ""}` })] }), showAppsMenu && (jsx("div", { className: "mt-1 ml-4 space-y-1", children: visibleSystemLinks.map(link => (jsxs("button", { type: "button", onClick: () => handleSystemLink(link.href), className: "flex w-full items-center gap-3 px-3 py-2 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [link.icon ?? jsx(ExternalLink, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: link.label }), link.active && (jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, `${link.label}:${link.href}`))) }))] })), helpAvailable && (jsxs("button", { type: "button", onClick: () => {
992
- setIsOpen(false);
993
- openHelp();
994
- }, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsx(CircleHelp, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D" })] })), jsx("button", { onClick: handleSignOut, disabled: isSigningOut, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed mt-1", children: isSigningOut ? (jsxs(Fragment$1, { children: [jsx(Loader2, { className: "h-4 w-4 animate-spin" }), jsx("span", { className: "flex-1 text-left", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E23\u0E30\u0E1A\u0E1A..." })] })) : (jsxs(Fragment$1, { children: [jsx(LogOut, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E23\u0E30\u0E1A\u0E1A" })] })) })] })] })) : null;
995
- if (variant === "workspace") {
996
- return (jsx("div", { className: "mt-3.5 border-t border-sidebar-border pt-3", children: jsxs("div", { className: "relative", children: [jsxs("button", { ref: buttonRef, onClick: () => setIsOpen(!isOpen), "aria-expanded": isOpen, className: "flex w-full items-center gap-[11px] rounded-lg px-3 py-1.5 text-left transition-colors hover:bg-sidebar-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring", children: [jsxs(Avatar, { className: "size-[34px] shrink-0 rounded-full border-[3px] border-sidebar-accent", children: [user.avatar ? (jsx(AvatarImage, { src: user.avatar, alt: user.name })) : null, jsx(AvatarFallback, { className: "rounded-full bg-sidebar-accent text-sm font-medium text-sidebar-accent-foreground", children: getInitials(user.name) })] }), jsxs("span", { className: "grid min-w-0 flex-1 leading-tight", children: [jsx("strong", { className: "truncate text-[13px] font-semibold", title: user.name, children: user.name }), jsx("small", { className: "truncate text-[11px] text-muted-foreground", title: user.email, children: user.email })] }), jsx(ChevronsUpDown, { className: "size-3.5 shrink-0 text-muted-foreground" })] }), typeof window !== "undefined" &&
997
- createPortal(dropdownContent, document.body)] }) }));
998
- }
999
- return (jsx("div", { className: "p-2", children: jsxs("div", { className: "relative", children: [jsxs("button", { ref: buttonRef, onClick: () => setIsOpen(!isOpen), className: "flex w-full items-center gap-2 p-2 rounded-md transition-colors", children: [jsxs(Avatar, { className: "h-8 w-8 rounded-lg", children: [user.avatar ? (jsx(AvatarImage, { src: user.avatar, alt: user.name })) : null, jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground w-8 h-8", children: getInitials(user.name) })] }), jsxs("div", { className: "grid flex-1 text-left text-sm leading-tight", children: [jsx("span", { className: "truncate font-medium", title: user.name, children: user.name }), jsx("span", { className: "truncate text-xs text-sidebar-foreground/70", title: user.email, children: user.email })] }), jsx(ChevronUp, { className: `ml-auto size-4 transition-transform ${isOpen ? "rotate-180" : ""}` })] }), typeof window !== "undefined" &&
1000
- createPortal(dropdownContent, document.body)] }) }));
1001
- }
1002
-
1003
888
  /**
1004
- * Plan feature keys (billable entitlements). Mirror of platform-apis plan.go.
889
+ * The caller's roles in the current organisation, as platform-api answers
890
+ * GET /organizations/:id/my-roles, and how the user card words them.
1005
891
  *
1006
- * This lives in the shell because every app gates on the same keys, and five
1007
- * private copies of the same list drift: the app-switcher list did exactly
1008
- * that before it moved here.
892
+ * No runtime imports: the tests run this file in Node as it is.
1009
893
  */
1010
- const PLAN_FEATURES = {
1011
- ORG_CHART: "org_chart",
1012
- APPROVAL_WORKFLOW: "approval_workflow",
1013
- API_ACCESS: "api_access",
1014
- SSO: "sso",
1015
- BASIC_ACCOUNTING_HR: "basic_accounting_hr",
1016
- MODULE_PLATFORM: "module.platform",
1017
- MODULE_HR: "module.hr",
1018
- MODULE_ACCOUNTING: "module.accounting",
1019
- MODULE_SALES: "module.sales",
1020
- MODULE_PURCHASING: "module.purchasing",
1021
- MODULE_INVENTORY: "module.inventory",
1022
- MODULE_PROJECT: "module.project",
1023
- MODULE_SECRETARIAT: "module.secretariat",
1024
- /** AI pack (paid or trial): AI calls on AntNiti's key while credits remain. */
1025
- AI: "ai",
1026
- };
1027
- /** Keys the Accounx add-on carries with it (same alias as org_effective_features). */
1028
- const ACCOUNTING_SUB_KEYS = [
1029
- PLAN_FEATURES.MODULE_SALES,
1030
- PLAN_FEATURES.MODULE_PURCHASING,
1031
- PLAN_FEATURES.MODULE_SECRETARIAT,
1032
- ];
894
+ /** The owner's chip. Ownership is a flag, not a role. */
895
+ const OWNER_CHIP = "เจ้าของ";
896
+ /** How many role names the card spells out before counting the rest. */
897
+ const CARD_ROLE_LIMIT = 2;
898
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
1033
899
  /**
1034
- * True when plan features include key. Aliases, mirroring the backend:
1035
- * legacy basic_accounting_hr → module.hr + module.accounting (+ its sub-keys);
1036
- * module.accounting → module.sales, module.purchasing, module.secretariat.
900
+ * The answer, checked. Anything that is not the shape the card needs — an
901
+ * older API, a proxy's error page, another organisation's answer — is null,
902
+ * and the card shows no role line.
1037
903
  */
1038
- function hasPlanFeature(features, feature) {
1039
- if (!features?.length)
1040
- return false;
1041
- if (features.includes(feature))
1042
- return true;
1043
- if ((feature === PLAN_FEATURES.MODULE_HR ||
1044
- feature === PLAN_FEATURES.MODULE_ACCOUNTING ||
1045
- ACCOUNTING_SUB_KEYS.includes(feature)) &&
1046
- features.includes(PLAN_FEATURES.BASIC_ACCOUNTING_HR)) {
1047
- return true;
1048
- }
1049
- if (ACCOUNTING_SUB_KEYS.includes(feature) &&
1050
- features.includes(PLAN_FEATURES.MODULE_ACCOUNTING)) {
1051
- return true;
904
+ function parseMyRoles(payload, organizationId) {
905
+ if (!isRecord(payload))
906
+ return null;
907
+ // Envelope { success, data } or the bare data.
908
+ const data = isRecord(payload.data) ? payload.data : payload;
909
+ if (!Array.isArray(data.roles))
910
+ return null;
911
+ if (typeof data.organization_id === "string" && data.organization_id !== organizationId) {
912
+ return null;
1052
913
  }
1053
- if (feature === PLAN_FEATURES.BASIC_ACCOUNTING_HR &&
1054
- features.includes(PLAN_FEATURES.MODULE_HR) &&
1055
- features.includes(PLAN_FEATURES.MODULE_ACCOUNTING)) {
1056
- return true;
914
+ const roles = [];
915
+ const seen = new Set();
916
+ for (const r of data.roles) {
917
+ if (!isRecord(r))
918
+ continue;
919
+ const name = typeof r.name_th === "string" ? r.name_th.trim() : "";
920
+ const key = typeof r.key === "string" ? r.key : "";
921
+ // A role with no name is left out, never shown by its key.
922
+ if (!name || seen.has(name))
923
+ continue;
924
+ seen.add(name);
925
+ roles.push({
926
+ key,
927
+ name_th: name,
928
+ app: typeof r.app === "string" ? r.app : "",
929
+ kind: typeof r.kind === "string" ? r.kind : "",
930
+ });
1057
931
  }
1058
- return false;
932
+ return { organizationId, isOwner: data.is_owner === true, roles };
1059
933
  }
1060
-
1061
- function normaliseBase(value, fallback) {
1062
- return (value ?? fallback).replace(/\/$/, "");
934
+ /** The card names the first two roles and counts the rest. */
935
+ function cardRoleLine(roles, limit = CARD_ROLE_LIMIT) {
936
+ const names = roles.map(r => r.name_th);
937
+ return { shown: names.slice(0, limit), more: Math.max(0, names.length - limit) };
1063
938
  }
1064
- /**
1065
- * One list, in display order, for the whole suite.
1066
- *
1067
- * It used to be copied into every app's own nav-user.tsx, which is how Invenx
1068
- * shipped without a link to Plannex: five copies, one of them edited by hand.
1069
- * Adding the sixth app is now a change here plus a version bump.
1070
- */
1071
- const APPS = [
1072
- {
1073
- id: "platform",
1074
- label: "แพลตฟอร์ม",
1075
- icon: jsx(Building2, { className: "h-4 w-4 shrink-0" }),
1076
- features: null,
1077
- baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_PLATFORM_URL, "https://platform.antniti.com"),
1078
- },
1079
- {
1080
- id: "accounx",
1081
- label: "Accounx",
1082
- icon: jsx(Calculator, { className: "h-4 w-4 shrink-0" }),
1083
- features: [PLAN_FEATURES.MODULE_ACCOUNTING],
1084
- baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_ACCOUNTING_URL, "https://accounx.antniti.com"),
1085
- },
1086
- {
1087
- id: "invenx",
1088
- label: "Invenx",
1089
- icon: jsx(Warehouse, { className: "h-4 w-4 shrink-0" }),
1090
- features: [PLAN_FEATURES.MODULE_INVENTORY],
1091
- baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_INVENX_URL, "https://invenx.antniti.com"),
1092
- },
1093
- {
1094
- id: "plannex",
1095
- label: "Plannex",
1096
- icon: jsx(FolderKanban, { className: "h-4 w-4 shrink-0" }),
1097
- // Tasks, day-logs and work records are free with HR; projects need the add-on.
1098
- features: [PLAN_FEATURES.MODULE_HR, PLAN_FEATURES.MODULE_PROJECT],
1099
- baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_PLANNEX_URL, "https://plannex.antniti.com"),
1100
- },
1101
- {
1102
- id: "peoplx",
1103
- label: "Peoplx",
1104
- icon: jsx(Users, { className: "h-4 w-4 shrink-0" }),
1105
- features: [PLAN_FEATURES.MODULE_HR],
1106
- baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_HR_URL, "https://peoplx.antniti.com"),
1107
- },
1108
- ];
1109
- /**
1110
- * Builds the cross-app link list for <NavUser systemLinks>.
1111
- *
1112
- * The current app is always present even when the org's plan no longer covers
1113
- * it — the user is looking at it, so hiding the link would only make the menu
1114
- * disagree with the screen.
1115
- */
1116
- function buildSystemLinks({ orgCode, activeApp, features, }) {
1117
- // The app names itself here; the help dialog says which app a message
1118
- // came from by it.
1119
- declareHelpApp(activeApp);
1120
- if (!orgCode)
1121
- return [];
1122
- return APPS.filter(app => app.id === activeApp ||
1123
- app.features === null ||
1124
- app.features.some(key => hasPlanFeature(features, key))).map(app => ({
1125
- label: app.label,
1126
- icon: app.icon,
1127
- // Same-app link stays relative so it never leaves the current origin.
1128
- href: app.id === activeApp ? `/${orgCode}` : `${app.baseUrl()}/${orgCode}`,
1129
- ...(app.id === activeApp ? { active: true } : {}),
1130
- }));
939
+ /** Everything, for the tooltip: "เจ้าของ · ผู้ดูแลสูงสุด · พนักงานทั่วไป". */
940
+ function roleSummary(my) {
941
+ return [...(my.isOwner ? [OWNER_CHIP] : []), ...my.roles.map(r => r.name_th)].join(" · ");
942
+ }
943
+ /** Whether there is anything to show at all. */
944
+ function hasRoleLine(my) {
945
+ return !!my && (my.isOwner || my.roles.length > 0);
1131
946
  }
1132
947
 
1133
- // Helper function to check if pathname matches a URL pattern
1134
- // This prevents false matches like /reports/sales matching /sales
1135
- // Handles paths with or without [code] prefix
1136
- function isPathActive(pathname, url) {
1137
- // Exact match
1138
- if (pathname === url) {
1139
- return true;
948
+ const TOKEN_TTL_MS = 30000;
949
+ let cachedToken = null;
950
+ let cachedAt = 0;
951
+ let inFlightTokenPromise = null;
952
+ const readSessionToken = async () => {
953
+ const response = await fetch("/api/auth/session", {
954
+ credentials: "include",
955
+ cache: "no-store",
956
+ });
957
+ if (!response.ok)
958
+ return null;
959
+ const session = (await response.json());
960
+ return session.accessToken ?? null;
961
+ };
962
+ const getAccessToken = async (forceRefresh = false) => {
963
+ const now = Date.now();
964
+ if (!forceRefresh && cachedToken && now - cachedAt < TOKEN_TTL_MS) {
965
+ return cachedToken;
1140
966
  }
1141
- // Check if pathname starts with url + "/" to ensure exact segment matching
1142
- // This prevents /reports/sales from matching /sales
1143
- // Example: /org123/reports/sales should NOT match /org123/sales
1144
- // But /org123/sales/overview SHOULD match /org123/sales
1145
- if (pathname.startsWith(url + "/")) {
1146
- return true;
967
+ if (!inFlightTokenPromise) {
968
+ inFlightTokenPromise = readSessionToken()
969
+ .then(token => {
970
+ cachedToken = token;
971
+ cachedAt = Date.now();
972
+ return token;
973
+ })
974
+ .finally(() => {
975
+ inFlightTokenPromise = null;
976
+ });
1147
977
  }
1148
- return false;
1149
- }
978
+ return inFlightTokenPromise;
979
+ };
980
+ const clearAccessTokenCache = () => {
981
+ cachedToken = null;
982
+ cachedAt = 0;
983
+ };
1150
984
 
1151
- /** "/O1/" and "/O1" are the same page; a query string never is. */
1152
- function samePage(pathname, url) {
1153
- if (url.includes("?"))
1154
- return false;
1155
- const strip = (p) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
1156
- return strip(pathname) === strip(url);
1157
- }
1158
- function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, chrome = "brand", highlightActiveModule = false, }) {
1159
- const workspace = chrome === "workspace";
1160
- const params = useParams();
1161
- const pathname = usePathname();
1162
- const { setOpen } = useSidebar();
1163
- const handleNavLinkClick = () => {
1164
- if (closeOnNavigation)
1165
- setOpen(false);
1166
- };
1167
- const code = orgCode ?? params?.code;
1168
- // Which module (and sub-item) the current page belongs to.
1169
- const states = items.map(item => {
1170
- const itemUrl = buildUrlWithCode(code, item.url);
1171
- // Most specific (longest) matching sub-item URL so only one link is active.
1172
- const activeSubItemUrl = item.subModules?.reduce((best, subItem) => {
1173
- const subItemUrl = buildUrlWithCode(code, subItem.url);
1174
- const matches = isPathActive(pathname, subItemUrl) ||
1175
- isPathActive(pathname, subItem.url);
1176
- const subPageMatches = subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
1177
- isPathActive(pathname, subPage.url));
1178
- if (!matches && !subPageMatches)
1179
- return best;
1180
- const candidateUrl = subItemUrl;
1181
- if (!best)
1182
- return candidateUrl;
1183
- return candidateUrl.length > best.length ? candidateUrl : best;
1184
- }, null) ?? null;
1185
- const hasActiveSubItem = !!activeSubItemUrl ||
1186
- !!item.subModules?.some(subItem => subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
1187
- isPathActive(pathname, subPage.url)));
1188
- // Parent only active when path matches its URL exactly AND no child
1189
- // is active (so parent+child don't both light up).
1190
- const isItemActive = (highlightActiveModule && !item.subModules?.length && !!item.isActive) ||
1191
- ((workspace ? samePage(pathname, itemUrl) : pathname === itemUrl) &&
1192
- !hasActiveSubItem);
1193
- return { itemUrl, activeSubItemUrl, hasActiveSubItem, isItemActive };
1194
- });
1195
- // A group opens when its page becomes current and otherwise stays as the
1196
- // user left it. defaultOpen alone decided once, on the first render — and
1197
- // on a direct load the sub-items arrive after permissions do, so the group
1198
- // holding the current page stayed shut.
1199
- const activeKey = items
1200
- .filter((_, i) => states[i].isItemActive || states[i].hasActiveSubItem)
1201
- .map(item => item.id)
1202
- .join(",");
1203
- const [openIds, setOpenIds] = React.useState(() => new Set(activeKey ? activeKey.split(",") : []));
1204
- React.useEffect(() => {
1205
- if (!activeKey)
1206
- return;
1207
- setOpenIds(prev => {
1208
- const ids = activeKey.split(",");
1209
- if (ids.every(id => prev.has(id)))
1210
- return prev;
1211
- const next = new Set(prev);
1212
- ids.forEach(id => next.add(id));
1213
- return next;
985
+ class ApiError extends Error {
986
+ constructor(message, options) {
987
+ super(message);
988
+ Object.defineProperty(this, "status", {
989
+ enumerable: true,
990
+ configurable: true,
991
+ writable: true,
992
+ value: void 0
1214
993
  });
1215
- }, [activeKey]);
1216
- const setItemOpen = (id, open) => setOpenIds(prev => {
1217
- if (prev.has(id) === open)
1218
- return prev;
1219
- const next = new Set(prev);
1220
- if (open)
1221
- next.add(id);
1222
- else
1223
- next.delete(id);
1224
- return next;
1225
- });
1226
- return (jsx(SidebarGroup, { "data-sidebar": true, className: workspace ? "p-0" : undefined, children: jsx(SidebarMenu, { className: workspace ? "gap-[3px]" : undefined, children: items.map((item, index) => {
1227
- const groupHeading = workspace &&
1228
- item.group &&
1229
- item.group !== items[index - 1]?.group ? (jsx("li", { className: "list-none px-3 pb-2.5 text-xs text-muted-foreground" +
1230
- (index > 0 ? " pt-5" : ""), children: item.group }, `group-${item.id}`)) : null;
1231
- const { itemUrl, activeSubItemUrl, isItemActive } = states[index];
1232
- const IconComp = item.icon;
1233
- const workspaceButton = workspace
1234
- ? "h-auto gap-3 rounded-[10px] px-3 py-2.5 text-[13px] " +
1235
- (isItemActive
1236
- ? "bg-sidebar-accent font-bold text-sidebar-accent-foreground"
1237
- : "font-medium text-sidebar-foreground/70")
1238
- : "";
1239
- const icon = workspace && IconComp ? (jsx(IconComp, { className: "size-[18px] shrink-0" })) : null;
1240
- const badge = workspace && item.badge !== undefined && item.badge !== "" ? (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;
1241
- return (jsxs(React.Fragment, { children: [groupHeading, jsx(Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: open => setItemOpen(item.id, open), className: "group/collapsible", children: jsxs(SidebarMenuItem, { children: [jsx(CollapsibleTrigger, { asChild: true, children: jsx(SidebarMenuButton, { asChild: !item.subModules || item.subModules.length === 0, isActive: isItemActive, className: "data-[state=open]:text-sidebar-accent-foreground " +
1242
- workspaceButton, children: item.subModules && item.subModules.length > 0 ? (jsxs(Fragment$1, { children: [icon, jsx("span", { id: item.id, children: item.titleTh }), jsx(ChevronRight, { className: "ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" })] })) : (jsxs(Link, { href: itemUrl, prefetch: false, className: "flex items-center w-full min-w-0 flex-1 " +
1243
- (workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, children: [icon, jsx("span", { id: item.id, className: "flex-1 truncate", title: item.titleTh, children: item.titleTh }), badge] })) }) }), item.subModules && item.subModules.length > 0 && (jsx(CollapsibleContent, { children: jsx(SidebarMenuSub, { className: workspace
1244
- ? "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]"
1245
- : undefined, children: item.subModules.map(subItem => {
1246
- const subItemUrl = buildUrlWithCode(code, subItem.url);
1247
- const isSubItemActive = subItemUrl === activeSubItemUrl;
1248
- const SubIcon = subItem.icon;
1249
- const approvalCount = approvalCounts && subItem.approvalCountKey
1250
- ? (approvalCounts[subItem.approvalCountKey] ?? 0)
1251
- : 0;
1252
- if (subItem.isCollapsible &&
1253
- subItem.subPages &&
1254
- subItem.subPages.length > 0) {
1255
- const hasActiveSubPage = subItem.subPages.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)));
1256
- return (jsx(Collapsible, { asChild: true, defaultOpen: isSubItemActive || hasActiveSubPage, className: "group/sub-collapsible", children: jsxs(SidebarMenuSubItem, { children: [jsx(CollapsibleTrigger, { asChild: true, children: jsxs(SidebarMenuSubButton, { className: "w-full", isActive: isSubItemActive || hasActiveSubPage, children: [SubIcon && (jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), jsx(ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsx(CollapsibleContent, { children: jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map(subPage => {
1257
- const subPageUrl = buildUrlWithCode(code, subPage.url);
1258
- const isSubPageActive = isPathActive(pathname, subPageUrl);
1259
- const SubPageIcon = subPage.icon;
1260
- return (jsx(SidebarMenuSubItem, { children: jsx(SidebarMenuSubButton, { asChild: true, className: `font-thai w-full text-sm ${isSubPageActive
1261
- ? "text-sidebar-accent-foreground font-medium"
1262
- : "text-sidebar-foreground"}`, isActive: false, children: jsxs(Link, { href: subPageUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubPageIcon && (jsx(SubPageIcon, { className: "sidebar-subpage-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", title: subPage.titleTh, children: subPage.titleTh })] }) }) }, subPage.id));
1263
- }) }) })] }) }, subItem.id));
1264
- }
1265
- return (jsx(SidebarMenuSubItem, { children: jsx(SidebarMenuSubButton, { asChild: true, className: "w-full" +
1266
- (workspace && isSubItemActive
1267
- ? " font-semibold"
1268
- : ""), isActive: isSubItemActive, children: jsxs(Link, { href: subItemUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubIcon && (jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), approvalCount > 0 && (jsx(Badge, { variant: "destructive", className: "ml-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums", children: approvalCount > 99
1269
- ? "99+"
1270
- : approvalCount }))] }) }) }, subItem.id));
1271
- }) }) }))] }) })] }, item.id));
1272
- }) }) }));
994
+ Object.defineProperty(this, "code", {
995
+ enumerable: true,
996
+ configurable: true,
997
+ writable: true,
998
+ value: void 0
999
+ });
1000
+ Object.defineProperty(this, "field", {
1001
+ enumerable: true,
1002
+ configurable: true,
1003
+ writable: true,
1004
+ value: void 0
1005
+ });
1006
+ Object.defineProperty(this, "details", {
1007
+ enumerable: true,
1008
+ configurable: true,
1009
+ writable: true,
1010
+ value: void 0
1011
+ });
1012
+ this.name = "ApiError";
1013
+ this.status = options?.status;
1014
+ this.code = options?.code;
1015
+ this.field = options?.field;
1016
+ this.details = options?.details;
1017
+ }
1273
1018
  }
1019
+ const toApiError = (error) => {
1020
+ if (error instanceof ApiError)
1021
+ return error;
1022
+ if (isAxiosError(error)) {
1023
+ const axiosErr = error;
1024
+ const message = axiosErr.response?.data?.message ||
1025
+ axiosErr.response?.data?.error ||
1026
+ axiosErr.message ||
1027
+ "Request failed";
1028
+ return new ApiError(message, {
1029
+ status: axiosErr.response?.status,
1030
+ code: axiosErr.response?.data?.code,
1031
+ field: axiosErr.response?.data?.field,
1032
+ details: axiosErr.response?.data?.details,
1033
+ });
1034
+ }
1035
+ if (error instanceof Error) {
1036
+ return new ApiError(error.message);
1037
+ }
1038
+ return new ApiError(String(error));
1039
+ };
1274
1040
 
1275
- function OrgMark({ logoUrl, size = "md", }) {
1276
- const [failed, setFailed] = useState(false);
1277
- const box = size === "sm"
1278
- ? "flex size-6 items-center justify-center rounded-md border overflow-hidden bg-background"
1279
- : size === "header"
1280
- ? "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"
1281
- : "flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground overflow-hidden";
1282
- const icon = size === "sm" ? "size-3.5 shrink-0" : "size-4";
1283
- if (logoUrl && !failed) {
1284
- return (jsx("div", { className: cn(box, size === "md" && "bg-white"), children: jsx("img", { src: logoUrl, alt: "", className: "size-full object-contain p-0.5", onError: () => setFailed(true) }) }));
1041
+ const isPlainObject = (value) => Object.prototype.toString.call(value) === "[object Object]";
1042
+ const visit = (value, cb) => {
1043
+ if (Array.isArray(value)) {
1044
+ value.forEach(item => visit(item, cb));
1045
+ return;
1285
1046
  }
1286
- return (jsx("div", { className: box, children: jsx(Building2, { className: icon }) }));
1287
- }
1288
- function OrganizationSwitcher({ activeOrganization, isContextLoading = false, organizations, isLoadingOrgs = false, setOrganizationCookie, registerOrganizationHref = "/register-organization", placement = "sidebar", className, }) {
1289
- const { isMobile } = useSidebarToggle();
1290
- const router = useRouter();
1291
- const params = useParams();
1292
- const [isPending, startTransition] = useTransition();
1293
- const currentCode = params?.code
1294
- ? Array.isArray(params.code)
1295
- ? params.code[0]
1296
- : params.code
1297
- : undefined;
1298
- const handleOrganizationChange = (org) => {
1299
- startTransition(async () => {
1300
- const orgCode = org.code || String(org.id);
1301
- // Write storage + cookie before navigation — Layout will re-hydrate
1302
- // Context on the new URL.
1303
- try {
1304
- setOrganizationId(String(org.id));
1305
- await setOrganizationCookie(org.id);
1306
- }
1307
- catch {
1308
- // ignore: navigation will re-attempt context resolution
1309
- }
1310
- if (currentCode) {
1311
- const currentPath = window.location.pathname;
1312
- const parts = currentPath.split("/");
1313
- const decodedParts = parts.map(p => decodeURIComponent(p));
1314
- const codeIndex = decodedParts.indexOf(currentCode);
1315
- if (codeIndex !== -1) {
1316
- parts[codeIndex] = orgCode;
1317
- router.push(parts.join("/"));
1318
- }
1319
- else {
1320
- router.push(`/${orgCode}`);
1321
- }
1047
+ if (!isPlainObject(value)) {
1048
+ return;
1049
+ }
1050
+ cb(value);
1051
+ Object.values(value).forEach(item => visit(item, cb));
1052
+ };
1053
+ const validateGoodsReceiptChecks = (data) => {
1054
+ let invalidPath = null;
1055
+ const isCheckStatus = (v) => v === undefined || v === null || v === "pass" || v === "fail";
1056
+ const isCompareStatus = (v) => v === undefined || v === null || v === "match" || v === "mismatch";
1057
+ visit(data, node => {
1058
+ if (invalidPath)
1059
+ return;
1060
+ if ("quantity_check" in node && !isCheckStatus(node.quantity_check)) {
1061
+ invalidPath = "quantity_check";
1062
+ return;
1063
+ }
1064
+ if ("quality_check" in node && !isCheckStatus(node.quality_check)) {
1065
+ invalidPath = "quality_check";
1066
+ return;
1067
+ }
1068
+ if ("compare_with_reference" in node &&
1069
+ !isCompareStatus(node.compare_with_reference)) {
1070
+ invalidPath = "compare_with_reference";
1071
+ }
1072
+ });
1073
+ if (invalidPath) {
1074
+ return {
1075
+ valid: false,
1076
+ message: "รูปแบบข้อมูลไม่ถูกต้อง: ฟิลด์ตรวจรับต้องเป็น enum string (quantity/quality: pass|fail, compare: match|mismatch)",
1077
+ };
1078
+ }
1079
+ return { valid: true };
1080
+ };
1081
+ const runGlobalRequestPrecheck = (config) => {
1082
+ const method = (config.method || "get").toLowerCase();
1083
+ const isWriteMethod = ["post", "put", "patch"].includes(method);
1084
+ if (!isWriteMethod || config.data === undefined || config.data === null) {
1085
+ return { valid: true };
1086
+ }
1087
+ if (typeof FormData !== "undefined" && config.data instanceof FormData) {
1088
+ return { valid: true };
1089
+ }
1090
+ return validateGoodsReceiptChecks(config.data);
1091
+ };
1092
+
1093
+ const isBrowser = () => typeof window !== "undefined";
1094
+ const isAuthEndpoint = (url) => !!url && url.includes("/api/auth/");
1095
+ const isAuthPage = () => isBrowser() && window.location.pathname.startsWith("/auth/");
1096
+ const getOrgFromCookie = () => {
1097
+ if (!isBrowser())
1098
+ return null;
1099
+ const cookies = document.cookie.split(";").map(cookie => cookie.trim());
1100
+ const orgCookie = cookies.find(cookie => cookie.startsWith("erp.organizationId="));
1101
+ if (!orgCookie)
1102
+ return null;
1103
+ const raw = orgCookie.substring("erp.organizationId=".length);
1104
+ return raw ? decodeURIComponent(raw) : null;
1105
+ };
1106
+ const redirectToLogout = async () => {
1107
+ if (!isBrowser() || isAuthPage())
1108
+ return;
1109
+ localStorage.clear();
1110
+ sessionStorage.clear();
1111
+ setOrganizationId(null);
1112
+ clearAccessTokenCache();
1113
+ await signOut({ callbackUrl: "/auth/signin" });
1114
+ };
1115
+ const instance = axios.create({
1116
+ baseURL: process.env.NEXT_PUBLIC_API_URL,
1117
+ headers: { "Content-Type": "application/json" },
1118
+ paramsSerializer: params => {
1119
+ const searchParams = new URLSearchParams();
1120
+ Object.entries(params || {}).forEach(([key, value]) => {
1121
+ if (Array.isArray(value)) {
1122
+ value.forEach(v => searchParams.append(key, String(v)));
1322
1123
  }
1323
- else {
1324
- router.push(`/${orgCode}`);
1124
+ else if (value !== undefined && value !== null) {
1125
+ searchParams.append(key, String(value));
1325
1126
  }
1326
1127
  });
1327
- };
1328
- const isLoading = isLoadingOrgs || isContextLoading;
1329
- if (placement === "header") {
1330
- const face = (interactive) => (jsxs(Fragment$1, { children: [jsx(OrgMark, { logoUrl: activeOrganization?.logoUrl, size: "header" }), jsxs("span", { className: "min-w-0 text-left", children: [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 ? (jsx(Skeleton, { className: "mt-1 h-3.5 w-28" })) : (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 ? (jsx(Loader2, { className: "ml-1 size-3.5 shrink-0 animate-spin text-[var(--header-fg-muted,currentColor)]" })) : (jsx(ChevronsUpDown, { className: "ml-1 size-3.5 shrink-0 text-[var(--header-fg-muted,currentColor)]" }))) : null] }));
1331
- const faceClass = cn("flex items-center gap-2.5 rounded-lg py-1.5 max-sm:gap-2", className);
1332
- if (isLoading || organizations.length <= 1) {
1333
- return jsx("div", { className: faceClass, children: face(false) });
1128
+ return searchParams.toString();
1129
+ },
1130
+ });
1131
+ instance.interceptors.request.use(async (config) => {
1132
+ if (!config.headers) {
1133
+ config.headers = new AxiosHeaders();
1134
+ }
1135
+ if (config.data instanceof FormData && config.headers) {
1136
+ delete config.headers["Content-Type"];
1137
+ }
1138
+ if (!isBrowser())
1139
+ return config;
1140
+ const token = await getAccessToken(false);
1141
+ if (token) {
1142
+ config.headers.Authorization = `Bearer ${token}`;
1143
+ }
1144
+ // localStorage first, cookie second.
1145
+ //
1146
+ // Both are written by the same state machine, but not at the same moment:
1147
+ // setOrganizationId writes localStorage synchronously and then awaits a
1148
+ // server action for the cookie. Preferring the cookie meant every request
1149
+ // fired during that round trip carried the previous organisation — while
1150
+ // the query params, built from React state, already carried the new one.
1151
+ //
1152
+ // The backend scopes by this header and ignores organization_id in the
1153
+ // query, so those requests asked for organisation A and were handed B, and
1154
+ // the answer landed in the cache under A's key. No amount of cache clearing
1155
+ // reaches that: the entry is wrong, not stale.
1156
+ const organizationId = getOrganizationId() || getOrgFromCookie();
1157
+ if (organizationId) {
1158
+ config.headers["X-Organization-ID"] = organizationId;
1159
+ }
1160
+ const precheck = runGlobalRequestPrecheck(config);
1161
+ if (!precheck.valid) {
1162
+ throw new ApiError(precheck.message || "Invalid request payload");
1163
+ }
1164
+ return config;
1165
+ });
1166
+ let refreshPromise = null;
1167
+ const refreshTokenSingleFlight = async () => {
1168
+ if (!refreshPromise) {
1169
+ refreshPromise = getAccessToken(true).finally(() => {
1170
+ refreshPromise = null;
1171
+ });
1172
+ }
1173
+ return refreshPromise;
1174
+ };
1175
+ instance.interceptors.response.use(response => {
1176
+ // Any successful call means the session can act in this organisation
1177
+ // after all. Without this the gate, once raised, stayed up through a
1178
+ // whole working session — including the seconds between an administrator
1179
+ // adding somebody and the employee record existing.
1180
+ if (typeof window !== "undefined") {
1181
+ window.dispatchEvent(new CustomEvent("erp:employee-record-ok"));
1182
+ }
1183
+ return response;
1184
+ }, async (error) => {
1185
+ const originalRequest = error.config;
1186
+ const requestUrl = originalRequest?.url || error.config?.url;
1187
+ if (error.response?.status === 401 &&
1188
+ originalRequest &&
1189
+ !originalRequest._retry &&
1190
+ !isAuthEndpoint(requestUrl) &&
1191
+ !isAuthPage()) {
1192
+ originalRequest._retry = true;
1193
+ const newToken = await refreshTokenSingleFlight();
1194
+ if (newToken && originalRequest.headers) {
1195
+ originalRequest.headers.Authorization = `Bearer ${newToken}`;
1196
+ return instance(originalRequest);
1334
1197
  }
1335
- return (jsxs(DropdownMenu, { children: [jsx(DropdownMenuTrigger, { className: 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) }), jsxs(DropdownMenuContent, { className: "min-w-64 rounded-lg", align: "end", side: "bottom", sideOffset: 6, children: [jsx(DropdownMenuLabel, { className: "text-xs text-muted-foreground", children: "\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" }), organizations.map(org => (jsxs(DropdownMenuItem, { onClick: () => handleOrganizationChange(org), disabled: isPending, className: cn("gap-2 p-2 cursor-pointer", String(activeOrganization?.id) === String(org.id) && "bg-accent"), children: [jsx(OrgMark, { logoUrl: org.logoUrl, size: "sm" }), jsx("span", { className: "truncate", children: org.name })] }, org.id))), jsx(DropdownMenuSeparator, {}), jsxs(DropdownMenuItem, { className: "gap-2 p-2 cursor-pointer", onClick: () => router.push(registerOrganizationHref), children: [jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-transparent", children: jsx(Plus, { className: "size-4" }) }), jsx("span", { className: "text-muted-foreground font-medium", children: "\u0E40\u0E1E\u0E34\u0E48\u0E21\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" })] })] })] }));
1198
+ await redirectToLogout();
1199
+ return Promise.reject(toApiError(error));
1336
1200
  }
1337
- if (isLoading) {
1338
- return (jsx(SidebarMenu, { className: className, children: jsx(SidebarMenuItem, { children: jsxs("div", { className: "flex items-center gap-2 p-2", children: [jsx(Skeleton, { className: "size-8 rounded-lg" }), jsx(Skeleton, { className: "h-4 w-24" })] }) }) }));
1201
+ // The server refuses every organisation-scoped call when the account has
1202
+ // no employee record. Announce it once so a gate can explain it, instead
1203
+ // of letting each failed query raise its own toast.
1204
+ if (error.response?.status === 403) {
1205
+ const body = error.response.data;
1206
+ if (body?.code === "EMPLOYEE_RECORD_REQUIRED" && typeof window !== "undefined") {
1207
+ window.dispatchEvent(new CustomEvent("erp:employee-record-required"));
1208
+ }
1339
1209
  }
1340
- if (organizations.length <= 1) {
1341
- return (jsx(SidebarMenu, { className: className, children: jsx(SidebarMenuItem, { children: jsxs("div", { className: "flex items-center gap-2 p-2", children: [jsx(OrgMark, { logoUrl: activeOrganization?.logoUrl }), jsx("span", { className: "truncate font-semibold text-sm min-w-0 flex-1", children: activeOrganization?.name || "ไม่มีองค์กร" })] }) }) }));
1210
+ return Promise.reject(toApiError(error));
1211
+ });
1212
+
1213
+ /**
1214
+ * Where platform-api is, for the calls the shell itself makes to it (the
1215
+ * help channel, the user card's roles). Every app already calls it with the
1216
+ * user's token and the organisation header, and its CORS admits every app.
1217
+ */
1218
+ const DEFAULT_PLATFORM_API_URL = "https://platform-api.antniti.com/api/v1";
1219
+ function platformApiBase(app) {
1220
+ const explicit = process.env.NEXT_PUBLIC_PLATFORM_API_URL?.trim();
1221
+ if (explicit)
1222
+ return explicit.replace(/\/$/, "");
1223
+ // Platform's own API is platform-api.
1224
+ if (app === "platform") {
1225
+ const own = process.env.NEXT_PUBLIC_API_URL?.trim();
1226
+ if (own)
1227
+ return own.replace(/\/$/, "");
1342
1228
  }
1343
- return (jsx(SidebarMenu, { className: className, children: jsx(SidebarMenuItem, { children: jsxs(DropdownMenu, { children: [jsx(DropdownMenuTrigger, { asChild: true, children: jsxs(SidebarMenuButton, { size: "lg", className: "data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground", children: [jsx(OrgMark, { logoUrl: activeOrganization?.logoUrl }), jsx("span", { className: "truncate font-semibold text-sm flex-1 min-w-0 text-left", children: activeOrganization?.name || "เลือกองค์กร" }), isPending ? (jsx(Loader2, { className: "ml-auto size-4 animate-spin" })) : (jsx(ChevronsUpDown, { className: "ml-auto size-4" }))] }) }), jsxs(DropdownMenuContent, { className: "w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg", align: "start", side: isMobile ? "bottom" : "right", sideOffset: 4, children: [jsx(DropdownMenuLabel, { className: "text-xs text-muted-foreground", children: "\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" }), organizations.map(org => (jsxs(DropdownMenuItem, { onClick: () => handleOrganizationChange(org), disabled: isPending, className: cn("gap-2 p-2 cursor-pointer", String(activeOrganization?.id) === String(org.id) &&
1344
- "bg-accent"), children: [jsx(OrgMark, { logoUrl: org.logoUrl, size: "sm" }), jsx("span", { className: "truncate", children: org.name })] }, org.id))), jsx(DropdownMenuSeparator, {}), jsxs(DropdownMenuItem, { className: "gap-2 p-2 cursor-pointer", onClick: () => router.push(registerOrganizationHref), children: [jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-transparent", children: jsx(Plus, { className: "size-4" }) }), jsx("span", { className: "text-muted-foreground font-medium", children: "\u0E40\u0E1E\u0E34\u0E48\u0E21\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" })] })] })] }) }) }));
1229
+ return DEFAULT_PLATFORM_API_URL;
1345
1230
  }
1346
1231
 
1347
- function SidebarHeader({ children, className }) {
1348
- return jsx("div", { className: cn("px-2 py-3", className), children: children });
1349
- }
1232
+ /**
1233
+ * This package's version, attached to help messages so the back office can
1234
+ * tell which shell a report came from. Keep in step with package.json.
1235
+ */
1236
+ const SHELL_VERSION = "0.1.53";
1350
1237
 
1351
- function AppSidebar({ modules, tools, activeModule, orgCode, approvalCounts, closeOnNavigation = false, highlightActiveModule = false, header, footer, bottom, chrome = "brand", className, }) {
1352
- const { open } = useSidebar();
1353
- const modulesWithActiveState = React.useMemo(() => modules.map(m => ({
1354
- ...m,
1355
- isActive: m.id === activeModule,
1356
- })), [modules, activeModule]);
1357
- if (!open)
1358
- return null;
1359
- if (chrome === "workspace") {
1360
- return (jsxs("div", { className: cn("flex h-screen w-[244px] shrink-0 flex-col overflow-hidden border-r border-sidebar-border bg-sidebar text-sidebar-foreground",
1361
- // As a drawer (≤1024px) it floats over the page and needs the lift.
1362
- "max-[1024px]:shadow-2xl", className), children: [header ? jsx("div", { className: "shrink-0 px-[18px] pt-8 pb-6", children: header }) : null, jsxs(SidebarContent, { className: "flex-1 overflow-y-auto px-[18px]", children: [jsx(NavMain, { items: modulesWithActiveState, orgCode: orgCode, approvalCounts: approvalCounts, closeOnNavigation: closeOnNavigation, chrome: "workspace", highlightActiveModule: highlightActiveModule }), jsx(NavTools, { tools: tools, closeOnNavigation: closeOnNavigation })] }), bottom ? jsx("div", { className: "shrink-0 px-[18px] pt-6", children: bottom }) : null, jsx("div", { className: "shrink-0 px-[18px] pb-3", children: footer })] }));
1363
- }
1364
- return (jsxs("div", { className: 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: [jsx(SidebarHeader, { children: header }), jsxs(SidebarContent, { className: "pl-2 pr-2 flex-1 overflow-y-auto", children: [jsx(NavMain, { items: modulesWithActiveState, orgCode: orgCode, approvalCounts: approvalCounts, closeOnNavigation: closeOnNavigation }), jsx(NavTools, { tools: tools, closeOnNavigation: closeOnNavigation })] }), jsx(SidebarFooter, { className: "pl-2 pr-2", children: footer })] }));
1365
- }
1366
-
1367
- /**
1368
- * Header ink as CSS variables. Header-borne pieces — breadcrumb, sidebar
1369
- * trigger, the org switcher, the app's own page title — read these, with
1370
- * white as the fallback, so the brand chrome looks exactly as before.
1371
- */
1372
- const WORKSPACE_HEADER_VARS = {
1373
- "--header-fg": "#fff",
1374
- "--header-fg-muted": "rgb(255 255 255 / 0.78)",
1375
- "--header-hover": "rgb(255 255 255 / 0.12)",
1376
- "--header-title-px": "0px",
1377
- "--header-title-py": "0px",
1378
- };
1379
- const WORKSPACE_LIGHT_HEADER_VARS = {
1380
- "--header-fg": "var(--foreground)",
1381
- "--header-fg-muted": "var(--muted-foreground)",
1382
- "--header-hover": "var(--accent)",
1383
- "--header-mark-bg": "var(--accent)",
1384
- "--header-mark-fg": "var(--primary)",
1385
- "--header-mark-border": "transparent",
1386
- "--header-title-px": "0px",
1387
- "--header-title-py": "0px",
1388
- };
1389
-
1390
- /**
1391
- * The ช่วยเหลือ button in the top bar. Drawn in the header's ink (the
1392
- * --header-* variables of the workspace chrome, white on the brand chrome).
1393
- */
1394
- function HelpButton({ className }) {
1395
- return (jsx("button", { type: "button", onClick: openHelp, "aria-label": "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D", title: "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D", className: "grid size-[34px] shrink-0 place-items-center rounded-lg text-[var(--header-fg-muted,#ffffffc7)] transition-colors hover:bg-[var(--header-hover,#ffffff1f)] hover:text-[var(--header-fg,#ffffff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--header-fg-muted,#ffffffc7)]" +
1396
- (className ? ` ${className}` : ""), children: jsx(CircleHelp, { className: "size-5", strokeWidth: 1.65 }) }));
1397
- }
1398
-
1399
- /**
1400
- * This package's version, attached to help messages so the back office can
1401
- * tell which shell a report came from. Keep in step with package.json.
1402
- */
1403
- const SHELL_VERSION = "0.1.52";
1404
-
1405
- const HELP_APP_LABEL = {
1406
- platform: "แพลตฟอร์ม",
1407
- accounx: "Accounx",
1408
- peoplx: "Peoplx",
1409
- plannex: "Plannex",
1410
- invenx: "Invenx",
1411
- other: "อื่น ๆ",
1412
- };
1413
- function originOf(value) {
1414
- if (!value)
1415
- return null;
1416
- try {
1417
- return new URL(value).origin;
1418
- }
1419
- catch {
1238
+ const HELP_APP_LABEL = {
1239
+ platform: "แพลตฟอร์ม",
1240
+ accounx: "Accounx",
1241
+ peoplx: "Peoplx",
1242
+ plannex: "Plannex",
1243
+ invenx: "Invenx",
1244
+ other: "อื่น ๆ",
1245
+ };
1246
+ function originOf(value) {
1247
+ if (!value)
1248
+ return null;
1249
+ try {
1250
+ return new URL(value).origin;
1251
+ }
1252
+ catch {
1420
1253
  return null;
1421
1254
  }
1422
1255
  }
@@ -1582,270 +1415,632 @@ function describeAttached(app, ctx, email) {
1582
1415
  .join(" · ");
1583
1416
  }
1584
1417
 
1585
- const TOKEN_TTL_MS = 30000;
1586
- let cachedToken = null;
1587
- let cachedAt = 0;
1588
- let inFlightTokenPromise = null;
1589
- const readSessionToken = async () => {
1590
- const response = await fetch("/api/auth/session", {
1591
- credentials: "include",
1592
- cache: "no-store",
1593
- });
1594
- if (!response.ok)
1595
- return null;
1596
- const session = (await response.json());
1597
- return session.accessToken ?? null;
1598
- };
1599
- const getAccessToken = async (forceRefresh = false) => {
1600
- const now = Date.now();
1601
- if (!forceRefresh && cachedToken && now - cachedAt < TOKEN_TTL_MS) {
1602
- return cachedToken;
1603
- }
1604
- if (!inFlightTokenPromise) {
1605
- inFlightTokenPromise = readSessionToken()
1606
- .then(token => {
1607
- cachedToken = token;
1608
- cachedAt = Date.now();
1609
- return token;
1610
- })
1611
- .finally(() => {
1612
- inFlightTokenPromise = null;
1613
- });
1614
- }
1615
- return inFlightTokenPromise;
1616
- };
1617
- const clearAccessTokenCache = () => {
1618
- cachedToken = null;
1619
- cachedAt = 0;
1620
- };
1621
-
1622
- class ApiError extends Error {
1623
- constructor(message, options) {
1624
- super(message);
1625
- Object.defineProperty(this, "status", {
1626
- enumerable: true,
1627
- configurable: true,
1628
- writable: true,
1629
- value: void 0
1630
- });
1631
- Object.defineProperty(this, "code", {
1632
- enumerable: true,
1633
- configurable: true,
1634
- writable: true,
1635
- value: void 0
1636
- });
1637
- Object.defineProperty(this, "field", {
1638
- enumerable: true,
1639
- configurable: true,
1640
- writable: true,
1641
- value: void 0
1642
- });
1643
- Object.defineProperty(this, "details", {
1644
- enumerable: true,
1645
- configurable: true,
1646
- writable: true,
1647
- value: void 0
1648
- });
1649
- this.name = "ApiError";
1650
- this.status = options?.status;
1651
- this.code = options?.code;
1652
- this.field = options?.field;
1653
- this.details = options?.details;
1654
- }
1418
+ const myRolesQueryKey = (organizationId) => ["erp-shell", "my-roles", organizationId];
1419
+ // Only for a host that renders NavUser outside a QueryClientProvider: the
1420
+ // card still works there instead of throwing.
1421
+ let fallbackClient = null;
1422
+ function useShellQueryClient() {
1423
+ const provided = useContext(QueryClientContext);
1424
+ if (provided)
1425
+ return provided;
1426
+ fallbackClient ?? (fallbackClient = new QueryClient());
1427
+ return fallbackClient;
1428
+ }
1429
+ function subscribeStorage(onChange) {
1430
+ window.addEventListener("storage", onChange);
1431
+ return () => window.removeEventListener("storage", onChange);
1432
+ }
1433
+ /**
1434
+ * The organisation the API client sends (X-Organization-ID). Null while
1435
+ * server-rendering and hydrating, so the first client render matches the
1436
+ * server's.
1437
+ */
1438
+ function useStoredOrganizationId() {
1439
+ return useSyncExternalStore(subscribeStorage, getOrganizationId, () => null);
1440
+ }
1441
+ /**
1442
+ * The signed-in person's roles in an organisation, from platform-api
1443
+ * (GET /organizations/:id/my-roles). Cached per organisation; a different
1444
+ * organisation is a different query. A failure, or an API too old to have
1445
+ * the route, leaves `data` empty — the caller shows nothing.
1446
+ */
1447
+ function useMyRoles(organizationId, enabled = true) {
1448
+ const orgId = organizationId !== null &&
1449
+ organizationId !== undefined &&
1450
+ String(organizationId).trim() !== ""
1451
+ ? String(organizationId).trim()
1452
+ : null;
1453
+ const client = useShellQueryClient();
1454
+ return useQuery({
1455
+ queryKey: myRolesQueryKey(orgId),
1456
+ queryFn: async ({ signal }) => {
1457
+ const base = platformApiBase(detectHelpApp());
1458
+ const res = await instance.get(`${base}/organizations/${encodeURIComponent(orgId)}/my-roles`, { signal });
1459
+ return parseMyRoles(res.data, orgId);
1460
+ },
1461
+ enabled: enabled && orgId !== null,
1462
+ staleTime: 60000,
1463
+ gcTime: 30 * 60000,
1464
+ // A refusal or a missing route will not be different a second later;
1465
+ // the next focus or organisation change asks again.
1466
+ retry: false,
1467
+ refetchOnWindowFocus: true,
1468
+ }, client);
1655
1469
  }
1656
- const toApiError = (error) => {
1657
- if (error instanceof ApiError)
1658
- return error;
1659
- if (isAxiosError(error)) {
1660
- const axiosErr = error;
1661
- const message = axiosErr.response?.data?.message ||
1662
- axiosErr.response?.data?.error ||
1663
- axiosErr.message ||
1664
- "Request failed";
1665
- return new ApiError(message, {
1666
- status: axiosErr.response?.status,
1667
- code: axiosErr.response?.data?.code,
1668
- field: axiosErr.response?.data?.field,
1669
- details: axiosErr.response?.data?.details,
1670
- });
1671
- }
1672
- if (error instanceof Error) {
1673
- return new ApiError(error.message);
1674
- }
1675
- return new ApiError(String(error));
1676
- };
1677
1470
 
1678
- const isPlainObject = (value) => Object.prototype.toString.call(value) === "[object Object]";
1679
- const visit = (value, cb) => {
1680
- if (Array.isArray(value)) {
1681
- value.forEach(item => visit(item, cb));
1682
- return;
1683
- }
1684
- if (!isPlainObject(value)) {
1685
- return;
1686
- }
1687
- cb(value);
1688
- Object.values(value).forEach(item => visit(item, cb));
1689
- };
1690
- const validateGoodsReceiptChecks = (data) => {
1691
- let invalidPath = null;
1692
- const isCheckStatus = (v) => v === undefined || v === null || v === "pass" || v === "fail";
1693
- const isCompareStatus = (v) => v === undefined || v === null || v === "match" || v === "mismatch";
1694
- visit(data, node => {
1695
- if (invalidPath)
1696
- return;
1697
- if ("quantity_check" in node && !isCheckStatus(node.quantity_check)) {
1698
- invalidPath = "quantity_check";
1699
- return;
1471
+ /** The owner's chip: the app's accent, tinted. */
1472
+ function OwnerChip({ size = "card" }) {
1473
+ return (jsx("span", { className: size === "menu"
1474
+ ? "inline-flex shrink-0 items-center rounded-md bg-primary/10 px-2 py-0.5 text-xs font-semibold text-primary"
1475
+ : "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 }));
1476
+ }
1477
+ /**
1478
+ * The card's role line: the owner's chip, then two role names and a count of
1479
+ * the rest. Wraps between names rather than inside one, and truncates a name
1480
+ * that is wider than the card on its own; the tooltip has everything.
1481
+ */
1482
+ function CardRoles({ my, className }) {
1483
+ const { shown, more } = cardRoleLine(my.roles);
1484
+ return (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 ? jsx(OwnerChip, {}) : null, shown.map((name, i) => {
1485
+ const last = i === shown.length - 1;
1486
+ return (jsxs("span", { className: "flex min-w-0 max-w-full items-baseline", children: [jsx("span", { className: "truncate", children: name }), !last ? (jsx("span", { "aria-hidden": "true", className: "shrink-0 pl-1 opacity-60", children: "\u00B7" })) : null, last && more > 0 ? (jsxs("span", { className: "shrink-0 pl-1 opacity-70", children: ["+", more] })) : null] }, name));
1487
+ })] }));
1488
+ }
1489
+ /** Every role in full, for the menu. */
1490
+ function MenuRoles({ my }) {
1491
+ return (jsxs("div", { className: "flex flex-wrap gap-1.5", children: [my.isOwner ? jsx(OwnerChip, { size: "menu" }) : null, my.roles.map(r => (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)))] }));
1492
+ }
1493
+ function isExternalHref(href) {
1494
+ return /^https?:\/\//i.test(href) || href.startsWith("//");
1495
+ }
1496
+ function NavUser({ organization, avatarSrc, profileHref, selectOrganizationHref = "/select-organization", signInRedirectHref = "/auth/signin", systemLinks, showSelectOrganization = true, showLanguageSwitch = true, variant = "brand", showRoles, organizationId, } = {}) {
1497
+ const { data: session, status } = useSession();
1498
+ const storedOrganizationId = useStoredOrganizationId();
1499
+ const rolesOrganizationId = organizationId !== null &&
1500
+ organizationId !== undefined &&
1501
+ String(organizationId).trim() !== ""
1502
+ ? organizationId
1503
+ : storedOrganizationId;
1504
+ const rolesOn = (showRoles ?? showSelectOrganization) && status === "authenticated";
1505
+ const { data: rolesData, fetchStatus: rolesFetchStatus } = useMyRoles(rolesOrganizationId, rolesOn);
1506
+ const myRoles = rolesOn && hasRoleLine(rolesData) ? rolesData : null;
1507
+ // First load only: hold the line's place so the card does not jump when
1508
+ // the roles arrive. A failure drops it.
1509
+ const rolesLoading = rolesOn && !!rolesOrganizationId && rolesData === undefined && rolesFetchStatus === "fetching";
1510
+ // With a role line the card is three lines or more: the chevron moves up
1511
+ // to the name so the lines under it get the card's full width.
1512
+ const cardTall = !!myRoles || rolesLoading;
1513
+ const router = useRouter();
1514
+ const { theme, setTheme } = useTheme();
1515
+ const [isSigningOut, setIsSigningOut] = useState(false);
1516
+ const [isOpen, setIsOpen] = useState(false);
1517
+ const [showThemeMenu, setShowThemeMenu] = useState(false);
1518
+ const [showLanguageMenu, setShowLanguageMenu] = useState(false);
1519
+ const [showAppsMenu, setShowAppsMenu] = useState(false);
1520
+ const buttonRef = useRef(null);
1521
+ const [position, setPosition] = useState({ top: 0, left: 0 });
1522
+ // Shown where a layout has mounted the help dialog (AppLayoutShell).
1523
+ const helpAvailable = useHelpAvailable();
1524
+ const user = session?.user
1525
+ ? {
1526
+ name: session.user.name || "ผู้ใช้",
1527
+ email: session.user.email || "",
1528
+ // avatarSrc first: the NextAuth session carries no picture unless the
1529
+ // host app deliberately puts one in the JWT, so relying on
1530
+ // session.user.image alone left every user on their initials forever.
1531
+ avatar: avatarSrc || session.user.image || undefined,
1700
1532
  }
1701
- if ("quality_check" in node && !isCheckStatus(node.quality_check)) {
1702
- invalidPath = "quality_check";
1703
- return;
1533
+ : null;
1534
+ const getInitials = (name) => {
1535
+ if (!name)
1536
+ return "U";
1537
+ return name
1538
+ .split(" ")
1539
+ .map(n => n[0])
1540
+ .join("")
1541
+ .toUpperCase()
1542
+ .slice(0, 2);
1543
+ };
1544
+ const handleSignOut = async () => {
1545
+ setIsSigningOut(true);
1546
+ try {
1547
+ await signOut({ callbackUrl: signInRedirectHref, redirect: true });
1704
1548
  }
1705
- if ("compare_with_reference" in node &&
1706
- !isCompareStatus(node.compare_with_reference)) {
1707
- invalidPath = "compare_with_reference";
1549
+ catch (error) {
1550
+ console.error("Sign out error:", error);
1551
+ window.location.href = signInRedirectHref;
1708
1552
  }
1709
- });
1710
- if (invalidPath) {
1711
- return {
1712
- valid: false,
1713
- message: "รูปแบบข้อมูลไม่ถูกต้อง: ฟิลด์ตรวจรับต้องเป็น enum string (quantity/quality: pass|fail, compare: match|mismatch)",
1714
- };
1715
- }
1716
- return { valid: true };
1717
- };
1718
- const runGlobalRequestPrecheck = (config) => {
1719
- const method = (config.method || "get").toLowerCase();
1720
- const isWriteMethod = ["post", "put", "patch"].includes(method);
1721
- if (!isWriteMethod || config.data === undefined || config.data === null) {
1722
- return { valid: true };
1723
- }
1724
- if (typeof FormData !== "undefined" && config.data instanceof FormData) {
1725
- return { valid: true };
1726
- }
1727
- return validateGoodsReceiptChecks(config.data);
1728
- };
1729
-
1730
- const isBrowser = () => typeof window !== "undefined";
1731
- const isAuthEndpoint = (url) => !!url && url.includes("/api/auth/");
1732
- const isAuthPage = () => isBrowser() && window.location.pathname.startsWith("/auth/");
1733
- const getOrgFromCookie = () => {
1734
- if (!isBrowser())
1735
- return null;
1736
- const cookies = document.cookie.split(";").map(cookie => cookie.trim());
1737
- const orgCookie = cookies.find(cookie => cookie.startsWith("erp.organizationId="));
1738
- if (!orgCookie)
1553
+ finally {
1554
+ setIsSigningOut(false);
1555
+ }
1556
+ };
1557
+ const handleSelectOrganization = () => {
1558
+ setIsOpen(false);
1559
+ router.push(selectOrganizationHref);
1560
+ };
1561
+ const handleProfile = () => {
1562
+ setIsOpen(false);
1563
+ if (profileHref)
1564
+ router.push(profileHref);
1565
+ };
1566
+ const handleSystemLink = (href) => {
1567
+ setIsOpen(false);
1568
+ if (isExternalHref(href)) {
1569
+ window.location.assign(href);
1570
+ return;
1571
+ }
1572
+ router.push(href);
1573
+ };
1574
+ const handleThemeChange = (newTheme) => {
1575
+ setTheme(newTheme);
1576
+ setShowThemeMenu(false);
1577
+ };
1578
+ const handleLanguageChange = (language) => {
1579
+ console.log("Change language to:", language);
1580
+ setShowLanguageMenu(false);
1581
+ };
1582
+ useEffect(() => {
1583
+ if (isOpen && buttonRef.current) {
1584
+ const rect = buttonRef.current.getBoundingClientRect();
1585
+ setPosition({ top: rect.bottom, left: rect.right + 8 });
1586
+ }
1587
+ }, [isOpen]);
1588
+ useEffect(() => {
1589
+ if (!isOpen)
1590
+ return;
1591
+ const handleClickOutside = (event) => {
1592
+ if (buttonRef.current &&
1593
+ !buttonRef.current.contains(event.target) &&
1594
+ !event.target.closest('[data-dropdown="nav-user"]')) {
1595
+ setIsOpen(false);
1596
+ }
1597
+ };
1598
+ document.addEventListener("mousedown", handleClickOutside);
1599
+ return () => document.removeEventListener("mousedown", handleClickOutside);
1600
+ }, [isOpen]);
1601
+ if (status === "loading") {
1602
+ return (jsx("div", { className: "p-2", children: jsxs("div", { className: "flex items-center gap-2 p-2 rounded-md", children: [jsx("div", { className: "h-8 w-8 rounded-lg bg-sidebar-accent animate-pulse" }), jsxs("div", { className: "flex-1 space-y-1", children: [jsx("div", { className: "h-4 w-24 bg-sidebar-accent rounded animate-pulse" }), jsx("div", { className: "h-3 w-32 bg-sidebar-accent rounded animate-pulse" })] })] }) }));
1603
+ }
1604
+ if (!user)
1739
1605
  return null;
1740
- const raw = orgCookie.substring("erp.organizationId=".length);
1741
- return raw ? decodeURIComponent(raw) : null;
1742
- };
1743
- const redirectToLogout = async () => {
1744
- if (!isBrowser() || isAuthPage())
1745
- return;
1746
- localStorage.clear();
1747
- sessionStorage.clear();
1748
- setOrganizationId(null);
1749
- clearAccessTokenCache();
1750
- await signOut({ callbackUrl: "/auth/signin" });
1606
+ const visibleSystemLinks = (systemLinks ?? []).filter(link => link.href && link.label);
1607
+ const dropdownContent = isOpen ? (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 && (jsx("div", { className: "p-4 border-b border-sidebar-border bg-sidebar-accent/30", children: jsxs("div", { className: `flex gap-3 ${myRoles ? "items-start" : "items-center"}`, children: [organization.logoUrl ? (
1608
+ // eslint-disable-next-line @next/next/no-img-element
1609
+ jsx("img", { src: organization.logoUrl, alt: organization.name ?? "โลโก้บริษัท", className: "h-8 w-8 flex-shrink-0 rounded object-contain bg-white",
1610
+ // A dead logo URL must not leave a broken-image glyph in the
1611
+ // sidebar; drop back to the generic icon's empty slot.
1612
+ onError: e => {
1613
+ e.currentTarget.style.display = "none";
1614
+ } })) : (jsx(Building2, { className: "h-5 w-5 text-sidebar-foreground/70 flex-shrink-0" })), jsxs("div", { className: "flex-1 min-w-0", children: [jsx("div", { className: "font-medium text-sm sm:truncate", children: organization.name }), organization.description && (jsx("div", { className: "text-xs text-sidebar-foreground/70 truncate mt-0.5", children: organization.description })), myRoles ? (jsx("div", { className: "mt-2.5", children: jsx(MenuRoles, { my: myRoles }) })) : null] })] }) })), !organization && myRoles ? (jsx("div", { className: "p-4 border-b border-sidebar-border bg-sidebar-accent/30", children: jsx(MenuRoles, { my: myRoles }) })) : null, jsxs("div", { className: "p-2", children: [jsxs("div", { className: "relative", children: [jsxs("button", { onClick: () => {
1615
+ setShowThemeMenu(!showThemeMenu);
1616
+ setShowLanguageMenu(false);
1617
+ setShowAppsMenu(false);
1618
+ }, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsx(Palette, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E18\u0E35\u0E21" }), jsx(ChevronUp, { className: `h-4 w-4 transition-transform ${showThemeMenu ? "rotate-180" : ""}` })] }), showThemeMenu && (jsx("div", { className: "mt-1 ml-4 space-y-1", children: [
1619
+ { key: "light", label: "สว่าง", Icon: Sun },
1620
+ { key: "dark", label: "มืด", Icon: Moon },
1621
+ { key: "system", label: "ตามระบบ", Icon: Monitor },
1622
+ ].map(({ key, label, Icon }) => (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: [jsx(Icon, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: label }), theme === key && (jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, key))) }))] }), showLanguageSwitch && (jsxs("div", { className: "relative", children: [jsxs("button", { onClick: () => {
1623
+ setShowLanguageMenu(!showLanguageMenu);
1624
+ setShowThemeMenu(false);
1625
+ setShowAppsMenu(false);
1626
+ }, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsx(Globe, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E1B\u0E25\u0E35\u0E48\u0E22\u0E19\u0E20\u0E32\u0E29\u0E32" }), jsx(ChevronUp, { className: `h-4 w-4 transition-transform ${showLanguageMenu ? "rotate-180" : ""}` })] }), showLanguageMenu && (jsx("div", { className: "mt-1 ml-4 space-y-1", children: [
1627
+ { code: "th", label: "ไทย" },
1628
+ { code: "en", label: "English" },
1629
+ { code: "zh", label: "中文" },
1630
+ { code: "ja", label: "日本語" },
1631
+ ].map(({ code, label }) => (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: [jsx("span", { className: "flex-1 text-left", children: label }), organization?.language === code && (jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, code))) }))] })), profileHref && (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: [jsx(User, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E2A\u0E48\u0E27\u0E19\u0E15\u0E31\u0E27" })] })), showSelectOrganization && (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: [jsx(Building2, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E25\u0E37\u0E2D\u0E01\u0E18\u0E38\u0E23\u0E01\u0E34\u0E08" })] })), visibleSystemLinks.length > 0 && (jsxs("div", { className: "relative", children: [jsxs("button", { onClick: () => {
1632
+ setShowAppsMenu(!showAppsMenu);
1633
+ setShowThemeMenu(false);
1634
+ setShowLanguageMenu(false);
1635
+ }, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsx(LayoutGrid, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E41\u0E2D\u0E1B\u0E1E\u0E25\u0E34\u0E40\u0E04\u0E0A\u0E31\u0E19" }), jsx(ChevronUp, { className: `h-4 w-4 transition-transform ${showAppsMenu ? "rotate-180" : ""}` })] }), showAppsMenu && (jsx("div", { className: "mt-1 ml-4 space-y-1", children: visibleSystemLinks.map(link => (jsxs("button", { type: "button", onClick: () => handleSystemLink(link.href), className: "flex w-full items-center gap-3 px-3 py-2 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [link.icon ?? jsx(ExternalLink, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: link.label }), link.active && (jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, `${link.label}:${link.href}`))) }))] })), helpAvailable && (jsxs("button", { type: "button", onClick: () => {
1636
+ setIsOpen(false);
1637
+ openHelp();
1638
+ }, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsx(CircleHelp, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D" })] })), jsx("button", { onClick: handleSignOut, disabled: isSigningOut, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors disabled:opacity-50 disabled:cursor-not-allowed mt-1", children: isSigningOut ? (jsxs(Fragment$1, { children: [jsx(Loader2, { className: "h-4 w-4 animate-spin" }), jsx("span", { className: "flex-1 text-left", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E23\u0E30\u0E1A\u0E1A..." })] })) : (jsxs(Fragment$1, { children: [jsx(LogOut, { className: "h-4 w-4" }), jsx("span", { className: "flex-1 text-left", children: "\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E23\u0E30\u0E1A\u0E1A" })] })) })] })] })) : null;
1639
+ if (variant === "workspace") {
1640
+ return (jsx("div", { className: "mt-3.5 border-t border-sidebar-border pt-3", children: jsxs("div", { className: "relative", children: [jsxs("button", { ref: buttonRef, onClick: () => setIsOpen(!isOpen), "aria-expanded": isOpen, className: `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: [jsxs(Avatar, { className: "size-[34px] shrink-0 rounded-full border-[3px] border-sidebar-accent", children: [user.avatar ? (jsx(AvatarImage, { src: user.avatar, alt: user.name })) : null, jsx(AvatarFallback, { className: "rounded-full bg-sidebar-accent text-sm font-medium text-sidebar-accent-foreground", children: getInitials(user.name) })] }), jsxs("span", { className: `grid min-w-0 flex-1 leading-tight ${cardTall ? "gap-0.5" : ""}`, children: [jsx("strong", { className: `truncate text-[13px] font-semibold ${cardTall ? "pr-5" : ""}`, title: user.name, children: user.name }), myRoles ? (jsx(CardRoles, { my: myRoles, className: "text-[12px] font-medium leading-[17px] text-sidebar-foreground/80" })) : rolesLoading ? (jsx("span", { className: "my-[3px] block h-[11px] w-20 animate-pulse rounded bg-sidebar-accent" })) : null, jsx("small", { className: "truncate text-[11px] text-muted-foreground", title: user.email, children: user.email })] }), jsx(ChevronsUpDown, { className: cardTall
1641
+ ? "absolute right-3 top-[9px] size-3.5 text-muted-foreground"
1642
+ : "size-3.5 shrink-0 text-muted-foreground" })] }), typeof window !== "undefined" &&
1643
+ createPortal(dropdownContent, document.body)] }) }));
1644
+ }
1645
+ return (jsx("div", { className: "p-2", children: jsxs("div", { className: "relative", children: [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: [jsxs(Avatar, { className: "h-8 w-8 shrink-0 rounded-lg", children: [user.avatar ? (jsx(AvatarImage, { src: user.avatar, alt: user.name })) : null, jsx(AvatarFallback, { className: "rounded-lg bg-primary text-primary-foreground w-8 h-8", children: getInitials(user.name) })] }), jsxs("div", { className: `grid min-w-0 flex-1 text-left text-sm leading-tight ${cardTall ? "gap-0.5" : ""}`, children: [jsx("span", { className: `truncate font-medium ${cardTall ? "pr-6" : ""}`, title: user.name, children: user.name }), myRoles ? (jsx(CardRoles, { my: myRoles, className: "text-xs font-medium text-sidebar-foreground/85" })) : rolesLoading ? (jsx("span", { className: "my-0.5 block h-3 w-24 animate-pulse rounded bg-sidebar-accent" })) : null, jsx("span", { className: "truncate text-xs text-sidebar-foreground/70", title: user.email, children: user.email })] }), jsx(ChevronUp, { className: `size-4 transition-transform ${isOpen ? "rotate-180" : ""} ${cardTall ? "absolute right-2 top-2.5" : "ml-auto shrink-0"}` })] }), typeof window !== "undefined" &&
1646
+ createPortal(dropdownContent, document.body)] }) }));
1647
+ }
1648
+
1649
+ /**
1650
+ * Plan feature keys (billable entitlements). Mirror of platform-apis plan.go.
1651
+ *
1652
+ * This lives in the shell because every app gates on the same keys, and five
1653
+ * private copies of the same list drift: the app-switcher list did exactly
1654
+ * that before it moved here.
1655
+ */
1656
+ const PLAN_FEATURES = {
1657
+ ORG_CHART: "org_chart",
1658
+ APPROVAL_WORKFLOW: "approval_workflow",
1659
+ API_ACCESS: "api_access",
1660
+ SSO: "sso",
1661
+ BASIC_ACCOUNTING_HR: "basic_accounting_hr",
1662
+ MODULE_PLATFORM: "module.platform",
1663
+ MODULE_HR: "module.hr",
1664
+ MODULE_ACCOUNTING: "module.accounting",
1665
+ MODULE_SALES: "module.sales",
1666
+ MODULE_PURCHASING: "module.purchasing",
1667
+ MODULE_INVENTORY: "module.inventory",
1668
+ MODULE_PROJECT: "module.project",
1669
+ MODULE_SECRETARIAT: "module.secretariat",
1670
+ /** AI pack (paid or trial): AI calls on AntNiti's key while credits remain. */
1671
+ AI: "ai",
1751
1672
  };
1752
- const instance = axios.create({
1753
- baseURL: process.env.NEXT_PUBLIC_API_URL,
1754
- headers: { "Content-Type": "application/json" },
1755
- paramsSerializer: params => {
1756
- const searchParams = new URLSearchParams();
1757
- Object.entries(params || {}).forEach(([key, value]) => {
1758
- if (Array.isArray(value)) {
1759
- value.forEach(v => searchParams.append(key, String(v)));
1760
- }
1761
- else if (value !== undefined && value !== null) {
1762
- searchParams.append(key, String(value));
1763
- }
1764
- });
1765
- return searchParams.toString();
1766
- },
1767
- });
1768
- instance.interceptors.request.use(async (config) => {
1769
- if (!config.headers) {
1770
- config.headers = new AxiosHeaders();
1673
+ /** Keys the Accounx add-on carries with it (same alias as org_effective_features). */
1674
+ const ACCOUNTING_SUB_KEYS = [
1675
+ PLAN_FEATURES.MODULE_SALES,
1676
+ PLAN_FEATURES.MODULE_PURCHASING,
1677
+ PLAN_FEATURES.MODULE_SECRETARIAT,
1678
+ ];
1679
+ /**
1680
+ * True when plan features include key. Aliases, mirroring the backend:
1681
+ * legacy basic_accounting_hr → module.hr + module.accounting (+ its sub-keys);
1682
+ * module.accounting → module.sales, module.purchasing, module.secretariat.
1683
+ */
1684
+ function hasPlanFeature(features, feature) {
1685
+ if (!features?.length)
1686
+ return false;
1687
+ if (features.includes(feature))
1688
+ return true;
1689
+ if ((feature === PLAN_FEATURES.MODULE_HR ||
1690
+ feature === PLAN_FEATURES.MODULE_ACCOUNTING ||
1691
+ ACCOUNTING_SUB_KEYS.includes(feature)) &&
1692
+ features.includes(PLAN_FEATURES.BASIC_ACCOUNTING_HR)) {
1693
+ return true;
1771
1694
  }
1772
- if (config.data instanceof FormData && config.headers) {
1773
- delete config.headers["Content-Type"];
1695
+ if (ACCOUNTING_SUB_KEYS.includes(feature) &&
1696
+ features.includes(PLAN_FEATURES.MODULE_ACCOUNTING)) {
1697
+ return true;
1774
1698
  }
1775
- if (!isBrowser())
1776
- return config;
1777
- const token = await getAccessToken(false);
1778
- if (token) {
1779
- config.headers.Authorization = `Bearer ${token}`;
1699
+ if (feature === PLAN_FEATURES.BASIC_ACCOUNTING_HR &&
1700
+ features.includes(PLAN_FEATURES.MODULE_HR) &&
1701
+ features.includes(PLAN_FEATURES.MODULE_ACCOUNTING)) {
1702
+ return true;
1780
1703
  }
1781
- // localStorage first, cookie second.
1782
- //
1783
- // Both are written by the same state machine, but not at the same moment:
1784
- // setOrganizationId writes localStorage synchronously and then awaits a
1785
- // server action for the cookie. Preferring the cookie meant every request
1786
- // fired during that round trip carried the previous organisation — while
1787
- // the query params, built from React state, already carried the new one.
1788
- //
1789
- // The backend scopes by this header and ignores organization_id in the
1790
- // query, so those requests asked for organisation A and were handed B, and
1791
- // the answer landed in the cache under A's key. No amount of cache clearing
1792
- // reaches that: the entry is wrong, not stale.
1793
- const organizationId = getOrganizationId() || getOrgFromCookie();
1794
- if (organizationId) {
1795
- config.headers["X-Organization-ID"] = organizationId;
1704
+ return false;
1705
+ }
1706
+
1707
+ function normaliseBase(value, fallback) {
1708
+ return (value ?? fallback).replace(/\/$/, "");
1709
+ }
1710
+ /**
1711
+ * One list, in display order, for the whole suite.
1712
+ *
1713
+ * It used to be copied into every app's own nav-user.tsx, which is how Invenx
1714
+ * shipped without a link to Plannex: five copies, one of them edited by hand.
1715
+ * Adding the sixth app is now a change here plus a version bump.
1716
+ */
1717
+ const APPS = [
1718
+ {
1719
+ id: "platform",
1720
+ label: "แพลตฟอร์ม",
1721
+ icon: jsx(Building2, { className: "h-4 w-4 shrink-0" }),
1722
+ features: null,
1723
+ baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_PLATFORM_URL, "https://platform.antniti.com"),
1724
+ },
1725
+ {
1726
+ id: "accounx",
1727
+ label: "Accounx",
1728
+ icon: jsx(Calculator, { className: "h-4 w-4 shrink-0" }),
1729
+ features: [PLAN_FEATURES.MODULE_ACCOUNTING],
1730
+ baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_ACCOUNTING_URL, "https://accounx.antniti.com"),
1731
+ },
1732
+ {
1733
+ id: "invenx",
1734
+ label: "Invenx",
1735
+ icon: jsx(Warehouse, { className: "h-4 w-4 shrink-0" }),
1736
+ features: [PLAN_FEATURES.MODULE_INVENTORY],
1737
+ baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_INVENX_URL, "https://invenx.antniti.com"),
1738
+ },
1739
+ {
1740
+ id: "plannex",
1741
+ label: "Plannex",
1742
+ icon: jsx(FolderKanban, { className: "h-4 w-4 shrink-0" }),
1743
+ // Tasks, day-logs and work records are free with HR; projects need the add-on.
1744
+ features: [PLAN_FEATURES.MODULE_HR, PLAN_FEATURES.MODULE_PROJECT],
1745
+ baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_PLANNEX_URL, "https://plannex.antniti.com"),
1746
+ },
1747
+ {
1748
+ id: "peoplx",
1749
+ label: "Peoplx",
1750
+ icon: jsx(Users, { className: "h-4 w-4 shrink-0" }),
1751
+ features: [PLAN_FEATURES.MODULE_HR],
1752
+ baseUrl: () => normaliseBase(process.env.NEXT_PUBLIC_HR_URL, "https://peoplx.antniti.com"),
1753
+ },
1754
+ ];
1755
+ /**
1756
+ * Builds the cross-app link list for <NavUser systemLinks>.
1757
+ *
1758
+ * The current app is always present even when the org's plan no longer covers
1759
+ * it — the user is looking at it, so hiding the link would only make the menu
1760
+ * disagree with the screen.
1761
+ */
1762
+ function buildSystemLinks({ orgCode, activeApp, features, }) {
1763
+ // The app names itself here; the help dialog says which app a message
1764
+ // came from by it.
1765
+ declareHelpApp(activeApp);
1766
+ if (!orgCode)
1767
+ return [];
1768
+ return APPS.filter(app => app.id === activeApp ||
1769
+ app.features === null ||
1770
+ app.features.some(key => hasPlanFeature(features, key))).map(app => ({
1771
+ label: app.label,
1772
+ icon: app.icon,
1773
+ // Same-app link stays relative so it never leaves the current origin.
1774
+ href: app.id === activeApp ? `/${orgCode}` : `${app.baseUrl()}/${orgCode}`,
1775
+ ...(app.id === activeApp ? { active: true } : {}),
1776
+ }));
1777
+ }
1778
+
1779
+ // Helper function to check if pathname matches a URL pattern
1780
+ // This prevents false matches like /reports/sales matching /sales
1781
+ // Handles paths with or without [code] prefix
1782
+ function isPathActive(pathname, url) {
1783
+ // Exact match
1784
+ if (pathname === url) {
1785
+ return true;
1796
1786
  }
1797
- const precheck = runGlobalRequestPrecheck(config);
1798
- if (!precheck.valid) {
1799
- throw new ApiError(precheck.message || "Invalid request payload");
1787
+ // Check if pathname starts with url + "/" to ensure exact segment matching
1788
+ // This prevents /reports/sales from matching /sales
1789
+ // Example: /org123/reports/sales should NOT match /org123/sales
1790
+ // But /org123/sales/overview SHOULD match /org123/sales
1791
+ if (pathname.startsWith(url + "/")) {
1792
+ return true;
1800
1793
  }
1801
- return config;
1802
- });
1803
- let refreshPromise = null;
1804
- const refreshTokenSingleFlight = async () => {
1805
- if (!refreshPromise) {
1806
- refreshPromise = getAccessToken(true).finally(() => {
1807
- refreshPromise = null;
1794
+ return false;
1795
+ }
1796
+
1797
+ /** "/O1/" and "/O1" are the same page; a query string never is. */
1798
+ function samePage(pathname, url) {
1799
+ if (url.includes("?"))
1800
+ return false;
1801
+ const strip = (p) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
1802
+ return strip(pathname) === strip(url);
1803
+ }
1804
+ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, chrome = "brand", highlightActiveModule = false, }) {
1805
+ const workspace = chrome === "workspace";
1806
+ const params = useParams();
1807
+ const pathname = usePathname();
1808
+ const { setOpen } = useSidebar();
1809
+ const handleNavLinkClick = () => {
1810
+ if (closeOnNavigation)
1811
+ setOpen(false);
1812
+ };
1813
+ const code = orgCode ?? params?.code;
1814
+ // Which module (and sub-item) the current page belongs to.
1815
+ const states = items.map(item => {
1816
+ const itemUrl = buildUrlWithCode(code, item.url);
1817
+ // Most specific (longest) matching sub-item URL so only one link is active.
1818
+ const activeSubItemUrl = item.subModules?.reduce((best, subItem) => {
1819
+ const subItemUrl = buildUrlWithCode(code, subItem.url);
1820
+ const matches = isPathActive(pathname, subItemUrl) ||
1821
+ isPathActive(pathname, subItem.url);
1822
+ const subPageMatches = subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
1823
+ isPathActive(pathname, subPage.url));
1824
+ if (!matches && !subPageMatches)
1825
+ return best;
1826
+ const candidateUrl = subItemUrl;
1827
+ if (!best)
1828
+ return candidateUrl;
1829
+ return candidateUrl.length > best.length ? candidateUrl : best;
1830
+ }, null) ?? null;
1831
+ const hasActiveSubItem = !!activeSubItemUrl ||
1832
+ !!item.subModules?.some(subItem => subItem.subPages?.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)) ||
1833
+ isPathActive(pathname, subPage.url)));
1834
+ // Parent only active when path matches its URL exactly AND no child
1835
+ // is active (so parent+child don't both light up).
1836
+ const isItemActive = (highlightActiveModule && !item.subModules?.length && !!item.isActive) ||
1837
+ ((workspace ? samePage(pathname, itemUrl) : pathname === itemUrl) &&
1838
+ !hasActiveSubItem);
1839
+ return { itemUrl, activeSubItemUrl, hasActiveSubItem, isItemActive };
1840
+ });
1841
+ // A group opens when its page becomes current and otherwise stays as the
1842
+ // user left it. defaultOpen alone decided once, on the first render — and
1843
+ // on a direct load the sub-items arrive after permissions do, so the group
1844
+ // holding the current page stayed shut.
1845
+ const activeKey = items
1846
+ .filter((_, i) => states[i].isItemActive || states[i].hasActiveSubItem)
1847
+ .map(item => item.id)
1848
+ .join(",");
1849
+ const [openIds, setOpenIds] = React.useState(() => new Set(activeKey ? activeKey.split(",") : []));
1850
+ React.useEffect(() => {
1851
+ if (!activeKey)
1852
+ return;
1853
+ setOpenIds(prev => {
1854
+ const ids = activeKey.split(",");
1855
+ if (ids.every(id => prev.has(id)))
1856
+ return prev;
1857
+ const next = new Set(prev);
1858
+ ids.forEach(id => next.add(id));
1859
+ return next;
1808
1860
  });
1861
+ }, [activeKey]);
1862
+ const setItemOpen = (id, open) => setOpenIds(prev => {
1863
+ if (prev.has(id) === open)
1864
+ return prev;
1865
+ const next = new Set(prev);
1866
+ if (open)
1867
+ next.add(id);
1868
+ else
1869
+ next.delete(id);
1870
+ return next;
1871
+ });
1872
+ return (jsx(SidebarGroup, { "data-sidebar": true, className: workspace ? "p-0" : undefined, children: jsx(SidebarMenu, { className: workspace ? "gap-[3px]" : undefined, children: items.map((item, index) => {
1873
+ const groupHeading = workspace &&
1874
+ item.group &&
1875
+ item.group !== items[index - 1]?.group ? (jsx("li", { className: "list-none px-3 pb-2.5 text-xs text-muted-foreground" +
1876
+ (index > 0 ? " pt-5" : ""), children: item.group }, `group-${item.id}`)) : null;
1877
+ const { itemUrl, activeSubItemUrl, isItemActive } = states[index];
1878
+ const IconComp = item.icon;
1879
+ const workspaceButton = workspace
1880
+ ? "h-auto gap-3 rounded-[10px] px-3 py-2.5 text-[13px] " +
1881
+ (isItemActive
1882
+ ? "bg-sidebar-accent font-bold text-sidebar-accent-foreground"
1883
+ : "font-medium text-sidebar-foreground/70")
1884
+ : "";
1885
+ const icon = workspace && IconComp ? (jsx(IconComp, { className: "size-[18px] shrink-0" })) : null;
1886
+ const badge = workspace && item.badge !== undefined && item.badge !== "" ? (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;
1887
+ return (jsxs(React.Fragment, { children: [groupHeading, jsx(Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: open => setItemOpen(item.id, open), className: "group/collapsible", children: jsxs(SidebarMenuItem, { children: [jsx(CollapsibleTrigger, { asChild: true, children: jsx(SidebarMenuButton, { asChild: !item.subModules || item.subModules.length === 0, isActive: isItemActive, className: "data-[state=open]:text-sidebar-accent-foreground " +
1888
+ workspaceButton, children: item.subModules && item.subModules.length > 0 ? (jsxs(Fragment$1, { children: [icon, jsx("span", { id: item.id, children: item.titleTh }), jsx(ChevronRight, { className: "ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" })] })) : (jsxs(Link, { href: itemUrl, prefetch: false, className: "flex items-center w-full min-w-0 flex-1 " +
1889
+ (workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, children: [icon, jsx("span", { id: item.id, className: "flex-1 truncate", title: item.titleTh, children: item.titleTh }), badge] })) }) }), item.subModules && item.subModules.length > 0 && (jsx(CollapsibleContent, { children: jsx(SidebarMenuSub, { className: workspace
1890
+ ? "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]"
1891
+ : undefined, children: item.subModules.map(subItem => {
1892
+ const subItemUrl = buildUrlWithCode(code, subItem.url);
1893
+ const isSubItemActive = subItemUrl === activeSubItemUrl;
1894
+ const SubIcon = subItem.icon;
1895
+ const approvalCount = approvalCounts && subItem.approvalCountKey
1896
+ ? (approvalCounts[subItem.approvalCountKey] ?? 0)
1897
+ : 0;
1898
+ if (subItem.isCollapsible &&
1899
+ subItem.subPages &&
1900
+ subItem.subPages.length > 0) {
1901
+ const hasActiveSubPage = subItem.subPages.some(subPage => isPathActive(pathname, buildUrlWithCode(code, subPage.url)));
1902
+ return (jsx(Collapsible, { asChild: true, defaultOpen: isSubItemActive || hasActiveSubPage, className: "group/sub-collapsible", children: jsxs(SidebarMenuSubItem, { children: [jsx(CollapsibleTrigger, { asChild: true, children: jsxs(SidebarMenuSubButton, { className: "w-full", isActive: isSubItemActive || hasActiveSubPage, children: [SubIcon && (jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), jsx(ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsx(CollapsibleContent, { children: jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map(subPage => {
1903
+ const subPageUrl = buildUrlWithCode(code, subPage.url);
1904
+ const isSubPageActive = isPathActive(pathname, subPageUrl);
1905
+ const SubPageIcon = subPage.icon;
1906
+ return (jsx(SidebarMenuSubItem, { children: jsx(SidebarMenuSubButton, { asChild: true, className: `font-thai w-full text-sm ${isSubPageActive
1907
+ ? "text-sidebar-accent-foreground font-medium"
1908
+ : "text-sidebar-foreground"}`, isActive: false, children: jsxs(Link, { href: subPageUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubPageIcon && (jsx(SubPageIcon, { className: "sidebar-subpage-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", title: subPage.titleTh, children: subPage.titleTh })] }) }) }, subPage.id));
1909
+ }) }) })] }) }, subItem.id));
1910
+ }
1911
+ return (jsx(SidebarMenuSubItem, { children: jsx(SidebarMenuSubButton, { asChild: true, className: "w-full" +
1912
+ (workspace && isSubItemActive
1913
+ ? " font-semibold"
1914
+ : ""), isActive: isSubItemActive, children: jsxs(Link, { href: subItemUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, children: [SubIcon && (jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsx("span", { className: "flex-1 truncate", title: subItem.titleTh, children: subItem.titleTh }), approvalCount > 0 && (jsx(Badge, { variant: "destructive", className: "ml-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums", children: approvalCount > 99
1915
+ ? "99+"
1916
+ : approvalCount }))] }) }) }, subItem.id));
1917
+ }) }) }))] }) })] }, item.id));
1918
+ }) }) }));
1919
+ }
1920
+
1921
+ function OrgMark({ logoUrl, size = "md", }) {
1922
+ const [failed, setFailed] = useState(false);
1923
+ const box = size === "sm"
1924
+ ? "flex size-6 items-center justify-center rounded-md border overflow-hidden bg-background"
1925
+ : size === "header"
1926
+ ? "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"
1927
+ : "flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground overflow-hidden";
1928
+ const icon = size === "sm" ? "size-3.5 shrink-0" : "size-4";
1929
+ if (logoUrl && !failed) {
1930
+ return (jsx("div", { className: cn(box, size === "md" && "bg-white"), children: jsx("img", { src: logoUrl, alt: "", className: "size-full object-contain p-0.5", onError: () => setFailed(true) }) }));
1809
1931
  }
1810
- return refreshPromise;
1811
- };
1812
- instance.interceptors.response.use(response => {
1813
- // Any successful call means the session can act in this organisation
1814
- // after all. Without this the gate, once raised, stayed up through a
1815
- // whole working session — including the seconds between an administrator
1816
- // adding somebody and the employee record existing.
1817
- if (typeof window !== "undefined") {
1818
- window.dispatchEvent(new CustomEvent("erp:employee-record-ok"));
1819
- }
1820
- return response;
1821
- }, async (error) => {
1822
- const originalRequest = error.config;
1823
- const requestUrl = originalRequest?.url || error.config?.url;
1824
- if (error.response?.status === 401 &&
1825
- originalRequest &&
1826
- !originalRequest._retry &&
1827
- !isAuthEndpoint(requestUrl) &&
1828
- !isAuthPage()) {
1829
- originalRequest._retry = true;
1830
- const newToken = await refreshTokenSingleFlight();
1831
- if (newToken && originalRequest.headers) {
1832
- originalRequest.headers.Authorization = `Bearer ${newToken}`;
1833
- return instance(originalRequest);
1932
+ return (jsx("div", { className: box, children: jsx(Building2, { className: icon }) }));
1933
+ }
1934
+ function OrganizationSwitcher({ activeOrganization, isContextLoading = false, organizations, isLoadingOrgs = false, setOrganizationCookie, registerOrganizationHref = "/register-organization", placement = "sidebar", className, }) {
1935
+ const { isMobile } = useSidebarToggle();
1936
+ const router = useRouter();
1937
+ const params = useParams();
1938
+ const [isPending, startTransition] = useTransition();
1939
+ const currentCode = params?.code
1940
+ ? Array.isArray(params.code)
1941
+ ? params.code[0]
1942
+ : params.code
1943
+ : undefined;
1944
+ const handleOrganizationChange = (org) => {
1945
+ startTransition(async () => {
1946
+ const orgCode = org.code || String(org.id);
1947
+ // Write storage + cookie before navigation — Layout will re-hydrate
1948
+ // Context on the new URL.
1949
+ try {
1950
+ setOrganizationId(String(org.id));
1951
+ await setOrganizationCookie(org.id);
1952
+ }
1953
+ catch {
1954
+ // ignore: navigation will re-attempt context resolution
1955
+ }
1956
+ if (currentCode) {
1957
+ const currentPath = window.location.pathname;
1958
+ const parts = currentPath.split("/");
1959
+ const decodedParts = parts.map(p => decodeURIComponent(p));
1960
+ const codeIndex = decodedParts.indexOf(currentCode);
1961
+ if (codeIndex !== -1) {
1962
+ parts[codeIndex] = orgCode;
1963
+ router.push(parts.join("/"));
1964
+ }
1965
+ else {
1966
+ router.push(`/${orgCode}`);
1967
+ }
1968
+ }
1969
+ else {
1970
+ router.push(`/${orgCode}`);
1971
+ }
1972
+ });
1973
+ };
1974
+ const isLoading = isLoadingOrgs || isContextLoading;
1975
+ if (placement === "header") {
1976
+ const face = (interactive) => (jsxs(Fragment$1, { children: [jsx(OrgMark, { logoUrl: activeOrganization?.logoUrl, size: "header" }), jsxs("span", { className: "min-w-0 text-left", children: [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 ? (jsx(Skeleton, { className: "mt-1 h-3.5 w-28" })) : (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 ? (jsx(Loader2, { className: "ml-1 size-3.5 shrink-0 animate-spin text-[var(--header-fg-muted,currentColor)]" })) : (jsx(ChevronsUpDown, { className: "ml-1 size-3.5 shrink-0 text-[var(--header-fg-muted,currentColor)]" }))) : null] }));
1977
+ const faceClass = cn("flex items-center gap-2.5 rounded-lg py-1.5 max-sm:gap-2", className);
1978
+ if (isLoading || organizations.length <= 1) {
1979
+ return jsx("div", { className: faceClass, children: face(false) });
1834
1980
  }
1835
- await redirectToLogout();
1836
- return Promise.reject(toApiError(error));
1981
+ return (jsxs(DropdownMenu, { children: [jsx(DropdownMenuTrigger, { className: 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) }), jsxs(DropdownMenuContent, { className: "min-w-64 rounded-lg", align: "end", side: "bottom", sideOffset: 6, children: [jsx(DropdownMenuLabel, { className: "text-xs text-muted-foreground", children: "\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" }), organizations.map(org => (jsxs(DropdownMenuItem, { onClick: () => handleOrganizationChange(org), disabled: isPending, className: cn("gap-2 p-2 cursor-pointer", String(activeOrganization?.id) === String(org.id) && "bg-accent"), children: [jsx(OrgMark, { logoUrl: org.logoUrl, size: "sm" }), jsx("span", { className: "truncate", children: org.name })] }, org.id))), jsx(DropdownMenuSeparator, {}), jsxs(DropdownMenuItem, { className: "gap-2 p-2 cursor-pointer", onClick: () => router.push(registerOrganizationHref), children: [jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-transparent", children: jsx(Plus, { className: "size-4" }) }), jsx("span", { className: "text-muted-foreground font-medium", children: "\u0E40\u0E1E\u0E34\u0E48\u0E21\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" })] })] })] }));
1837
1982
  }
1838
- // The server refuses every organisation-scoped call when the account has
1839
- // no employee record. Announce it once so a gate can explain it, instead
1840
- // of letting each failed query raise its own toast.
1841
- if (error.response?.status === 403) {
1842
- const body = error.response.data;
1843
- if (body?.code === "EMPLOYEE_RECORD_REQUIRED" && typeof window !== "undefined") {
1844
- window.dispatchEvent(new CustomEvent("erp:employee-record-required"));
1845
- }
1983
+ if (isLoading) {
1984
+ return (jsx(SidebarMenu, { className: className, children: jsx(SidebarMenuItem, { children: jsxs("div", { className: "flex items-center gap-2 p-2", children: [jsx(Skeleton, { className: "size-8 rounded-lg" }), jsx(Skeleton, { className: "h-4 w-24" })] }) }) }));
1985
+ }
1986
+ if (organizations.length <= 1) {
1987
+ return (jsx(SidebarMenu, { className: className, children: jsx(SidebarMenuItem, { children: jsxs("div", { className: "flex items-center gap-2 p-2", children: [jsx(OrgMark, { logoUrl: activeOrganization?.logoUrl }), jsx("span", { className: "truncate font-semibold text-sm min-w-0 flex-1", children: activeOrganization?.name || "ไม่มีองค์กร" })] }) }) }));
1846
1988
  }
1847
- return Promise.reject(toApiError(error));
1848
- });
1989
+ return (jsx(SidebarMenu, { className: className, children: jsx(SidebarMenuItem, { children: jsxs(DropdownMenu, { children: [jsx(DropdownMenuTrigger, { asChild: true, children: jsxs(SidebarMenuButton, { size: "lg", className: "data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground", children: [jsx(OrgMark, { logoUrl: activeOrganization?.logoUrl }), jsx("span", { className: "truncate font-semibold text-sm flex-1 min-w-0 text-left", children: activeOrganization?.name || "เลือกองค์กร" }), isPending ? (jsx(Loader2, { className: "ml-auto size-4 animate-spin" })) : (jsx(ChevronsUpDown, { className: "ml-auto size-4" }))] }) }), jsxs(DropdownMenuContent, { className: "w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg", align: "start", side: isMobile ? "bottom" : "right", sideOffset: 4, children: [jsx(DropdownMenuLabel, { className: "text-xs text-muted-foreground", children: "\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" }), organizations.map(org => (jsxs(DropdownMenuItem, { onClick: () => handleOrganizationChange(org), disabled: isPending, className: cn("gap-2 p-2 cursor-pointer", String(activeOrganization?.id) === String(org.id) &&
1990
+ "bg-accent"), children: [jsx(OrgMark, { logoUrl: org.logoUrl, size: "sm" }), jsx("span", { className: "truncate", children: org.name })] }, org.id))), jsx(DropdownMenuSeparator, {}), jsxs(DropdownMenuItem, { className: "gap-2 p-2 cursor-pointer", onClick: () => router.push(registerOrganizationHref), children: [jsx("div", { className: "flex size-6 items-center justify-center rounded-md border bg-transparent", children: jsx(Plus, { className: "size-4" }) }), jsx("span", { className: "text-muted-foreground font-medium", children: "\u0E40\u0E1E\u0E34\u0E48\u0E21\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" })] })] })] }) }) }));
1991
+ }
1992
+
1993
+ function SidebarHeader({ children, className }) {
1994
+ return jsx("div", { className: cn("px-2 py-3", className), children: children });
1995
+ }
1996
+
1997
+ function AppSidebar({ modules, tools, activeModule, orgCode, approvalCounts, closeOnNavigation = false, highlightActiveModule = false, header, footer, bottom, chrome = "brand", className, }) {
1998
+ const { open } = useSidebar();
1999
+ const modulesWithActiveState = React.useMemo(() => modules.map(m => ({
2000
+ ...m,
2001
+ isActive: m.id === activeModule,
2002
+ })), [modules, activeModule]);
2003
+ if (!open)
2004
+ return null;
2005
+ if (chrome === "workspace") {
2006
+ return (jsxs("div", { className: cn("flex h-screen w-[244px] shrink-0 flex-col overflow-hidden border-r border-sidebar-border bg-sidebar text-sidebar-foreground",
2007
+ // As a drawer (≤1024px) it floats over the page and needs the lift.
2008
+ "max-[1024px]:shadow-2xl", className), children: [header ? jsx("div", { className: "shrink-0 px-[18px] pt-8 pb-6", children: header }) : null, jsxs(SidebarContent, { className: "flex-1 overflow-y-auto px-[18px]", children: [jsx(NavMain, { items: modulesWithActiveState, orgCode: orgCode, approvalCounts: approvalCounts, closeOnNavigation: closeOnNavigation, chrome: "workspace", highlightActiveModule: highlightActiveModule }), jsx(NavTools, { tools: tools, closeOnNavigation: closeOnNavigation })] }), bottom ? jsx("div", { className: "shrink-0 px-[18px] pt-6", children: bottom }) : null, jsx("div", { className: "shrink-0 px-[18px] pb-3", children: footer })] }));
2009
+ }
2010
+ return (jsxs("div", { className: 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: [jsx(SidebarHeader, { children: header }), jsxs(SidebarContent, { className: "pl-2 pr-2 flex-1 overflow-y-auto", children: [jsx(NavMain, { items: modulesWithActiveState, orgCode: orgCode, approvalCounts: approvalCounts, closeOnNavigation: closeOnNavigation }), jsx(NavTools, { tools: tools, closeOnNavigation: closeOnNavigation })] }), jsx(SidebarFooter, { className: "pl-2 pr-2", children: footer })] }));
2011
+ }
2012
+
2013
+ /**
2014
+ * Header ink as CSS variables. Header-borne pieces — breadcrumb, sidebar
2015
+ * trigger, the org switcher, the app's own page title — read these, with
2016
+ * white as the fallback, so the brand chrome looks exactly as before.
2017
+ */
2018
+ const WORKSPACE_HEADER_VARS = {
2019
+ "--header-fg": "#fff",
2020
+ "--header-fg-muted": "rgb(255 255 255 / 0.78)",
2021
+ "--header-hover": "rgb(255 255 255 / 0.12)",
2022
+ "--header-title-px": "0px",
2023
+ "--header-title-py": "0px",
2024
+ };
2025
+ const WORKSPACE_LIGHT_HEADER_VARS = {
2026
+ "--header-fg": "var(--foreground)",
2027
+ "--header-fg-muted": "var(--muted-foreground)",
2028
+ "--header-hover": "var(--accent)",
2029
+ "--header-mark-bg": "var(--accent)",
2030
+ "--header-mark-fg": "var(--primary)",
2031
+ "--header-mark-border": "transparent",
2032
+ "--header-title-px": "0px",
2033
+ "--header-title-py": "0px",
2034
+ };
2035
+
2036
+ /**
2037
+ * The ช่วยเหลือ button in the top bar. Drawn in the header's ink (the
2038
+ * --header-* variables of the workspace chrome, white on the brand chrome).
2039
+ */
2040
+ function HelpButton({ className }) {
2041
+ return (jsx("button", { type: "button", onClick: openHelp, "aria-label": "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D", title: "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D", className: "grid size-[34px] shrink-0 place-items-center rounded-lg text-[var(--header-fg-muted,#ffffffc7)] transition-colors hover:bg-[var(--header-hover,#ffffff1f)] hover:text-[var(--header-fg,#ffffff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--header-fg-muted,#ffffffc7)]" +
2042
+ (className ? ` ${className}` : ""), children: jsx(CircleHelp, { className: "size-5", strokeWidth: 1.65 }) }));
2043
+ }
1849
2044
 
1850
2045
  const hasPaginationShape = (payload) => {
1851
2046
  return ("total" in payload ||
@@ -1873,46 +2068,358 @@ const unwrapApiResponse = (payload) => {
1873
2068
  };
1874
2069
 
1875
2070
  /**
1876
- * The help channel lives on platform-api (POST /support/messages): every app
1877
- * already calls it with the user's token and the organisation header, and
1878
- * its CORS admits every app. The shared axios instance carries both and
2071
+ * The images on a help message (and on a follow-up): what was added, what is
2072
+ * uploading, what is ready to send, and what was refused and why.
2073
+ *
2074
+ * Plain state with no React and no network of its own — the uploader is
2075
+ * handed in — so the rules are tested on their own (help-attach.test.ts):
2076
+ * PNG, JPEG, WebP or GIF; at most 5 MB each; at most three; a paste or a drop
2077
+ * adds the same way the file picker does; ส่ง waits for what is uploading.
2078
+ *
2079
+ * Kept free of imports: the tests load it straight from source.
2080
+ */
2081
+ const HELP_IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp", "image/gif"];
2082
+ const HELP_IMAGE_ACCEPT = HELP_IMAGE_TYPES.join(",");
2083
+ const HELP_IMAGE_MAX_BYTES = 5 * 1024 * 1024;
2084
+ const HELP_IMAGE_MAX_COUNT = 3;
2085
+ /** The words for each refusal, per file. */
2086
+ const HELP_IMAGE_ERRORS = {
2087
+ tooLarge: "ใหญ่เกิน 5 MB",
2088
+ notImage: "ไม่ใช่ไฟล์รูปภาพ (PNG, JPEG, WebP หรือ GIF)",
2089
+ tooMany: "แนบได้ไม่เกิน 3 รูป",
2090
+ failed: "อัปโหลดไม่สำเร็จ",
2091
+ };
2092
+ /** The declared type, as one of the four, or "". */
2093
+ function helpImageType(type) {
2094
+ const t = (type || "").toLowerCase().split(";")[0].trim();
2095
+ if (t === "image/jpg" || t === "image/pjpeg")
2096
+ return "image/jpeg";
2097
+ return HELP_IMAGE_TYPES.includes(t) ? t : "";
2098
+ }
2099
+ /** Why a file cannot be attached, or null when it can. */
2100
+ function checkHelpImage(file) {
2101
+ if (!helpImageType(file.type) || file.size <= 0)
2102
+ return HELP_IMAGE_ERRORS.notImage;
2103
+ if (file.size > HELP_IMAGE_MAX_BYTES)
2104
+ return HELP_IMAGE_ERRORS.tooLarge;
2105
+ return null;
2106
+ }
2107
+ /**
2108
+ * The files a paste carries, when it is a paste of a picture: a screenshot
2109
+ * or a copied image. A paste that carries text is text — copying a cell from
2110
+ * a spreadsheet also puts a picture of it on the clipboard, and that is not
2111
+ * an attachment.
2112
+ */
2113
+ function filesFromPaste(data) {
2114
+ if (!data)
2115
+ return [];
2116
+ const text = data.getData?.("text/plain") ?? "";
2117
+ if (text.trim() !== "")
2118
+ return [];
2119
+ const out = [];
2120
+ if (data.files && data.files.length > 0) {
2121
+ for (let i = 0; i < data.files.length; i++)
2122
+ out.push(data.files[i]);
2123
+ return out;
2124
+ }
2125
+ if (data.items) {
2126
+ for (let i = 0; i < data.items.length; i++) {
2127
+ const it = data.items[i];
2128
+ if (it.kind !== "file")
2129
+ continue;
2130
+ const f = it.getAsFile();
2131
+ if (f)
2132
+ out.push(f);
2133
+ }
2134
+ }
2135
+ return out;
2136
+ }
2137
+ /** True while what is dragged over the dialog includes files. */
2138
+ function dragHasFiles(dt) {
2139
+ if (!dt?.types)
2140
+ return false;
2141
+ for (let i = 0; i < dt.types.length; i++)
2142
+ if (dt.types[i] === "Files")
2143
+ return true;
2144
+ return false;
2145
+ }
2146
+ /** The files a drop carries. */
2147
+ function filesFromDrop(dt) {
2148
+ const out = [];
2149
+ if (!dt?.files)
2150
+ return out;
2151
+ for (let i = 0; i < dt.files.length; i++)
2152
+ out.push(dt.files[i]);
2153
+ return out;
2154
+ }
2155
+ let seq = 0;
2156
+ /**
2157
+ * The images being attached to one message. Subscribe to redraw; every
2158
+ * change replaces items and rejections with new arrays.
2159
+ */
2160
+ class HelpAttachments {
2161
+ constructor(uploader, opts = {}) {
2162
+ Object.defineProperty(this, "list", {
2163
+ enumerable: true,
2164
+ configurable: true,
2165
+ writable: true,
2166
+ value: []
2167
+ });
2168
+ Object.defineProperty(this, "refused", {
2169
+ enumerable: true,
2170
+ configurable: true,
2171
+ writable: true,
2172
+ value: []
2173
+ });
2174
+ Object.defineProperty(this, "listeners", {
2175
+ enumerable: true,
2176
+ configurable: true,
2177
+ writable: true,
2178
+ value: new Set()
2179
+ });
2180
+ Object.defineProperty(this, "running", {
2181
+ enumerable: true,
2182
+ configurable: true,
2183
+ writable: true,
2184
+ value: new Map()
2185
+ });
2186
+ Object.defineProperty(this, "uploader", {
2187
+ enumerable: true,
2188
+ configurable: true,
2189
+ writable: true,
2190
+ value: void 0
2191
+ });
2192
+ Object.defineProperty(this, "opts", {
2193
+ enumerable: true,
2194
+ configurable: true,
2195
+ writable: true,
2196
+ value: void 0
2197
+ });
2198
+ this.uploader = uploader;
2199
+ this.opts = opts;
2200
+ }
2201
+ get items() {
2202
+ return this.list;
2203
+ }
2204
+ /** The files the last add, paste or drop refused, each with its reason. */
2205
+ get rejections() {
2206
+ return this.refused;
2207
+ }
2208
+ get max() {
2209
+ return this.opts.max ?? HELP_IMAGE_MAX_COUNT;
2210
+ }
2211
+ /** True while any image is still uploading. */
2212
+ get uploading() {
2213
+ return this.list.some(i => i.status === "uploading");
2214
+ }
2215
+ /** How many more can be added. */
2216
+ get room() {
2217
+ return Math.max(0, this.max - this.list.length);
2218
+ }
2219
+ subscribe(fn) {
2220
+ this.listeners.add(fn);
2221
+ return () => {
2222
+ this.listeners.delete(fn);
2223
+ };
2224
+ }
2225
+ emit() {
2226
+ this.listeners.forEach(l => l());
2227
+ }
2228
+ patch(key, change) {
2229
+ let changed = false;
2230
+ this.list = this.list.map(i => {
2231
+ if (i.key !== key)
2232
+ return i;
2233
+ changed = true;
2234
+ return { ...i, ...change };
2235
+ });
2236
+ if (changed)
2237
+ this.emit();
2238
+ }
2239
+ /**
2240
+ * Adds files: each is checked, the first ones that fit are uploaded at
2241
+ * once, and every one refused is named with its reason.
2242
+ */
2243
+ add(files) {
2244
+ const refused = [];
2245
+ const added = [];
2246
+ let room = this.room;
2247
+ for (const file of files) {
2248
+ const why = checkHelpImage(file);
2249
+ if (why) {
2250
+ refused.push({ name: file.name || "image", message: why });
2251
+ continue;
2252
+ }
2253
+ if (room <= 0) {
2254
+ refused.push({ name: file.name || "image", message: HELP_IMAGE_ERRORS.tooMany });
2255
+ continue;
2256
+ }
2257
+ room--;
2258
+ seq += 1;
2259
+ const item = {
2260
+ key: `img-${Date.now().toString(36)}-${seq}`,
2261
+ name: file.name || "image",
2262
+ size: file.size,
2263
+ preview: this.opts.preview ? this.opts.preview(file) : "",
2264
+ status: "uploading",
2265
+ progress: 0,
2266
+ id: null,
2267
+ error: null,
2268
+ };
2269
+ added.push({ item, file });
2270
+ }
2271
+ this.refused = refused;
2272
+ this.list = [...this.list, ...added.map(a => a.item)];
2273
+ this.emit();
2274
+ for (const { item, file } of added)
2275
+ this.start(item.key, file);
2276
+ }
2277
+ start(key, file) {
2278
+ const abort = new AbortController();
2279
+ const done = this.uploader
2280
+ .upload(file, {
2281
+ signal: abort.signal,
2282
+ onProgress: p => {
2283
+ if (!abort.signal.aborted)
2284
+ this.patch(key, { progress: Math.max(0, Math.min(1, p)) });
2285
+ },
2286
+ onId: id => {
2287
+ if (abort.signal.aborted) {
2288
+ this.uploader.discard?.(id);
2289
+ return;
2290
+ }
2291
+ this.patch(key, { id });
2292
+ },
2293
+ })
2294
+ .then(res => {
2295
+ if (abort.signal.aborted) {
2296
+ this.uploader.discard?.(res.id);
2297
+ return;
2298
+ }
2299
+ this.patch(key, { status: "ready", progress: 1, id: res.id, error: null });
2300
+ }, (err) => {
2301
+ if (abort.signal.aborted)
2302
+ return;
2303
+ const message = err instanceof Error && err.message ? err.message : HELP_IMAGE_ERRORS.failed;
2304
+ this.patch(key, { status: "failed", error: message });
2305
+ })
2306
+ .finally(() => {
2307
+ this.running.delete(key);
2308
+ });
2309
+ this.running.set(key, { abort, done });
2310
+ }
2311
+ /** Takes an image off: stops its upload and forgets it on the server. */
2312
+ remove(key) {
2313
+ const item = this.list.find(i => i.key === key);
2314
+ if (!item)
2315
+ return;
2316
+ const run = this.running.get(key);
2317
+ if (run)
2318
+ run.abort.abort();
2319
+ if (item.id)
2320
+ this.uploader.discard?.(item.id);
2321
+ if (item.preview)
2322
+ this.opts.revoke?.(item.preview);
2323
+ this.list = this.list.filter(i => i.key !== key);
2324
+ this.emit();
2325
+ }
2326
+ /** Marks the image platform-api refused on sending (it names it by id). */
2327
+ refuse(id, message) {
2328
+ const item = this.list.find(i => i.id === id);
2329
+ if (item)
2330
+ this.patch(item.key, { status: "failed", error: message });
2331
+ }
2332
+ dismissRejections() {
2333
+ if (this.refused.length === 0)
2334
+ return;
2335
+ this.refused = [];
2336
+ this.emit();
2337
+ }
2338
+ /**
2339
+ * Waits until nothing is uploading, then says what can be sent: the ids
2340
+ * of the ready images in the order they are shown, and the ones that
2341
+ * failed. ส่ง calls this, so a send never leaves without its images.
2342
+ */
2343
+ async settled() {
2344
+ while (this.running.size > 0) {
2345
+ await Promise.all(Array.from(this.running.values(), r => r.done));
2346
+ }
2347
+ return {
2348
+ ids: this.list.filter(i => i.status === "ready" && i.id).map(i => i.id),
2349
+ failed: this.list.filter(i => i.status === "failed"),
2350
+ };
2351
+ }
2352
+ /**
2353
+ * Empties the list. sent: the images went with a message — nothing to
2354
+ * forget on the server. Otherwise every upload made is discarded.
2355
+ */
2356
+ clear(sent = false) {
2357
+ for (const [, run] of this.running)
2358
+ run.abort.abort();
2359
+ for (const item of this.list) {
2360
+ if (!sent && item.id)
2361
+ this.uploader.discard?.(item.id);
2362
+ if (item.preview)
2363
+ this.opts.revoke?.(item.preview);
2364
+ }
2365
+ this.list = [];
2366
+ this.refused = [];
2367
+ this.emit();
2368
+ }
2369
+ }
2370
+
2371
+ /**
2372
+ * The help channel lives on platform-api (POST /support/messages). The shared
2373
+ * axios instance carries the user's token and the organisation header, and
1879
2374
  * refreshes a stale token.
1880
2375
  */
1881
- const DEFAULT_PLATFORM_API_URL = "https://platform-api.antniti.com/api/v1";
1882
2376
  function helpApiBase(app) {
1883
- const explicit = process.env.NEXT_PUBLIC_PLATFORM_API_URL?.trim();
1884
- if (explicit)
1885
- return explicit.replace(/\/$/, "");
1886
- // Platform's own API is platform-api.
1887
- if (app === "platform") {
1888
- const own = process.env.NEXT_PUBLIC_API_URL?.trim();
1889
- if (own)
1890
- return own.replace(/\/$/, "");
1891
- }
1892
- return DEFAULT_PLATFORM_API_URL;
2377
+ return platformApiBase(app);
1893
2378
  }
1894
- /** A failure the dialog shows as is. */
2379
+ /** The thread of m, oldest first, from whichever shape platform-api sent. */
2380
+ function helpThread(m) {
2381
+ if (m.thread)
2382
+ return m.thread;
2383
+ return m.replies.map((r, i) => ({ id: `${m.id}-${i}`, author: "staff", body: r.body, created_at: r.created_at, images: [] }));
2384
+ }
2385
+ /** A failure the dialog shows as is; attachmentId names the image it is about. */
1895
2386
  class HelpSendError extends Error {
1896
- constructor(message, status) {
2387
+ constructor(message, status, attachmentId) {
1897
2388
  super(message);
1898
2389
  Object.defineProperty(this, "status", {
1899
2390
  enumerable: true,
1900
2391
  configurable: true,
1901
2392
  writable: true,
1902
- value: status
2393
+ value: void 0
2394
+ });
2395
+ Object.defineProperty(this, "attachmentId", {
2396
+ enumerable: true,
2397
+ configurable: true,
2398
+ writable: true,
2399
+ value: void 0
1903
2400
  });
1904
2401
  this.name = "HelpSendError";
2402
+ this.status = status;
2403
+ this.attachmentId = attachmentId;
1905
2404
  }
1906
2405
  }
2406
+ function attachmentIdOf(err) {
2407
+ const data = err?.response?.data?.data;
2408
+ return typeof data?.attachment_id === "string" ? data.attachment_id : undefined;
2409
+ }
1907
2410
  function friendly(err, fallback) {
1908
2411
  const e = toApiError(err);
2412
+ const attachmentId = attachmentIdOf(err);
1909
2413
  if (e.status === 429) {
1910
- return new HelpSendError("ส่งบ่อยเกินไป กรุณาลองใหม่ภายหลัง", 429);
2414
+ return new HelpSendError(e.message && e.message !== "Request failed with status code 429" ? e.message : "ส่งบ่อยเกินไป กรุณาลองใหม่ภายหลัง", 429);
1911
2415
  }
1912
- if (e.status && e.status >= 400 && e.status < 500 && e.message && e.status !== 401 && e.status !== 404) {
2416
+ if (e.status && e.status >= 400 && e.status < 500 && e.message && e.status !== 401 && (e.status !== 404 || attachmentId)) {
2417
+ return new HelpSendError(e.message, e.status, attachmentId);
2418
+ }
2419
+ if (e.status === 503 && e.message) {
1913
2420
  return new HelpSendError(e.message, e.status);
1914
2421
  }
1915
- return new HelpSendError(fallback, e.status);
2422
+ return new HelpSendError(fallback, e.status, attachmentId);
1916
2423
  }
1917
2424
  async function sendHelpMessage(input) {
1918
2425
  try {
@@ -1921,6 +2428,7 @@ async function sendHelpMessage(input) {
1921
2428
  message: input.message,
1922
2429
  app: input.app === "other" ? "" : input.app,
1923
2430
  context: input.context,
2431
+ attachment_ids: input.attachmentIds ?? [],
1924
2432
  });
1925
2433
  return unwrapApiResponse(res.data);
1926
2434
  }
@@ -1928,6 +2436,83 @@ async function sendHelpMessage(input) {
1928
2436
  throw friendly(err, "ส่งไม่สำเร็จ");
1929
2437
  }
1930
2438
  }
2439
+ /** One of the caller's messages by id or reference number ("H1004"). */
2440
+ async function getMyHelpMessage(app, key) {
2441
+ try {
2442
+ const res = await instance.get(`${helpApiBase(app)}/support/messages/${encodeURIComponent(key)}`);
2443
+ return unwrapApiResponse(res.data);
2444
+ }
2445
+ catch (err) {
2446
+ if (toApiError(err).status === 404)
2447
+ throw new HelpSendError("ไม่พบข้อความ", 404);
2448
+ throw friendly(err, "โหลดข้อมูลไม่สำเร็จ");
2449
+ }
2450
+ }
2451
+ /** The author writes again on their own message. */
2452
+ async function sendHelpFollowUp(input) {
2453
+ try {
2454
+ const res = await instance.post(`${helpApiBase(input.app)}/support/messages/${encodeURIComponent(input.key)}/replies`, { message: input.message, attachment_ids: input.attachmentIds ?? [] });
2455
+ return unwrapApiResponse(res.data);
2456
+ }
2457
+ catch (err) {
2458
+ throw friendly(err, "ส่งไม่สำเร็จ");
2459
+ }
2460
+ }
2461
+ /** PUTs the file to the presigned URL, reporting progress. No credentials go with it. */
2462
+ function putFile(ticket, file, onProgress, signal) {
2463
+ return new Promise((resolve, reject) => {
2464
+ const xhr = new XMLHttpRequest();
2465
+ xhr.open(ticket.method || "PUT", ticket.upload_url);
2466
+ for (const [k, v] of Object.entries(ticket.headers ?? {}))
2467
+ xhr.setRequestHeader(k, v);
2468
+ xhr.upload.onprogress = e => {
2469
+ if (e.lengthComputable && e.total > 0)
2470
+ onProgress(e.loaded / e.total);
2471
+ };
2472
+ xhr.onload = () => (xhr.status >= 200 && xhr.status < 300 ? resolve() : reject(new HelpSendError("อัปโหลดไม่สำเร็จ", xhr.status)));
2473
+ xhr.onerror = () => reject(new HelpSendError("อัปโหลดไม่สำเร็จ"));
2474
+ xhr.onabort = () => reject(new HelpSendError("ยกเลิกแล้ว"));
2475
+ if (signal.aborted) {
2476
+ reject(new HelpSendError("ยกเลิกแล้ว"));
2477
+ return;
2478
+ }
2479
+ signal.addEventListener("abort", () => xhr.abort(), { once: true });
2480
+ xhr.send(file);
2481
+ });
2482
+ }
2483
+ /**
2484
+ * The uploader the dialog uses: an upload URL from platform-api, the bytes
2485
+ * straight to the bucket, then platform-api reads them to say whether they
2486
+ * are an image it will take.
2487
+ */
2488
+ function helpUploader(app) {
2489
+ const base = () => helpApiBase(app);
2490
+ return {
2491
+ async upload(file, hooks) {
2492
+ let ticket;
2493
+ try {
2494
+ const res = await instance.post(`${base()}/support/attachments`, { file_name: file.name, content_type: helpImageType(file.type), size: file.size }, { signal: hooks.signal });
2495
+ ticket = unwrapApiResponse(res.data);
2496
+ }
2497
+ catch (err) {
2498
+ throw friendly(err, "อัปโหลดไม่สำเร็จ");
2499
+ }
2500
+ hooks.onId(ticket.id);
2501
+ hooks.onProgress(0.02);
2502
+ await putFile(ticket, file, p => hooks.onProgress(0.02 + p * 0.93), hooks.signal);
2503
+ try {
2504
+ await instance.post(`${base()}/support/attachments/${ticket.id}/check`, null, { signal: hooks.signal });
2505
+ }
2506
+ catch (err) {
2507
+ throw friendly(err, "อัปโหลดไม่สำเร็จ");
2508
+ }
2509
+ return { id: ticket.id };
2510
+ },
2511
+ discard(id) {
2512
+ void instance.delete(`${base()}/support/attachments/${encodeURIComponent(id)}`).catch(() => undefined);
2513
+ },
2514
+ };
2515
+ }
1931
2516
  async function listMyHelpMessages(app) {
1932
2517
  try {
1933
2518
  const res = await instance.get(`${helpApiBase(app)}/support/messages`, {
@@ -1941,6 +2526,167 @@ async function listMyHelpMessages(app) {
1941
2526
  }
1942
2527
  }
1943
2528
 
2529
+ /** One message's images, uploading as they are added. */
2530
+ function useHelpAttachments(app) {
2531
+ const attach = React.useMemo(() => new HelpAttachments(helpUploader(app), {
2532
+ preview: f => typeof URL !== "undefined" && typeof URL.createObjectURL === "function" && f instanceof Blob
2533
+ ? URL.createObjectURL(f)
2534
+ : "",
2535
+ revoke: u => {
2536
+ if (u.startsWith("blob:"))
2537
+ URL.revokeObjectURL(u);
2538
+ },
2539
+ }), [app]);
2540
+ const snap = React.useRef({
2541
+ items: attach.items,
2542
+ rejections: attach.rejections,
2543
+ });
2544
+ const read = React.useCallback(() => {
2545
+ if (snap.current.items !== attach.items || snap.current.rejections !== attach.rejections) {
2546
+ snap.current = { items: attach.items, rejections: attach.rejections };
2547
+ }
2548
+ return snap.current;
2549
+ }, [attach]);
2550
+ const state = React.useSyncExternalStore(React.useCallback(l => attach.subscribe(l), [attach]), read, read);
2551
+ // Leaving the page: what was uploaded and not sent is forgotten.
2552
+ React.useEffect(() => () => attach.clear(), [attach]);
2553
+ return { attach, items: state.items, rejections: state.rejections };
2554
+ }
2555
+ /**
2556
+ * Paste and drop for a form that takes images: spread onto the form. A
2557
+ * pasted screenshot or a dropped file is added like a picked one.
2558
+ */
2559
+ function useHelpImageDrop(attach, enabled) {
2560
+ const [over, setOver] = React.useState(false);
2561
+ const depth = React.useRef(0);
2562
+ const props = {
2563
+ onPaste: (e) => {
2564
+ if (!enabled)
2565
+ return;
2566
+ const files = filesFromPaste(e.clipboardData);
2567
+ if (files.length === 0)
2568
+ return;
2569
+ e.preventDefault();
2570
+ attach.add(files);
2571
+ },
2572
+ onDragEnter: (e) => {
2573
+ if (!enabled || !dragHasFiles(e.dataTransfer))
2574
+ return;
2575
+ e.preventDefault();
2576
+ depth.current += 1;
2577
+ setOver(true);
2578
+ },
2579
+ onDragOver: (e) => {
2580
+ if (!enabled || !dragHasFiles(e.dataTransfer))
2581
+ return;
2582
+ e.preventDefault();
2583
+ e.dataTransfer.dropEffect = "copy";
2584
+ },
2585
+ onDragLeave: (e) => {
2586
+ if (!enabled || !dragHasFiles(e.dataTransfer))
2587
+ return;
2588
+ depth.current = Math.max(0, depth.current - 1);
2589
+ if (depth.current === 0)
2590
+ setOver(false);
2591
+ },
2592
+ onDrop: (e) => {
2593
+ if (!enabled)
2594
+ return;
2595
+ depth.current = 0;
2596
+ setOver(false);
2597
+ const files = filesFromDrop(e.dataTransfer);
2598
+ if (files.length === 0)
2599
+ return;
2600
+ e.preventDefault();
2601
+ attach.add(files);
2602
+ },
2603
+ };
2604
+ return { over, props };
2605
+ }
2606
+ function kb(size) {
2607
+ if (size >= 1024 * 1024)
2608
+ return `${(size / (1024 * 1024)).toFixed(1)} MB`;
2609
+ return `${Math.max(1, Math.round(size / 1024))} KB`;
2610
+ }
2611
+ /**
2612
+ * แนบรูป: the button that opens the file picker (on a phone, the photo
2613
+ * library or the camera), the thumbnails with their progress and a remove
2614
+ * button each, and a line per file that was refused.
2615
+ */
2616
+ function HelpAttachField({ state, disabled, id }) {
2617
+ const { attach, items, rejections } = state;
2618
+ const input = React.useRef(null);
2619
+ const full = items.length >= attach.max;
2620
+ const problems = [
2621
+ ...items.filter(i => i.status === "failed").map(i => ({ key: i.key, name: i.name, message: i.error ?? "" })),
2622
+ ...rejections.map((r, n) => ({ key: `refused-${n}`, name: r.name, message: r.message })),
2623
+ ];
2624
+ return (jsxs("div", { className: "grid gap-2", children: [jsxs("div", { className: "flex flex-wrap items-start gap-2", children: [items.map(it => (jsx(AttachThumb, { item: it, onRemove: () => attach.remove(it.key), disabled: disabled }, it.key))), jsxs(Button, { type: "button", variant: "outline", size: "sm", disabled: disabled || full, onClick: () => {
2625
+ attach.dismissRejections();
2626
+ input.current?.click();
2627
+ }, "aria-controls": id, className: items.length > 0 ? "h-16 w-16 flex-col gap-1 p-1 text-[11px]" : "gap-1.5", children: [jsx(ImagePlus, { className: "h-4 w-4" }), "\u0E41\u0E19\u0E1A\u0E23\u0E39\u0E1B"] }), jsx("input", { ref: input, id: id, type: "file", accept: HELP_IMAGE_ACCEPT, multiple: true, tabIndex: -1, "aria-hidden": "true", className: "sr-only", onChange: e => {
2628
+ const files = e.currentTarget.files;
2629
+ if (files && files.length > 0)
2630
+ attach.add(Array.from(files));
2631
+ e.currentTarget.value = "";
2632
+ } })] }), problems.length > 0 ? (jsx("ul", { role: "alert", className: "grid gap-0.5 text-xs text-destructive", children: problems.map(p => (jsxs("li", { className: "break-words", children: [p.name, ": ", p.message] }, p.key))) })) : null] }));
2633
+ }
2634
+ function AttachThumb({ item, onRemove, disabled }) {
2635
+ const failed = item.status === "failed";
2636
+ return (jsxs("figure", { className: "relative m-0 w-16", children: [jsxs("div", { className: "relative h-16 w-16 overflow-hidden rounded-md border bg-muted " +
2637
+ (failed ? "border-destructive" : "border-border"), children: [item.preview ? (
2638
+ // eslint-disable-next-line @next/next/no-img-element
2639
+ jsx("img", { src: item.preview, alt: item.name, className: "h-full w-full object-cover" })) : null, item.status === "uploading" ? (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: jsx("div", { className: "h-full bg-primary transition-[width]", style: { width: `${Math.round(item.progress * 100)}%` } }) })) : null] }), 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: jsx(X, { className: "h-3 w-3" }) }), jsx("figcaption", { className: "mt-0.5 truncate text-[10px] leading-tight text-muted-foreground", title: item.name, children: failed ? jsx("span", { className: "text-destructive", children: "\u0E44\u0E21\u0E48\u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08" }) : kb(item.size) })] }));
2640
+ }
2641
+ /** Images already sent: thumbnails that open the full image. */
2642
+ function HelpImageStrip({ images, size = "md" }) {
2643
+ if (!images || images.length === 0)
2644
+ return null;
2645
+ const box = size === "sm" ? "h-12 w-12" : "h-20 w-20";
2646
+ return (jsx("ul", { className: "mt-2 flex flex-wrap gap-2", children: images.map(img => (jsx("li", { children: img.url ? (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: jsx("img", { src: img.url, alt: img.file_name || "รูปที่แนบ", loading: "lazy", className: "h-full w-full object-cover" }) })) : (jsx("span", { className: `block ${box} rounded-md border border-border bg-muted`, "aria-label": img.file_name })) }, img.id))) }));
2647
+ }
2648
+
2649
+ /**
2650
+ * The ตอบกลับ link in a staff answer e-mail opens the app with ?help=H1004:
2651
+ * the shell's ช่วยเหลือ dialog then opens on that message. Any app page
2652
+ * with the shell's layout works; the e-mail uses Platform.
2653
+ *
2654
+ * Kept free of imports: the tests load it straight from source.
2655
+ */
2656
+ const HELP_LINK_PARAM = "help";
2657
+ const REF = /^H\d{1,12}$/;
2658
+ /** The message a page address asks the dialog to open, or null. */
2659
+ function helpRefFromSearch(search) {
2660
+ if (!search)
2661
+ return null;
2662
+ let params;
2663
+ try {
2664
+ params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
2665
+ }
2666
+ catch {
2667
+ return null;
2668
+ }
2669
+ const raw = (params.get(HELP_LINK_PARAM) ?? "").trim().toUpperCase();
2670
+ return REF.test(raw) ? raw : null;
2671
+ }
2672
+ /**
2673
+ * The same address without ?help=, so a reload or a shared link does not
2674
+ * open the dialog again. Everything else in it is kept.
2675
+ */
2676
+ function withoutHelpParam(href) {
2677
+ let u;
2678
+ try {
2679
+ u = new URL(href);
2680
+ }
2681
+ catch {
2682
+ return href;
2683
+ }
2684
+ if (!u.searchParams.has(HELP_LINK_PARAM))
2685
+ return href;
2686
+ u.searchParams.delete(HELP_LINK_PARAM);
2687
+ return u.toString();
2688
+ }
2689
+
1944
2690
  const KINDS = [
1945
2691
  { value: "help", label: "ขอความช่วยเหลือ", placeholder: "ต้องการให้ช่วยเรื่องอะไร" },
1946
2692
  { value: "problem", label: "แจ้งปัญหา", placeholder: "เกิดอะไรขึ้น และกำลังทำอะไรอยู่" },
@@ -1958,6 +2704,7 @@ const STATUS_TONE = {
1958
2704
  closed: "bg-muted text-muted-foreground",
1959
2705
  };
1960
2706
  const MAX_MESSAGE = 4000;
2707
+ const LINK_BUTTON = "rounded text-sm text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring";
1961
2708
  function when(iso) {
1962
2709
  const d = new Date(iso);
1963
2710
  if (Number.isNaN(d.getTime()))
@@ -1971,10 +2718,34 @@ function when(iso) {
1971
2718
  hour12: false,
1972
2719
  });
1973
2720
  }
2721
+ function errorText(e, fallback) {
2722
+ return e instanceof Error && e.message ? e.message : fallback;
2723
+ }
2724
+ /**
2725
+ * Sends a message with its images: waits for what is still uploading, then
2726
+ * sends; an image platform-api refuses is marked on its thumbnail.
2727
+ */
2728
+ async function sendWithImages(state, send) {
2729
+ const { ids, failed } = await state.attach.settled();
2730
+ if (failed.length > 0)
2731
+ throw new HelpSendError("มีรูปที่อัปโหลดไม่สำเร็จ");
2732
+ try {
2733
+ const res = await send(ids);
2734
+ state.attach.clear(true);
2735
+ return res;
2736
+ }
2737
+ catch (err) {
2738
+ if (err instanceof HelpSendError && err.attachmentId)
2739
+ state.attach.refuse(err.attachmentId, err.message);
2740
+ throw err;
2741
+ }
2742
+ }
1974
2743
  /**
1975
2744
  * The ช่วยเหลือ dialog. AppLayoutShell mounts one; the top-bar button and
1976
- * the user menu open it with openHelp(). It sends to platform-api, which
1977
- * puts the message in the back office's queue on admin.antniti.com.
2745
+ * the user menu open it with openHelp(), and a page address with
2746
+ * ?help=H1004 (the ตอบกลับ button in a staff answer) opens it on that
2747
+ * message. It sends to platform-api, which puts the message in the back
2748
+ * office's queue on admin.antniti.com.
1978
2749
  */
1979
2750
  function HelpDialogHost({ app, orgCode }) {
1980
2751
  const open = useHelpOpen();
@@ -1987,24 +2758,60 @@ function HelpDialogHost({ app, orgCode }) {
1987
2758
  const [sent, setSent] = React.useState(null);
1988
2759
  const [mine, setMine] = React.useState(null);
1989
2760
  const [mineError, setMineError] = React.useState(null);
2761
+ const [threadKey, setThreadKey] = React.useState(null);
2762
+ const [thread, setThread] = React.useState(null);
2763
+ const [threadError, setThreadError] = React.useState(null);
2764
+ const [followDraft, setFollowDraft] = React.useState("");
2765
+ const [following, setFollowing] = React.useState(false);
2766
+ const [followError, setFollowError] = React.useState(null);
1990
2767
  React.useEffect(() => registerHelpHost(), []);
2768
+ // The ตอบกลับ link: ?help=H1004 opens the dialog on that message. The
2769
+ // parameter comes off the address so a reload does not open it again.
2770
+ React.useEffect(() => {
2771
+ if (typeof window === "undefined")
2772
+ return;
2773
+ const ref = helpRefFromSearch(window.location.search);
2774
+ if (!ref)
2775
+ return;
2776
+ window.history.replaceState(null, "", withoutHelpParam(window.location.href));
2777
+ openHelpOn(ref);
2778
+ }, []);
1991
2779
  // Read when the dialog opens: the page and the window as they are now.
1992
2780
  // open is a dependency on purpose: the app is read again each time.
1993
2781
  const appId = React.useMemo(() => detectHelpApp(app), [app, open]);
1994
2782
  const context = React.useMemo(() => (open ? collectHelpContext(orgCode) : null), [open, orgCode]);
2783
+ const images = useHelpAttachments(appId);
2784
+ const followImages = useHelpAttachments(appId);
2785
+ const formDrop = useHelpImageDrop(images.attach, view === "form" && !sending);
2786
+ const followDrop = useHelpImageDrop(followImages.attach, view === "thread" && !following && !!thread);
2787
+ const openThread = React.useCallback((key) => {
2788
+ setView("thread");
2789
+ setThreadKey(key);
2790
+ setThread(null);
2791
+ setThreadError(null);
2792
+ setFollowError(null);
2793
+ getMyHelpMessage(appId, key)
2794
+ .then(setThread)
2795
+ .catch((e) => setThreadError(errorText(e, "โหลดข้อมูลไม่สำเร็จ")));
2796
+ }, [appId]);
1995
2797
  React.useEffect(() => {
1996
- if (open) {
2798
+ if (!open)
2799
+ return;
2800
+ setError(null);
2801
+ const key = pendingHelpRef();
2802
+ if (key)
2803
+ openThread(key);
2804
+ else
1997
2805
  setView("form");
1998
- setError(null);
1999
- }
2000
- }, [open]);
2806
+ }, [open, openThread]);
2001
2807
  const loadMine = React.useCallback(() => {
2808
+ clearPendingHelpRef();
2002
2809
  setView("mine");
2003
2810
  setMine(null);
2004
2811
  setMineError(null);
2005
2812
  listMyHelpMessages(appId)
2006
2813
  .then(setMine)
2007
- .catch((e) => setMineError(e instanceof Error ? e.message : "โหลดข้อมูลไม่สำเร็จ"));
2814
+ .catch((e) => setMineError(errorText(e, "โหลดข้อมูลไม่สำเร็จ")));
2008
2815
  }, [appId]);
2009
2816
  const trimmed = message.trim();
2010
2817
  const canSend = trimmed.length > 0 && !sending;
@@ -2015,25 +2822,61 @@ function HelpDialogHost({ app, orgCode }) {
2015
2822
  setSending(true);
2016
2823
  setError(null);
2017
2824
  try {
2018
- const res = await sendHelpMessage({ app: appId, kind, message: trimmed, context });
2825
+ const res = await sendWithImages(images, ids => sendHelpMessage({ app: appId, kind, message: trimmed, context, attachmentIds: ids }));
2019
2826
  setSent(res);
2020
2827
  setMessage("");
2021
2828
  setKind("help");
2022
2829
  setView("sent");
2023
2830
  }
2024
2831
  catch (err) {
2025
- setError(err instanceof Error ? err.message : "ส่งไม่สำเร็จ");
2832
+ setError(errorText(err, "ส่งไม่สำเร็จ"));
2026
2833
  }
2027
2834
  finally {
2028
2835
  setSending(false);
2029
2836
  }
2030
2837
  };
2838
+ const followTrimmed = followDraft.trim();
2839
+ const sendFollowUp = async (e) => {
2840
+ e?.preventDefault();
2841
+ if (!thread || !followTrimmed || following)
2842
+ return;
2843
+ setFollowing(true);
2844
+ setFollowError(null);
2845
+ try {
2846
+ await sendWithImages(followImages, ids => sendHelpFollowUp({ app: appId, key: thread.id, message: followTrimmed, attachmentIds: ids }));
2847
+ setFollowDraft("");
2848
+ setMine(null);
2849
+ setThread(await getMyHelpMessage(appId, thread.id));
2850
+ }
2851
+ catch (err) {
2852
+ setFollowError(errorText(err, "ส่งไม่สำเร็จ"));
2853
+ }
2854
+ finally {
2855
+ setFollowing(false);
2856
+ }
2857
+ };
2031
2858
  const email = session?.user?.email ?? null;
2032
2859
  const placeholder = KINDS.find(k => k.value === kind)?.placeholder;
2033
- return (jsx(Dialog, { open: open, onOpenChange: o => (o ? openHelp() : closeHelp()), children: jsxs(DialogContent, { className: "max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-lg", children: [view === "form" ? (jsxs("form", { onSubmit: send, className: "grid gap-4", children: [jsx(DialogHeader, { children: jsx(DialogTitle, { children: "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D" }) }), jsxs("div", { className: "grid gap-2", children: [jsx(Label, { id: "erp-help-kind", children: "\u0E1B\u0E23\u0E30\u0E40\u0E20\u0E17" }), jsx("div", { role: "radiogroup", "aria-labelledby": "erp-help-kind", className: "flex gap-1 rounded-lg bg-muted p-1", children: KINDS.map(k => (jsx("button", { type: "button", role: "radio", "aria-checked": kind === k.value, onClick: () => setKind(k.value), className: "flex-auto whitespace-nowrap rounded-md px-2 py-2 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:text-sm " +
2860
+ const dropRing = (over) => (over ? " rounded-lg ring-2 ring-primary ring-offset-4 ring-offset-background" : "");
2861
+ return (jsx(Dialog, { open: open, onOpenChange: o => (o ? openHelp() : closeHelp()), children: jsxs(DialogContent, { className: "max-h-[calc(100dvh-2rem)] overflow-y-auto sm:max-w-lg", children: [view === "form" ? (jsxs("form", { onSubmit: send, className: "grid gap-4" + dropRing(formDrop.over), ...formDrop.props, children: [jsx(DialogHeader, { children: jsx(DialogTitle, { children: "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D" }) }), jsxs("div", { className: "grid gap-2", children: [jsx(Label, { id: "erp-help-kind", children: "\u0E1B\u0E23\u0E30\u0E40\u0E20\u0E17" }), jsx("div", { role: "radiogroup", "aria-labelledby": "erp-help-kind", className: "flex gap-1 rounded-lg bg-muted p-1", children: KINDS.map(k => (jsx("button", { type: "button", role: "radio", "aria-checked": kind === k.value, onClick: () => setKind(k.value), className: "flex-auto whitespace-nowrap rounded-md px-2 py-2 text-xs font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring sm:text-sm " +
2034
2862
  (kind === k.value
2035
2863
  ? "bg-background text-foreground shadow-sm"
2036
- : "text-muted-foreground hover:text-foreground"), children: k.label }, k.value))) })] }), jsxs("div", { className: "grid gap-2", children: [jsx(Label, { htmlFor: "erp-help-message", children: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21" }), jsx(Textarea, { id: "erp-help-message", rows: 6, maxLength: MAX_MESSAGE, required: true, value: message, placeholder: placeholder, onChange: e => setMessage(e.target.value), className: "min-h-[140px] resize-y" })] }), context ? (jsxs("p", { className: "break-words text-xs leading-relaxed text-muted-foreground", children: ["\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E17\u0E35\u0E48\u0E41\u0E19\u0E1A: ", describeAttached(appId, context, email)] })) : null, error ? (jsx("p", { role: "alert", className: "text-sm text-destructive", children: error })) : null, jsxs(DialogFooter, { className: "flex-row items-center gap-2 sm:justify-between", children: [jsx("button", { type: "button", onClick: loadMine, className: "mr-auto text-sm text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded", children: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E17\u0E35\u0E48\u0E2A\u0E48\u0E07\u0E41\u0E25\u0E49\u0E27" }), jsxs("div", { className: "flex gap-2", children: [jsx(Button, { type: "button", variant: "outline", onClick: closeHelp, children: "\u0E22\u0E01\u0E40\u0E25\u0E34\u0E01" }), jsxs(Button, { type: "submit", disabled: !canSend, children: [sending ? jsx(Loader2, { className: "mr-1 h-4 w-4 animate-spin" }) : null, "\u0E2A\u0E48\u0E07"] })] })] })] })) : null, view === "sent" && sent ? (jsxs("div", { className: "grid gap-4", children: [jsxs("div", { className: "flex flex-col items-center gap-2 py-4 text-center", children: [jsx(CircleCheck, { className: "h-12 w-12 text-primary", strokeWidth: 1.6 }), jsx(DialogTitle, { className: "text-lg font-semibold", children: "\u0E2A\u0E48\u0E07\u0E41\u0E25\u0E49\u0E27" }), jsxs("p", { className: "text-sm text-muted-foreground", children: ["\u0E40\u0E25\u0E02\u0E17\u0E35\u0E48\u0E2D\u0E49\u0E32\u0E07\u0E2D\u0E34\u0E07", " ", jsx("span", { className: "font-mono text-base font-semibold text-foreground", children: sent.ref })] })] }), jsxs(DialogFooter, { className: "flex-row items-center gap-2 sm:justify-between", children: [jsx("button", { type: "button", onClick: loadMine, className: "mr-auto text-sm text-primary hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded", children: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E17\u0E35\u0E48\u0E2A\u0E48\u0E07\u0E41\u0E25\u0E49\u0E27" }), jsx(Button, { type: "button", onClick: closeHelp, children: "\u0E1B\u0E34\u0E14" })] })] })) : null, view === "mine" ? (jsxs("div", { className: "grid gap-4", children: [jsx(DialogHeader, { children: jsxs(DialogTitle, { className: "flex items-center gap-2", children: [jsx("button", { type: "button", "aria-label": "\u0E01\u0E25\u0E31\u0E1A", onClick: () => setView("form"), className: "-ml-1 grid size-8 place-items-center rounded-md text-muted-foreground hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", children: jsx(ArrowLeft, { className: "h-4 w-4" }) }), "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E17\u0E35\u0E48\u0E2A\u0E48\u0E07\u0E41\u0E25\u0E49\u0E27"] }) }), mine === null && !mineError ? (jsxs("div", { className: "flex items-center gap-2 py-6 text-sm text-muted-foreground", children: [jsx(Loader2, { className: "h-4 w-4 animate-spin" }), "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14"] })) : null, mineError ? (jsx("p", { role: "alert", className: "text-sm text-destructive", children: mineError })) : null, mine && mine.length === 0 ? (jsx("p", { className: "py-6 text-sm text-muted-foreground", children: "\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21" })) : null, mine && mine.length > 0 ? (jsx("ul", { className: "grid gap-3", children: mine.map(m => (jsxs("li", { className: "rounded-lg border border-border p-3", children: [jsxs("div", { className: "flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground", children: [jsx("span", { className: "font-mono font-semibold text-foreground", children: m.ref }), jsx("span", { children: KIND_LABEL[m.kind] ?? m.kind }), jsx("span", { children: when(m.created_at) }), jsx("span", { className: `ml-auto rounded-full px-2 py-0.5 font-medium ${STATUS_TONE[m.status] ?? ""}`, children: STATUS_LABEL[m.status] ?? m.status })] }), jsx("p", { className: "mt-2 line-clamp-4 whitespace-pre-wrap break-words text-sm", children: m.message }), m.replies.map((r, i) => (jsxs("div", { className: "mt-2 rounded-md bg-muted px-3 py-2", children: [jsxs("p", { className: "text-xs text-muted-foreground", children: ["\u0E17\u0E35\u0E21 AntNiti \u00B7 ", when(r.created_at)] }), jsx("p", { className: "mt-1 whitespace-pre-wrap break-words text-sm", children: r.body })] }, i)))] }, m.id))) })) : null] })) : null] }) }));
2864
+ : "text-muted-foreground hover:text-foreground"), children: k.label }, k.value))) })] }), jsxs("div", { className: "grid gap-2", children: [jsx(Label, { htmlFor: "erp-help-message", children: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21" }), jsx(Textarea, { id: "erp-help-message", rows: 6, maxLength: MAX_MESSAGE, required: true, value: message, placeholder: placeholder, onChange: e => setMessage(e.target.value), className: "min-h-[140px] resize-y" })] }), jsx(HelpAttachField, { state: images, disabled: sending, id: "erp-help-images" }), context ? (jsxs("p", { className: "break-words text-xs leading-relaxed text-muted-foreground", children: ["\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E17\u0E35\u0E48\u0E41\u0E19\u0E1A: ", describeAttached(appId, context, email)] })) : null, error ? (jsx("p", { role: "alert", className: "text-sm text-destructive", children: error })) : null, jsxs(DialogFooter, { className: "flex-row items-center gap-2 sm:justify-between", children: [jsx("button", { type: "button", onClick: loadMine, className: "mr-auto " + LINK_BUTTON, children: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E17\u0E35\u0E48\u0E2A\u0E48\u0E07\u0E41\u0E25\u0E49\u0E27" }), jsxs("div", { className: "flex gap-2", children: [jsx(Button, { type: "button", variant: "outline", onClick: closeHelp, children: "\u0E22\u0E01\u0E40\u0E25\u0E34\u0E01" }), jsxs(Button, { type: "submit", disabled: !canSend, children: [sending ? jsx(Loader2, { className: "mr-1 h-4 w-4 animate-spin" }) : null, "\u0E2A\u0E48\u0E07"] })] })] })] })) : null, view === "sent" && sent ? (jsxs("div", { className: "grid gap-4", children: [jsxs("div", { className: "flex flex-col items-center gap-2 py-4 text-center", children: [jsx(CircleCheck, { className: "h-12 w-12 text-primary", strokeWidth: 1.6 }), jsx(DialogTitle, { className: "text-lg font-semibold", children: "\u0E2A\u0E48\u0E07\u0E41\u0E25\u0E49\u0E27" }), jsxs("p", { className: "text-sm text-muted-foreground", children: ["\u0E40\u0E25\u0E02\u0E17\u0E35\u0E48\u0E2D\u0E49\u0E32\u0E07\u0E2D\u0E34\u0E07", " ", jsx("span", { className: "font-mono text-base font-semibold text-foreground", children: sent.ref })] })] }), jsxs(DialogFooter, { className: "flex-row items-center gap-2 sm:justify-between", children: [jsx("button", { type: "button", onClick: loadMine, className: "mr-auto " + LINK_BUTTON, children: "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E17\u0E35\u0E48\u0E2A\u0E48\u0E07\u0E41\u0E25\u0E49\u0E27" }), jsx(Button, { type: "button", onClick: closeHelp, children: "\u0E1B\u0E34\u0E14" })] })] })) : null, view === "mine" ? (jsxs("div", { className: "grid gap-4", children: [jsx(DialogHeader, { children: jsxs(DialogTitle, { className: "flex items-center gap-2", children: [jsx(BackButton, { onClick: () => setView("form") }), "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21\u0E17\u0E35\u0E48\u0E2A\u0E48\u0E07\u0E41\u0E25\u0E49\u0E27"] }) }), mine === null && !mineError ? jsx(Loading, {}) : null, mineError ? (jsx("p", { role: "alert", className: "text-sm text-destructive", children: mineError })) : null, mine && mine.length === 0 ? (jsx("p", { className: "py-6 text-sm text-muted-foreground", children: "\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E21\u0E35\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21" })) : null, mine && mine.length > 0 ? (jsx("ul", { className: "grid gap-3", children: mine.map(m => {
2865
+ const entries = helpThread(m);
2866
+ const last = entries[entries.length - 1];
2867
+ return (jsxs("li", { className: "rounded-lg border border-border", children: [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: [jsxs("span", { className: "flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground", children: [jsx("span", { className: "font-mono font-semibold text-foreground", children: m.ref }), jsx("span", { children: KIND_LABEL[m.kind] ?? m.kind }), jsx("span", { children: when(m.created_at) }), jsx("span", { className: `ml-auto rounded-full px-2 py-0.5 font-medium ${STATUS_TONE[m.status] ?? ""}`, children: STATUS_LABEL[m.status] ?? m.status }), jsx(ChevronRight, { className: "h-4 w-4", "aria-hidden": "true" })] }), jsx("span", { className: "mt-2 line-clamp-3 block whitespace-pre-wrap break-words text-sm", children: m.message }), last ? (jsxs("span", { className: "mt-2 line-clamp-2 block rounded-md bg-muted px-3 py-2 text-xs", children: [jsxs("span", { className: "text-muted-foreground", children: [last.author === "staff" ? "ทีม AntNiti" : "คุณ", " \u00B7 ", when(last.created_at)] }), jsx("span", { className: "mt-0.5 block whitespace-pre-wrap break-words text-sm", children: last.body })] })) : null] }), m.images && m.images.length > 0 ? (jsx("div", { className: "px-3 pb-3", children: jsx(HelpImageStrip, { images: m.images, size: "sm" }) })) : null] }, m.id));
2868
+ }) })) : null] })) : null, view === "thread" ? (jsxs("div", { className: "grid gap-4", children: [jsx(DialogHeader, { children: jsxs(DialogTitle, { className: "flex items-center gap-2", children: [jsx(BackButton, { onClick: loadMine }), jsx("span", { className: "font-mono", children: thread?.ref ?? threadKey }), thread ? (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 ? jsx(Loading, {}) : null, threadError ? (jsx("p", { role: "alert", className: "text-sm text-destructive", children: threadError })) : null, thread ? (jsxs(Fragment$1, { children: [jsxs("ol", { className: "grid gap-3", "aria-label": "\u0E02\u0E49\u0E2D\u0E04\u0E27\u0E32\u0E21", children: [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 => (jsx(ThreadBubble, { mine: e.author === "customer", label: e.author === "staff" ? "ทีม AntNiti" : "คุณ", at: e.created_at, body: e.body, images: e.images }, e.id)))] }), jsxs("form", { onSubmit: sendFollowUp, className: "grid gap-3 border-t border-border pt-4" + dropRing(followDrop.over), ...followDrop.props, children: [jsxs("div", { className: "grid gap-2", children: [jsx(Label, { htmlFor: "erp-help-followup", children: "\u0E15\u0E2D\u0E1A\u0E01\u0E25\u0E31\u0E1A" }), jsx(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" })] }), jsx(HelpAttachField, { state: followImages, disabled: following, id: "erp-help-followup-images" }), followError ? (jsx("p", { role: "alert", className: "text-sm text-destructive", children: followError })) : null, jsx(DialogFooter, { className: "flex-row items-center justify-end gap-2", children: jsxs(Button, { type: "submit", disabled: !followTrimmed || following, children: [following ? jsx(Loader2, { className: "mr-1 h-4 w-4 animate-spin" }) : null, "\u0E2A\u0E48\u0E07"] }) })] })] })) : null] })) : null] }) }));
2869
+ }
2870
+ function BackButton({ onClick }) {
2871
+ return (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: jsx(ArrowLeft, { className: "h-4 w-4" }) }));
2872
+ }
2873
+ function Loading() {
2874
+ return (jsxs("div", { className: "flex items-center gap-2 py-6 text-sm text-muted-foreground", children: [jsx(Loader2, { className: "h-4 w-4 animate-spin" }), "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14"] }));
2875
+ }
2876
+ /** One message in the thread: the author's own on the right, AntNiti's on the left. */
2877
+ function ThreadBubble({ mine, label, at, body, images, }) {
2878
+ return (jsx("li", { className: "flex " + (mine ? "justify-end pl-6" : "justify-start pr-6"), children: jsxs("div", { className: "min-w-0 max-w-full rounded-lg px-3 py-2 " +
2879
+ (mine ? "border border-primary/30 bg-primary/5" : "bg-muted"), children: [jsxs("p", { className: "text-xs text-muted-foreground", children: [label, " \u00B7 ", when(at)] }), jsx("p", { className: "mt-1 whitespace-pre-wrap break-words text-sm", children: body }), jsx(HelpImageStrip, { images: images })] }) }));
2037
2880
  }
2038
2881
 
2039
2882
  /**
@@ -2756,5 +3599,5 @@ const getAxiosErrorMessage = (error) => {
2756
3599
  return String(error);
2757
3600
  };
2758
3601
 
2759
- export { AccessDenied, ApiError, AppBreadcrumb, AppLayoutShell, AppSidebar, EMPLOYEE_RECORD_OK_EVENT, EMPLOYEE_RECORD_REQUIRED, EMPLOYEE_RECORD_REQUIRED_EVENT, EmployeeRequiredGate, FeatureFlagsProvider, HeaderBackground, HelpButton, HelpDialogHost, NavMain, NavTools, NavUser, OrganizationSwitcher, PLAN_FEATURES, PermissionGateShell, PermissionProvider, PermissionRulesProvider, ReactQueryProvider, SHELL_VERSION, SessionBackedProfileCacheSeeder, SessionRefreshTrigger, SidebarContent, SidebarFooter, SidebarHeader, SidebarHelpCard, SidebarHelpLink, SidebarProvider, SidebarTrigger, TapMenu, ThemeProvider, WORKSPACE_HEADER_VARS, WORKSPACE_LIGHT_HEADER_VARS, WorkspaceBrand, instance as axiosInstance, buildSystemLinks, buildUrlWithCode, canAccessNavPermissionRoute, clearAccessTokenCache, clearOrganizationId, closeHelp, decodeJWT, describeAttached, describeBrowser, detectHelpApp, filterNavigationModulesByPermission, filterNavigationToolsByPermission, getAccessToken, getActiveModule, getActiveSubModule, getAxiosErrorMessage, getOrganizationId, getQueryClientConfig, getRequiredChildGroups, hasPlanFeature, isLikelyTransientNetworkError, isPathActive, normalizePathnameForPermissionRules, openHelp, resolveUiRoutePermission, runGlobalRequestPrecheck, runWithTransientRetry, setOrganizationId, toApiError, unwrapApiResponse, updateOrganizationId, useClearOrgOnUserChange, useFeatureFlag, useFeatureFlags, useInvalidateQueriesOnOrgChange, useKeycloakRoles, useOrganizationIdState, usePermissionRules, useSessionRefresh, useSidebar, useSidebarToggle };
3602
+ export { AccessDenied, ApiError, AppBreadcrumb, AppLayoutShell, AppSidebar, EMPLOYEE_RECORD_OK_EVENT, EMPLOYEE_RECORD_REQUIRED, EMPLOYEE_RECORD_REQUIRED_EVENT, EmployeeRequiredGate, FeatureFlagsProvider, HeaderBackground, HelpButton, HelpDialogHost, NavMain, NavTools, NavUser, OWNER_CHIP, OrganizationSwitcher, PLAN_FEATURES, PermissionGateShell, PermissionProvider, PermissionRulesProvider, ReactQueryProvider, SHELL_VERSION, SessionBackedProfileCacheSeeder, SessionRefreshTrigger, SidebarContent, SidebarFooter, SidebarHeader, SidebarHelpCard, SidebarHelpLink, SidebarProvider, SidebarTrigger, TapMenu, ThemeProvider, WORKSPACE_HEADER_VARS, WORKSPACE_LIGHT_HEADER_VARS, WorkspaceBrand, instance as axiosInstance, buildSystemLinks, buildUrlWithCode, canAccessNavPermissionRoute, clearAccessTokenCache, clearOrganizationId, closeHelp, decodeJWT, describeAttached, describeBrowser, detectHelpApp, filterNavigationModulesByPermission, filterNavigationToolsByPermission, getAccessToken, getActiveModule, getActiveSubModule, getAxiosErrorMessage, getOrganizationId, getQueryClientConfig, getRequiredChildGroups, hasPlanFeature, isLikelyTransientNetworkError, isPathActive, myRolesQueryKey, normalizePathnameForPermissionRules, openHelp, parseMyRoles, resolveUiRoutePermission, roleSummary, runGlobalRequestPrecheck, runWithTransientRetry, setOrganizationId, toApiError, unwrapApiResponse, updateOrganizationId, useClearOrgOnUserChange, useFeatureFlag, useFeatureFlags, useInvalidateQueriesOnOrgChange, useKeycloakRoles, useMyRoles, useOrganizationIdState, usePermissionRules, useSessionRefresh, useSidebar, useSidebarToggle };
2760
3603
  //# sourceMappingURL=index.esm.js.map