import { type UseMutationResult, useMutation, useQuery, } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { ChevronDown, ChevronRight, Plus, RefreshCw, Shield, Trash2, UserMinus, UserPlus, Users, } from "lucide-react"; import type React from "react"; import { useState } from "react"; import { useParams } from "react-router-dom"; import { toast } from "sonner"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "../../../components/ui/card"; import { Input } from "../../../components/ui/input"; import { Label } from "../../../components/ui/label"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from "../../../components/ui/table"; import { type GroupSummary, addGroupMember, createGroup, deleteGroup, fetchGroups, removeGroupMember, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; 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 } >; } const UserGroupTreeNode: React.FC = ({ node, level, onSelect, selectedGroupId, onDelete, onAddSubGroup, addMemberMutation, removeMemberMutation, }) => { 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 [newGroupName, setNewGroupName] = useState(""); const [newGroupDesc, setNewGroupNameDesc] = useState(""); const [newGroupUnitType, setNewGroupUnitType] = useState("Team"); const [newGroupParentId, setNewGroupParentId] = useState(null); const [selectedGroupId, setSelectedGroupId] = useState(null); // 그룹 목록 조회 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, }); }, }); 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 (
{/* 그룹 생성 폼 */} {" "} {t("ui.admin.groups.create.title", "새 그룹 생성")} {t( "ui.admin.groups.create.description", "새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.", )}
setNewGroupName(e.target.value)} placeholder={t( "ui.admin.groups.form.name_placeholder", "예: 개발팀, 인사팀", )} />
setNewGroupUnitType(e.target.value)} placeholder={t( "ui.admin.groups.form.unit_level_placeholder", "예: 본부, 팀, 셀", )} />
setNewGroupNameDesc(e.target.value)} 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} /> ))}
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */} {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.remove", "제거")} {currentGroup.members?.length === 0 && ( {t("msg.admin.groups.members.empty", "멤버가 없습니다.")} )} {currentGroup.members?.map((user) => ( {user.name} {user.email} ))}
)}
); } export default TenantGroupsPage;