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, UserPlus, Users, } from "lucide-react"; import type React from "react"; import { useState } from "react"; import { useParams } from "react-router-dom"; import { commonStickyTableHeaderClass } from "../../../../../common/ui/table"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../../../components/ui/card"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } 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, TableCell, TableHead, TableHeader, TableRow, } from "../../../components/ui/table"; import { toast } from "../../../components/ui/use-toast"; import { addGroupMember, createGroup, deleteGroup, fetchGroups, fetchTenant, fetchUsers, type GroupSummary, removeGroupMember, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; import { useTenantPermission } from "../hooks/useTenantPermission"; type UserGroupNode = GroupSummary & { children: UserGroupNode[]; isExpanded?: boolean; }; function buildGroupTree( groups: GroupSummary[], parentId: string | null = null, ): UserGroupNode[] { const nodes: UserGroupNode[] = []; const childrenOf = new Map(); // First pass: Initialize all groups as nodes and populate childrenOf map for (const group of groups) { childrenOf.set(group.id, []); } // Second pass: Populate children for (const group of groups) { const node: UserGroupNode = { ...group, children: childrenOf.get(group.id) ?? [], }; if (group.parentId === parentId) { nodes.push(node); } else { // Check if the parent exists before adding to children // This handles cases where a parent might not be in the current 'groups' list (e.g., filtered data) if (group.parentId && childrenOf.has(group.parentId)) { childrenOf.get(group.parentId)?.push(node); } else { // If parentId exists but parent not found, it's a root level group for this tree view nodes.push(node); } } } // Sort children for consistent rendering (optional, but good for UI) nodes.sort((a, b) => a.name.localeCompare(b.name)); for (const node of nodes) { node.children.sort((a, b) => a.name.localeCompare(b.name)); } return nodes; } interface UserGroupTreeNodeProps { node: UserGroupNode; level: number; onSelect: (groupId: string) => void; selectedGroupId: string | null; onDelete: (groupId: string) => void; onAddSubGroup: (parentId: string) => void; addMemberMutation: UseMutationResult< void, AxiosError<{ error?: string }>, { groupId: string; userId: string } >; removeMemberMutation: UseMutationResult< void, AxiosError<{ error?: string }>, { groupId: string; userId: string } >; isWritable?: boolean; } const UserGroupTreeNode: React.FC = ({ node, level, onSelect, selectedGroupId, onDelete, onAddSubGroup, addMemberMutation, removeMemberMutation, isWritable = true, }) => { const [isExpanded, setIsExpanded] = useState(true); const hasChildren = node.children.length > 0; const handleToggleExpand = (e: React.MouseEvent) => { e.stopPropagation(); setIsExpanded(!isExpanded); }; return ( <> onSelect(node.id)} >
{hasChildren ? ( ) : ( level > 0 && ( ) )} {node.name} {node.unitType || "Team"}
{t("msg.admin.groups.members.count", "{{count}} 명", { count: node.members?.length || 0, })}
{isExpanded && hasChildren && node.children.map((child) => ( ))} ); }; function TenantGroupsPage() { const params = useParams<{ tenantId: string }>(); const tenantId = params.tenantId ?? ""; const _queryClient = useQueryClient(); const { hasPermission } = useTenantPermission(tenantId); const isWritable = hasPermission("manage_organization") || hasPermission("manage"); const canView = hasPermission("view_organization") || hasPermission("view"); const [newGroupName, setNewGroupName] = useState(""); const [newGroupDesc, setNewGroupNameDesc] = useState(""); const [newGroupUnitType, setNewGroupUnitType] = useState("Team"); const [newGroupParentId, setNewGroupParentId] = useState(null); const [selectedGroupId, setSelectedGroupId] = useState(null); // Modal States const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false); const [isMoveMemberModalOpen, setIsMoveMemberModalOpen] = useState(false); const [memberActionTargetUserId, setMemberActionTargetUserId] = useState< string | null >(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: () => createGroup(tenantId, { name: newGroupName, description: newGroupDesc, unitType: newGroupUnitType, parentId: newGroupParentId || undefined, }), onSuccess: () => { toast.success( t( "msg.admin.groups.list.create_success", "그룹이 성공적으로 생성되었습니다.", ), ); groupsQuery.refetch(); setNewGroupName(""); setNewGroupNameDesc(""); setNewGroupUnitType("Team"); setNewGroupParentId(null); }, onError: (error: AxiosError<{ error?: string }>) => { toast.error(t("msg.admin.groups.list.create_error", "그룹 생성 실패"), { description: error.response?.data?.error || error.message, }); }, }); // 그룹 삭제 const deleteMutation = useMutation({ mutationFn: (id: string) => deleteGroup(tenantId, id), onSuccess: () => { toast.success( t("msg.admin.groups.list.delete_success", "그룹이 삭제되었습니다."), ); groupsQuery.refetch(); setSelectedGroupId(null); }, onError: (error: AxiosError<{ error?: string }>) => { toast.error(t("msg.admin.groups.list.delete_error", "그룹 삭제 실패"), { description: error.response?.data?.error || error.message, }); }, }); // 멤버 추가 const addMemberMutation = useMutation({ mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => addGroupMember(tenantId, groupId, userId), onSuccess: () => { toast.success( t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다."), ); groupsQuery.refetch(); }, onError: (error: AxiosError<{ error?: string }>) => { toast.error(t("msg.common.error", "오류 발생"), { description: error.response?.data?.error || error.message, }); }, }); // 멤버 제거 const removeMemberMutation = useMutation({ mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => removeGroupMember(tenantId, groupId, userId), onSuccess: () => { toast.success( t("msg.admin.groups.members.remove_success", "멤버가 제거되었습니다."), ); groupsQuery.refetch(); }, onError: (error: AxiosError<{ error?: string }>) => { toast.error(t("msg.common.error", "오류 발생"), { description: error.response?.data?.error || error.message, }); }, }); // 멤버 이동 (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, }); }, }); if (!canView) { return (

{t("msg.common.forbidden", "접근 권한이 없습니다.")}

); } const groupTree = groupsQuery.data ? buildGroupTree(groupsQuery.data, tenantId) : []; const handleAddSubGroup = (parentId: string) => { setNewGroupParentId(parentId); }; const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId); return ( <>
{/* 그룹 생성 폼 */} {" "} {t("ui.admin.groups.create.title", "새 그룹 생성")} {t( "ui.admin.groups.create.description", "새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.", )}
setNewGroupName(e.target.value)} disabled={!isWritable} placeholder={t( "ui.admin.groups.form.name_placeholder", "예: 개발팀, 인사팀", )} />
setNewGroupUnitType(e.target.value)} disabled={!isWritable} placeholder={t( "ui.admin.groups.form.unit_level_placeholder", "예: 본부, 팀, 셀", )} />
setNewGroupNameDesc(e.target.value)} disabled={!isWritable} placeholder={t( "ui.admin.groups.form.desc_placeholder", "그룹 용도 설명", )} />
{/* 그룹 목록 (트리 뷰) */}
{t("ui.admin.groups.list.title", "User Groups")} {t( "msg.admin.groups.list.subtitle", "이 테넌트에 정의된 사용자 그룹 목록입니다.", )}
{t("ui.admin.groups.table.name", "NAME")} {t("ui.admin.groups.table.members", "MEMBERS")} {t("ui.admin.groups.table.actions", "ACTIONS")} {groupsQuery.isLoading && ( {t("msg.admin.groups.list.loading", "로딩 중...")} )} {!groupsQuery.isLoading && groupTree.length === 0 && ( {t( "msg.admin.groups.list.empty", "아직 등록된 그룹이 없습니다.", )} )} {groupTree.map((node) => ( { if ( window.confirm( t( "msg.admin.groups.list.delete_confirm", "그룹을 삭제하시겠습니까?", ), ) ) { deleteMutation.mutate(id); } }} onAddSubGroup={handleAddSubGroup} addMemberMutation={addMemberMutation} removeMemberMutation={removeMemberMutation} isWritable={isWritable} /> ))}
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */} {currentGroup && ( {t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", { name: currentGroup.name, })} {t( "ui.admin.groups.detail.members_subtitle", "그룹에 속한 멤버들을 확인하고 관리합니다.", )}
{t("ui.admin.groups.members.table.name", "이름")} {t("ui.admin.groups.members.table.email", "이메일")} {t("ui.admin.groups.members.table.actions", "관리")} {currentGroup.members?.length === 0 && ( {t( "msg.admin.groups.members.empty", "멤버가 없습니다.", )} )} {currentGroup.members?.map((user) => ( {user.name} {user.email}
))}
)}
{/* 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;