import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { ArrowRight, Briefcase, Building2, Check, ChevronDown, ChevronRight, CornerDownRight, Network, Plus, RefreshCw, Search, 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 { Input } from "../../../components/ui/input"; import { Label } from "../../../components/ui/label"; 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 GroupSummary, type TenantSummary, type UserSummary, createUser, fetchGroups, fetchTenants, fetchUsers, updateTenant, updateUser, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree"; const getTenantIcon = (type?: string) => { switch (type?.toUpperCase()) { case "COMPANY_GROUP": return Briefcase; case "PERSONAL": return UserCircle; case "USER_GROUP": return Network; default: return Building2; } }; const MemberListDialog: React.FC<{ node: TenantNode; trigger?: React.ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; }> = ({ node, trigger, open, onOpenChange }) => { const [activeTab, setActiveTab] = useState("direct"); const { data: directData, isLoading: isDirectLoading, refetch: refetchDirect, } = useQuery({ queryKey: ["tenant-members", node.slug], queryFn: () => fetchUsers(100, 0, undefined, node.slug), enabled: open && activeTab === "direct", }); const descendantSlugs = useMemo(() => { const slugs: string[] = []; const collect = (n: TenantNode) => { for (const child of n.children) { slugs.push(child.slug); collect(child); } }; collect(node); return slugs; }, [node]); const { data: descendantData, isLoading: isDescendantLoading, refetch: refetchDescendant, } = useQuery({ queryKey: ["tenant-descendant-members", node.id], queryFn: async () => { if (descendantSlugs.length === 0) return []; // Fetch users for all descendant slugs in parallel const results = await Promise.all( descendantSlugs .slice(0, 10) .map((slug) => fetchUsers(50, 0, undefined, slug)), ); return results.flatMap((res) => res.items); }, enabled: open && activeTab === "descendants" && descendantSlugs.length > 0, }); const directMembers = directData?.items ?? []; const descendantMembers = descendantData ?? []; return ( {trigger && {trigger}} {node.name}{" "} {t("ui.admin.tenants.members.list_title", "구성원 관리")} ({isDirectLoading ? "..." : (directData?.total ?? 0)}) {t( "msg.admin.tenants.members.desc", "조직에 소속된 사용자 목록을 확인합니다.", )}
{t("ui.admin.tenants.members.direct", "소속 멤버")} ( {isDirectLoading ? "..." : (directData?.total ?? 0)}) {t("ui.admin.tenants.members.descendants", "하위 조직 멤버")} ( {node.recursiveMemberCount - (node.memberCount || 0)})
{descendantSlugs.length > 10 && (

*{" "} {t( "msg.admin.tenants.members.limit_notice", "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다.", )}

)}
); }; const MemberTable: React.FC<{ members: UserSummary[]; isLoading: boolean; onRefresh: () => void; showTenant?: boolean; }> = ({ members, isLoading, onRefresh, showTenant }) => (
{t("ui.admin.users.table.name", "NAME")} {t("ui.admin.users.table.email", "EMAIL")} {showTenant && ( {t("ui.admin.tenants.table.slug", "TENANT")} )} {t("ui.admin.users.table.role", "ROLE")} {isLoading ? ( {t("msg.common.loading", "로딩 중...")} ) : members.length === 0 ? (

{t("msg.admin.users.list.empty", "멤버가 없습니다.")}

) : ( members.map((user) => ( {user.name} {user.email} {showTenant && ( {user.tenantSlug} )} {user.role} )) )}
); const UserAddDialog: React.FC<{ tenantSlug: string; tenantName: string; trigger?: React.ReactNode; open?: boolean; onOpenChange?: (open: boolean) => void; }> = ({ tenantSlug, tenantName, trigger, open, onOpenChange }) => { const queryClient = useQueryClient(); const [activeTab, setActiveTab] = useState("select"); // Create state const [email, setEmail] = useState(""); const [name, setName] = useState(""); // Select state 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 handleCreate = async () => { if (!email || !name) { toast.error( t( "msg.admin.users.create.form.email_required", "이메일과 이름은 필수입니다.", ), ); return; } setIsSubmitting(true); try { const res = await createUser({ email, name, tenantSlug: tenantSlug, role: "user", }); toast.success( t("msg.admin.users.create.success", "사용자가 생성되었습니다."), { description: res.initialPassword ? `초기 비밀번호: ${res.initialPassword}` : undefined, }, ); // Refresh tenant tree to update member counts setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); }, 1000); // Wait 1s for backend async sync onOpenChange?.(false); resetFields(); } catch (err: unknown) { const error = err as { response?: { data?: { error?: string } } }; toast.error( error.response?.data?.error || t("msg.admin.users.create.error", "사용자 생성 실패"), ); } finally { setIsSubmitting(false); } }; const handleAssign = async () => { if (!selectedUserId) return; setIsSubmitting(true); try { await updateUser(selectedUserId, { tenantSlug: tenantSlug }); toast.success( t("msg.info.saved_success", "사용자가 테넌트에 배정되었습니다."), ); // Refresh tenant tree to update member counts setTimeout(() => { queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] }); }, 1000); // Wait 1s for backend async sync onOpenChange?.(false); resetFields(); } catch (err: unknown) { const error = err as { response?: { data?: { error?: string } } }; toast.error( error.response?.data?.error || t("msg.admin.users.detail.update_error", "배정 실패"), ); } finally { setIsSubmitting(false); } }; const resetFields = () => { setEmail(""); setName(""); setUserSearch(""); setSearchResults([]); setSelectedUserId(null); }; return ( { onOpenChange?.(v); if (!v) resetFields(); }} > {trigger && {trigger}} {t("ui.admin.users.create.title", "사용자 추가")} [{tenantName}] 테넌트에 사용자를 등록하거나 기존 사용자를 배정합니다. {t("ui.common.select", "기존 사용자 선택")} {t("ui.common.create", "신규 생성")}
setUserSearch(e.target.value)} onKeyDown={(e) => e.key === "Enter" && handleSearch()} />
{searchResults.length === 0 ? ( {isSearching ? t("msg.common.loading", "검색 중...") : t( "msg.admin.users.list.empty", "사용자를 검색해 주세요.", )} ) : ( searchResults.map((user) => ( setSelectedUserId(user.id)} >
{user.name}
{user.email}
{user.tenantSlug && ( {user.tenantSlug} )}
{selectedUserId === user.id && ( )}
)) )}
setEmail(e.target.value)} placeholder="user@example.com" />
setName(e.target.value)} placeholder="홍길동" />
{activeTab === "create" ? ( ) : ( )}
); }; const TenantTreeRow: React.FC<{ node: TenantNode; level: number; isRoot: boolean; onRemove: (id: string, name: string) => void; onMove: (id: string, newParentId: string) => void; isUpdating: boolean; searchTerm?: string; }> = ({ node, level, isRoot, onRemove, onMove, isUpdating, searchTerm }) => { const navigate = useNavigate(); const [isExpanded, setIsExpanded] = useState(true); const [isUserAddOpen, setIsUserAddOpen] = useState(false); const [isMemberListOpen, setIsMemberListOpen] = useState(false); const [isDragOver, setIsDragOver] = useState(false); const hasChildren = node.children && node.children.length > 0; // Auto expand if search matches children React.useEffect(() => { if (searchTerm) { const hasMatchingChild = (n: TenantNode): boolean => { return n.children.some( (c) => c.name.toLowerCase().includes(searchTerm.toLowerCase()) || c.slug.toLowerCase().includes(searchTerm.toLowerCase()) || hasMatchingChild(c), ); }; if (hasMatchingChild(node)) { setIsExpanded(true); } } }, [searchTerm, node]); const isMatching = searchTerm && (node.name.toLowerCase().includes(searchTerm.toLowerCase()) || node.slug.toLowerCase().includes(searchTerm.toLowerCase())); const TypeIcon = getTenantIcon(node.type); // DnD Handlers const handleDragStart = (e: React.DragEvent) => { if (isRoot) return; e.dataTransfer.setData("nodeId", node.id); e.dataTransfer.setData("nodeName", node.name); e.dataTransfer.effectAllowed = "move"; }; const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); if (isUpdating) return; setIsDragOver(true); }; const handleDragLeave = () => { setIsDragOver(false); }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); setIsDragOver(false); const draggedId = e.dataTransfer.getData("nodeId"); if (!draggedId || draggedId === node.id) return; onMove(draggedId, node.id); }; const hoverTitle = `${node.name} (${node.type})\n${t("ui.admin.tenants.members.direct", "소속 멤버")}: ${node.memberCount || 0}\n${t("ui.admin.tenants.members.total", "총 멤버")}: ${node.recursiveMemberCount || 0}`; return ( <>
{hasChildren ? ( ) : ( level > 0 && (
) )} {!isRoot && ( )}
{node.name} {isRoot && ( Root )} {t(`domain.tenant_type.${node.type?.toLowerCase()}`, node.type)}
{node.slug} {t(`ui.common.status.${node.status}`, node.status)}
{!isRoot && ( )}
{isExpanded && node.children.map((child) => ( ))} ); }; function TenantUserGroupsTab() { const { tenantId } = useParams<{ tenantId: string }>(); const queryClient = useQueryClient(); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isHeaderUserAddOpen, setIsHeaderUserAddOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(""); const [treeSearchTerm, setTreeSearchTerm] = useState(""); if (!tenantId) return null; const { data, isLoading, refetch } = useQuery({ queryKey: ["tenants-full-tree-v2"], queryFn: () => fetchTenants(1000, 0), }); const { data: groupsData, isLoading: isGroupsLoading } = useQuery({ queryKey: ["tenant-groups", tenantId], queryFn: () => fetchGroups(tenantId), enabled: !!tenantId, }); const groupNodes = useMemo(() => { if (!groupsData) return []; return groupsData.map((g) => ({ ...g, type: "USER_GROUP", children: [], // Simplified for now, just a list or separate tree memberCount: g.members?.length || 0, recursiveMemberCount: g.members?.length || 0, })) as unknown as TenantNode[]; }, [groupsData]); 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", "저장되었습니다.")); setIsAddDialogOpen(false); }, onError: (error: AxiosError<{ error?: string }>) => { toast.error(t("msg.common.error", "오류 발생"), { description: error.response?.data?.error || error.message, }); }, }); const allTenants = data?.items ?? []; const { currentBase, subTree } = useMemo(() => { const tree = buildTenantFullTree(allTenants, tenantId); if (tree.currentBase) { // Merge backend-provided UserGroups into the tree as virtual children tree.currentBase.children = [...tree.currentBase.children, ...groupNodes]; } return tree; }, [allTenants, tenantId, groupNodes]); const handleAdd = (id: string) => updateParentMutation.mutate({ id, parentId: tenantId }); const handleMove = (id: string, newParentId: string) => { if (id === newParentId) return; updateParentMutation.mutate({ id, parentId: newParentId }); }; const handleRemove = (id: string, name: string) => { if ( window.confirm( t( "msg.admin.tenants.remove_sub_confirm", `${name} 테넌트를 하위 조직에서 제외할까요?`, { name }, ), ) ) { updateParentMutation.mutate({ id, parentId: undefined }); } }; if (isLoading) return (
{t("msg.common.loading", "로딩 중...")}
); if (!currentBase) return (
{t("msg.admin.tenants.not_found", "현재 테넌트를 찾을 수 없습니다.")}{" "} (ID: {tenantId})
); const candidates = allTenants.filter((t) => { if (t.id === tenantId) return false; // Check if it's already a child if (t.parentId === tenantId) return false; // Basic search if (searchTerm === "") return true; return ( t.name.toLowerCase().includes(searchTerm.toLowerCase()) || t.slug.toLowerCase().includes(searchTerm.toLowerCase()) ); }); const BaseIcon = getTenantIcon(currentBase.type); return (
{t("ui.admin.tenants.sub.title", "조직 계층 구조", { count: subTree.length, })} {t( "msg.admin.tenants.sub.subtitle", "하위 조직망을 구성하고 관리합니다.", )}
{t( "ui.admin.tenants.sub.add_dialog_title", "하위 테넌트 추가", )} {t( "ui.admin.tenants.sub.add_dialog_desc", "기존에 등록된 테넌트를 검색하여 하위 조직으로 추가합니다.", )}
setSearchTerm(e.target.value)} />
{candidates.length === 0 ? ( {t( "ui.admin.tenants.sub.no_candidates", "검색 결과 없음", )} ) : ( candidates.map((tenant) => { const CandidateIcon = getTenantIcon(tenant.type); return (
{tenant.name}
{tenant.slug}
{t( `domain.tenant_type.${tenant.type?.toLowerCase()}`, tenant.type, )}
); }) )}
setTreeSearchTerm(e.target.value)} />
{treeSearchTerm && ( )}
{t("ui.admin.tenants.table.name", "NAME")} {t("ui.admin.tenants.table.slug", "SLUG")} {t("ui.admin.tenants.table.members", "MEMBERS")} {t("ui.admin.tenants.table.status", "STATUS")} {t("ui.admin.tenants.table.actions", "ACTIONS")}
); } export default TenantUserGroupsTab;