@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/README.md +4 -1
- package/dist/help/help-api.d.ts +53 -3
- package/dist/help/help-attach.d.ts +145 -0
- package/dist/help/help-dialog.d.ts +4 -2
- package/dist/help/help-images.d.ts +42 -0
- package/dist/help/help-link.d.ts +15 -0
- package/dist/help/help-store.d.ts +13 -1
- package/dist/http/platform-api.d.ts +2 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.esm.js +1653 -810
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +1655 -807
- package/dist/index.js.map +1 -1
- package/dist/navigation/my-roles.d.ts +41 -0
- package/dist/navigation/nav-user.d.ts +12 -1
- package/dist/navigation/use-my-roles.d.ts +15 -0
- package/dist/version.d.ts +1 -1
- package/package.json +3 -2
package/dist/index.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,
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
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
|
-
*
|
|
1035
|
-
*
|
|
1036
|
-
*
|
|
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
|
|
1039
|
-
if (!
|
|
1040
|
-
return
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
if ((
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
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
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
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
|
|
932
|
+
return { organizationId, isOwner: data.is_owner === true, roles };
|
|
1059
933
|
}
|
|
1060
|
-
|
|
1061
|
-
function
|
|
1062
|
-
|
|
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
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
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
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
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
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
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
|
|
1149
|
-
}
|
|
978
|
+
return inFlightTokenPromise;
|
|
979
|
+
};
|
|
980
|
+
const clearAccessTokenCache = () => {
|
|
981
|
+
cachedToken = null;
|
|
982
|
+
cachedAt = 0;
|
|
983
|
+
};
|
|
1150
984
|
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
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
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
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
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
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
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
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
|
-
|
|
1124
|
+
else if (value !== undefined && value !== null) {
|
|
1125
|
+
searchParams.append(key, String(value));
|
|
1325
1126
|
}
|
|
1326
1127
|
});
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
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
|
-
|
|
1198
|
+
await redirectToLogout();
|
|
1199
|
+
return Promise.reject(toApiError(error));
|
|
1336
1200
|
}
|
|
1337
|
-
|
|
1338
|
-
|
|
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
|
-
|
|
1341
|
-
|
|
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
|
|
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
|
-
|
|
1348
|
-
|
|
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
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
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
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
let
|
|
1589
|
-
|
|
1590
|
-
const
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
});
|
|
1637
|
-
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
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
}
|
|
1690
|
-
const
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
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
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
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
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1549
|
+
catch (error) {
|
|
1550
|
+
console.error("Sign out error:", error);
|
|
1551
|
+
window.location.href = signInRedirectHref;
|
|
1708
1552
|
}
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
const
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
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
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
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
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
return
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1769
|
-
|
|
1770
|
-
|
|
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 (
|
|
1773
|
-
|
|
1695
|
+
if (ACCOUNTING_SUB_KEYS.includes(feature) &&
|
|
1696
|
+
features.includes(PLAN_FEATURES.MODULE_ACCOUNTING)) {
|
|
1697
|
+
return true;
|
|
1774
1698
|
}
|
|
1775
|
-
if (
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
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
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
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
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
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
|
|
1802
|
-
}
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
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
|
|
1811
|
-
}
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
const
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
if (
|
|
1842
|
-
|
|
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
|
|
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
|
|
1877
|
-
*
|
|
1878
|
-
*
|
|
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
|
-
|
|
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
|
-
/**
|
|
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:
|
|
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()
|
|
1977
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|