diff --git a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx index bc2c22fa..da214dc2 100644 --- a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx @@ -2,13 +2,16 @@ import { type UseMutationResult, useMutation, useQuery, + useQueryClient, } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { + ArrowRightLeft, ChevronDown, ChevronRight, Plus, RefreshCw, + Search, Shield, Trash2, UserMinus, @@ -27,8 +30,18 @@ import { CardHeader, CardTitle, } from "../../../components/ui/card"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../../../components/ui/dialog"; import { Input } from "../../../components/ui/input"; import { Label } from "../../../components/ui/label"; +import { ScrollArea } from "../../../components/ui/scroll-area"; import { Table, TableBody, @@ -44,6 +57,8 @@ import { createGroup, deleteGroup, fetchGroups, + fetchTenant, + fetchUsers, removeGroupMember, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; @@ -223,6 +238,7 @@ const UserGroupTreeNode: React.FC = ({ function TenantGroupsPage() { const params = useParams<{ tenantId: string }>(); const tenantId = params.tenantId ?? ""; + const queryClient = useQueryClient(); const [newGroupName, setNewGroupName] = useState(""); const [newGroupDesc, setNewGroupNameDesc] = useState(""); @@ -231,13 +247,35 @@ function TenantGroupsPage() { const [selectedGroupId, setSelectedGroupId] = useState(null); + // Modal States + const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false); + const [isMoveMemberModalOpen, setIsMoveMemberModalOpen] = useState(false); + const [memberActionTargetUserId, setMemberActionTargetUserId] = useState(null); + const [userSearchTerm, setUserSearchTerm] = useState(""); + const [groupSearchTerm, setGroupSearchTerm] = useState(""); + + // 테넌트 정보 조회 (slug 획득) + const tenantQuery = useQuery({ + queryKey: ["tenant", tenantId], + queryFn: () => fetchTenant(tenantId), + enabled: tenantId.length > 0, + }); + const tenantSlug = tenantQuery.data?.slug; + + // 해당 테넌트의 사용자 목록 조회 + const usersQuery = useQuery({ + queryKey: ["users", { tenantSlug }], + queryFn: () => fetchUsers(1000, 0, undefined, tenantSlug), + enabled: !!tenantSlug, + }); + const users = usersQuery.data?.items ?? []; + // 그룹 목록 조회 const groupsQuery = useQuery({ queryKey: ["groups", tenantId], queryFn: () => fetchGroups(tenantId), enabled: tenantId.length > 0, }); - // 그룹 생성 const createMutation = useMutation({ mutationFn: () => @@ -318,32 +356,48 @@ function TenantGroupsPage() { }, }); + // 멤버 이동 (Remove -> Add) + const moveMemberMutation = useMutation({ + mutationFn: async ({ + sourceGroupId, + targetGroupId, + userId, + }: { + sourceGroupId: string; + targetGroupId: string; + userId: string; + }) => { + await removeGroupMember(tenantId, sourceGroupId, userId); + await addGroupMember(tenantId, targetGroupId, userId); + }, + onSuccess: () => { + toast.success( + t("msg.admin.groups.members.move_success", "멤버가 이동되었습니다."), + ); + groupsQuery.refetch(); + setIsMoveMemberModalOpen(false); + setMemberActionTargetUserId(null); + }, + onError: (error: AxiosError<{ error?: string }>) => { + toast.error(t("msg.common.error", "오류 발생"), { + description: error.response?.data?.error || error.message, + }); + }, + }); + const groupTree = groupsQuery.data ? buildGroupTree(groupsQuery.data, tenantId) : []; const handleAddSubGroup = (parentId: string) => { setNewGroupParentId(parentId); - // Optionally scroll to the create form or highlight it }; - - const handleAddMember = (groupId: string) => { - const userId = window.prompt( - t( - "msg.admin.groups.prompt.user_id", - "추가할 사용자의 UUID를 입력하세요:", - ), - ); - if (userId) { - addMemberMutation.mutate({ groupId, userId }); - } - }; - const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId); return ( -
-
+ <> +
+
{/* 그룹 생성 폼 */} @@ -544,7 +598,7 @@ function TenantGroupsPage() {
+
+ + +
))} @@ -610,11 +689,205 @@ function TenantGroupsPage() {
- - - )} -
- ); -} + + + )} +
-export default TenantGroupsPage; + {/* Add Member Modal */} + { + setIsAddMemberModalOpen(val); + if (!val) setUserSearchTerm(""); + }} + > + + + + {t("ui.admin.groups.members.add_modal_title", "그룹에 멤버 추가")} + + + {t( + "msg.admin.groups.members.add_modal_desc", + "이 테넌트에 속한 사용자 중 추가할 멤버를 검색하여 선택하세요.", + )} + + +
+
+ + setUserSearchTerm(e.target.value)} + /> +
+ +
+ {usersQuery.isLoading ? ( +
+ {t("ui.common.loading", "로딩 중...")} +
+ ) : ( + users + .filter((u) => { + const term = userSearchTerm.toLowerCase(); + return ( + u.name.toLowerCase().includes(term) || + u.email.toLowerCase().includes(term) + ); + }) + .filter( + (u) => + !currentGroup?.members?.some((m) => m.id === u.id), + ) // Exclude existing members + .map((user) => ( +
+
+

{user.name}

+

+ {user.email} +

+
+ +
+ )) + )} + {users.length > 0 && + users.filter( + (u) => !currentGroup?.members?.some((m) => m.id === u.id), + ).length === 0 && ( +
+ {t("msg.admin.groups.members.all_added", "모든 테넌트 멤버가 이미 이 그룹에 속해 있습니다.")} +
+ )} +
+
+
+ + + +
+
+ + {/* Move Member Modal */} + { + setIsMoveMemberModalOpen(val); + if (!val) { + setMemberActionTargetUserId(null); + setGroupSearchTerm(""); + } + }} + > + + + + {t("ui.admin.groups.members.move_modal_title", "부서 이동")} + + + {t( + "msg.admin.groups.members.move_modal_desc", + "선택한 멤버를 이동할 대상 그룹을 선택하세요.", + )} + + +
+
+ + setGroupSearchTerm(e.target.value)} + /> +
+ +
+ {groupsQuery.isLoading ? ( +
+ {t("ui.common.loading", "로딩 중...")} +
+ ) : groupsQuery.data && groupsQuery.data.length > 0 ? ( + groupsQuery.data + .filter((g) => + g.name + .toLowerCase() + .includes(groupSearchTerm.toLowerCase()), + ) + .filter((g) => g.id !== currentGroup?.id) // Exclude current group + .map((group) => ( +
+
+ + {group.name} +
+ +
+ )) + ) : ( +
+ {t("msg.admin.groups.list.no_results", "그룹이 없습니다.")} +
+ )} +
+
+
+ + + +
+
+ + ); + } + + export default TenantGroupsPage;