import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { ArrowRight, Briefcase, Building2, ChevronDown, ChevronRight, CornerDownRight, 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, DialogTrigger, } from "../../../components/ui/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger, } from "../../../components/ui/dropdown-menu"; 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 { Tabs, TabsContent, TabsList, TabsTrigger, } from "../../../components/ui/tabs"; import { toast } from "../../../components/ui/use-toast"; import { type TenantSummary, type UserSummary, createUser, fetchTenants, fetchUsers, updateTenant, updateUser, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; // --- Icons & Helpers --- const getTenantIcon = (type?: string) => { switch (type?.toUpperCase()) { case "COMPANY_GROUP": return Briefcase; case "PERSONAL": return UserCircle; 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 (
) : ( level > 0 &&
)}
{node.name}
{node.recursiveMemberCount} {isExpanded && hasChildren && (
{node.children.map((child) => ( ))}
)} ); }; const MemberTable: React.FC<{ tenantSlug: string; onRefreshTrigger?: number; }> = ({ tenantSlug, onRefreshTrigger }) => { const { data, isLoading, refetch } = useQuery({ queryKey: ["tenant-members-v2", tenantSlug, onRefreshTrigger], queryFn: () => fetchUsers(100, 0, undefined, tenantSlug), enabled: !!tenantSlug, }); const members = data?.items ?? []; 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", "상세보기")} ))}
); }; // --- 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(""); // Data Fetching const { data: allTenantsData, isLoading: isTenantsLoading, refetch: refetchTree, } = useQuery({ queryKey: ["tenants-full-tree-v2"], queryFn: () => fetchTenants(1000, 0), }); const { currentBase, subTree } = 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 }); 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;