import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { ArrowRight, Briefcase, Building2, ChevronDown, ChevronRight, Download, ExternalLink, FolderOpen, LayoutDashboard, MoreHorizontal, Network, Plus, RefreshCw, Search, Settings, Trash2, UserCircle, UserPlus, Users, } from "lucide-react"; import * as React from "react"; import { useMemo, useState } from "react"; import { Link, useNavigate, useParams } from "react-router-dom"; 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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "../../../components/ui/dropdown-menu"; import { Input } from "../../../components/ui/input"; 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 { exportTenantsCSV, exportUsersCSV, fetchAllTenants, fetchUsers, type TenantSummary, type UserSummary, updateTenant, updateUser, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree"; // --- Icons & Helpers --- const getTenantIcon = (type?: string) => { switch (type?.toUpperCase()) { case "COMPANY_GROUP": return Briefcase; case "PERSONAL": return UserCircle; case "ORGANIZATION": case "USER_GROUP": return Network; default: return Building2; } }; // --- Components --- const SidebarNode: React.FC<{ node: TenantNode; level: number; selectedId: string; onSelect: (id: string) => void; searchTerm: string; }> = ({ node, level, selectedId, onSelect, searchTerm }) => { const [isExpanded, setIsExpanded] = useState(true); const hasChildren = node.children && node.children.length > 0; const isSelected = selectedId === node.id; const TypeIcon = getTenantIcon(node.type); // Auto-expand on search React.useEffect(() => { if (searchTerm) { const matchInDescendants = (n: TenantNode): boolean => { return n.children.some( (c) => c.name.toLowerCase().includes(searchTerm.toLowerCase()) || matchInDescendants(c), ); }; if (matchInDescendants(node)) setIsExpanded(true); } }, [searchTerm, node]); const isMatching = searchTerm && node.name.toLowerCase().includes(searchTerm.toLowerCase()); return (
{isExpanded && hasChildren && (
{node.children.map((child) => ( ))}
)}
); }; const MemberTable: React.FC<{ tenantSlug: string; onRefreshTrigger?: number; allTenants?: TenantSummary[]; }> = ({ tenantSlug, onRefreshTrigger, allTenants }) => { const queryClient = useQueryClient(); const { data, isLoading, refetch } = useQuery({ queryKey: ["tenant-members-v2", tenantSlug, onRefreshTrigger], queryFn: () => fetchUsers(100, 0, undefined, tenantSlug), enabled: !!tenantSlug, }); const members = data?.items ?? []; const [isMoveOpen, setIsMoveOpen] = useState(false); const [selectedUser, setSelectedUser] = useState(null); const [targetTenantSlug, setTargetTenantSlug] = useState(""); const [searchTenant, setSearchTenant] = useState(""); const moveMutation = useMutation({ mutationFn: (newSlug: string) => { if (!selectedUser) throw new Error("No user selected"); return updateUser(selectedUser.id, { tenantSlug: newSlug }); }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); toast.success( t("msg.info.saved_success", "사용자 조직이 변경되었습니다."), ); setIsMoveOpen(false); setSelectedUser(null); refetch(); }, onError: () => toast.error(t("msg.common.error", "오류가 발생했습니다.")), }); const removeMutation = useMutation({ mutationFn: (userId: string) => updateUser(userId, { tenantSlug, isRemoveTenant: true }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); toast.success(t("msg.info.saved_success", "조직에서 제외되었습니다.")); refetch(); }, onError: () => toast.error(t("msg.common.error", "오류가 발생했습니다.")), }); const handleMoveClick = (user: UserSummary) => { setSelectedUser(user); setTargetTenantSlug(""); setIsMoveOpen(true); }; const filteredTenants = React.useMemo(() => { if (!allTenants) return []; if (!searchTenant) return allTenants; return allTenants.filter( (t) => t.name.toLowerCase().includes(searchTenant.toLowerCase()) || t.slug.toLowerCase().includes(searchTenant.toLowerCase()), ); }, [allTenants, searchTenant]); if (isLoading) return (
{t("msg.common.loading", "멤버 정보를 불러오는 중...")}
); if (members.length === 0) return (

{t("msg.admin.users.list.empty", "이 조직에 소속된 멤버가 없습니다.")}

); return (
{t("ui.admin.users.table.name", "이름")} {t("ui.admin.users.table.email", "이메일")} {t("ui.admin.users.table.role", "역할")} {members.map((user) => ( {user.name} {user.email} {user.role} {t("ui.common.detail", "상세보기")} handleMoveClick(user)}> {t("ui.common.move_org", "타 조직으로 이동")} { if ( window.confirm( t( "msg.admin.users.confirm_remove_org", "이 조직에서 사용자를 제외하시겠습니까?", ), ) ) { removeMutation.mutate(user.id); } }} > {t("ui.common.remove_org", "조직에서 제외")} ))}
{t("ui.common.move_org", "타 조직으로 이동")} {selectedUser?.name} 사용자를 이동할 타 조직을 선택하세요.
setSearchTenant(e.target.value)} />
{filteredTenants.map((tItem) => ( ))} {filteredTenants.length === 0 && (
{t("msg.common.no_results", "검색 결과가 없습니다.")}
)}
); }; // --- Main Component --- function TenantUserGroupsTab() { const { tenantId } = useParams<{ tenantId: string }>(); const _navigate = useNavigate(); const queryClient = useQueryClient(); const [selectedNodeId, setSelectedNodeId] = useState(tenantId || ""); const [treeSearch, setTreeSearch] = useState(""); const [refreshMembersCount, setRefreshMembersCount] = useState(0); const [isUserAddOpen, setIsUserAddOpen] = useState(false); const [isAddExistingOpen, setIsAddExistingOpen] = useState(false); const [existingSearch, setExistingSearch] = useState(""); const exportChildrenMutation = useMutation({ mutationFn: (parentId: string) => exportTenantsCSV(true, parentId), onSuccess: ({ blob, filename }) => { const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); }, onError: () => toast.error( t("msg.admin.tenants.export_error", "테넌트 내보내기에 실패했습니다."), ), }); const exportCurrentMembersMutation = useMutation({ mutationFn: (tenantSlug: string) => exportUsersCSV("", tenantSlug, false), onSuccess: ({ blob, filename }) => { const url = window.URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; document.body.appendChild(link); link.click(); link.remove(); window.URL.revokeObjectURL(url); }, onError: () => toast.error( t("msg.admin.users.export_error", "사용자 내보내기에 실패했습니다."), ), }); // Data Fetching const { data: allTenantsData, isLoading: isTenantsLoading, refetch: refetchTree, } = useQuery({ queryKey: ["tenants-full-tree-v2"], queryFn: () => fetchAllTenants(), }); const { currentBase } = useMemo(() => { const allItems = allTenantsData?.items ?? []; return buildTenantFullTree(allItems, tenantId); }, [allTenantsData, tenantId]); const selectedNode = useMemo(() => { // Find selected node in the built tree const findNode = (nodes: TenantNode[], id: string): TenantNode | null => { if (!currentBase) return null; if (currentBase.id === id) return currentBase; for (const node of nodes) { if (node.id === id) return node; if (node.children.length > 0) { const found = findNode(node.children, id); if (found) return found; } } return null; }; if (!currentBase) return null; return findNode(currentBase.children, selectedNodeId) || currentBase; }, [currentBase, selectedNodeId]); // Mutations const updateParentMutation = useMutation({ mutationFn: ({ id, parentId, }: { id: string; parentId: string | undefined; }) => updateTenant(id, { parentId: parentId || "" }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); toast.success( t("msg.info.saved_success", "조직 구조가 업데이트되었습니다."), ); setIsAddExistingOpen(false); }, }); const handleRemoveNode = (id: string, name: string) => { if ( window.confirm( t( "msg.admin.tenants.remove_sub_confirm", `${name} 조직을 하위에서 제외할까요?`, { name }, ), ) ) { updateParentMutation.mutate({ id, parentId: undefined }); if (selectedNodeId === id) setSelectedNodeId(tenantId || ""); } }; if (isTenantsLoading) return (
{t("msg.common.loading", "조직 정보를 불러오는 중...")}
); if (!currentBase) return (
테넌트를 찾을 수 없습니다.
); const candidates = (allTenantsData?.items ?? []).filter( (t) => t.id !== tenantId && t.parentId !== tenantId && (existingSearch === "" || t.name.toLowerCase().includes(existingSearch.toLowerCase()) || t.slug.toLowerCase().includes(existingSearch.toLowerCase())), ); return (
{/* --- Left Panel: Sidebar Tree --- */}
조직도
setTreeSearch(e.target.value)} />
{/* --- Right Panel: Selected Node Content --- */}
{selectedNode ? (
{React.createElement(getTenantIcon(selectedNode.type), { size: 24, })}
{selectedNode.name} {selectedNode.slug}
{selectedNode.recursiveMemberCount}{" "} {t("ui.admin.tenants.table.members", "명")} | {t( `domain.tenant_type.${selectedNode.type.toLowerCase()}`, selectedNode.type, )}
{t("ui.common.manage", "조직 관리")} 상세 프로필로 이동 {!selectedNode.parentId && ( {t("ui.admin.tenants.sub.add", "하위 부서 생성")} )} {selectedNode.id !== tenantId && ( handleRemoveNode(selectedNode.id, selectedNode.name) } > {t("ui.common.remove", "조직 계층에서 제외")} )}
{selectedNode.children.length > 0 && (

하위 조직 ({selectedNode.children.length})

{selectedNode.children?.map((child) => ( setSelectedNodeId(child.id)} >
{React.createElement( getTenantIcon(child.type), { size: 14, className: "text-primary", }, )}
{child.recursiveMemberCount}{" "} {t("ui.admin.tenants.table.members", "명")}
{child.name} {child.slug}
))}
)}

{t("ui.admin.tenants.members.list_title", "소속 멤버")}

) : (

조직을 선택해 주세요.

)}
{/* --- Dialogs --- */} { setIsUserAddOpen(open); if (!open) setRefreshMembersCount((prev) => prev + 1); }} /> {t("ui.admin.tenants.sub.add_existing", "기존 테넌트 연결")} 기존에 생성된 테넌트를 [{currentBase.name}] 하위로 가져옵니다.
setExistingSearch(e.target.value)} />
{candidates?.map((tenantItem) => ( updateParentMutation.mutate({ id: tenantItem.id, parentId: tenantId, }) } >
{React.createElement(getTenantIcon(tenantItem.type), { size: 14, className: "text-muted-foreground", })}

{tenantItem.name}

{tenantItem.slug}

))}
); } // --- Internal Support Components --- const UserAddDialog: React.FC<{ tenantSlug: string; tenantName: string; open: boolean; onOpenChange: (open: boolean) => void; }> = ({ tenantSlug, tenantName, open, onOpenChange }) => { const queryClient = useQueryClient(); const [userSearch, setUserSearch] = useState(""); const [isSearching, setIsSearching] = useState(false); const [searchResults, setSearchResults] = useState([]); const [selectedUserId, setSelectedUserId] = useState(null); const [isSubmitting, setIsSubmitting] = useState(false); const handleSearch = async () => { if (!userSearch) return; setIsSearching(true); try { const res = await fetchUsers(20, 0, userSearch); setSearchResults(res.items); } catch (_err) { toast.error(t("msg.admin.users.list.fetch_error", "사용자 검색 실패")); } finally { setIsSearching(false); } }; const handleAssign = async () => { if (!selectedUserId) return; setIsSubmitting(true); try { await updateUser(selectedUserId, { tenantSlug }); queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); toast.success(t("msg.info.saved_success", "사용자가 배정되었습니다.")); onOpenChange(false); resetFields(); } catch (err) { const error = err as AxiosError<{ error?: string }>; toast.error( error.response?.data?.error || t("msg.admin.users.detail.update_error", "배정 실패"), ); } finally { setIsSubmitting(false); } }; const resetFields = () => { setUserSearch(""); setSearchResults([]); setSelectedUserId(null); }; return ( { onOpenChange(v); if (!v) resetFields(); }} > {t("ui.admin.users.create.title", "멤버 추가")} [{tenantName}] 조직에 기존 사용자를 배정합니다.
setUserSearch(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
{searchResults?.map((user) => ( setSelectedUserId(user.id)} >

{user.name}

{user.email}

{selectedUserId === user.id && ( )}
))}
); }; export default TenantUserGroupsTab;