From 1c6fb4ef83085829164206bd92513ca43c85016d Mon Sep 17 00:00:00 2001 From: chan Date: Mon, 23 Feb 2026 13:58:34 +0900 Subject: [PATCH] feat(adminfront): Implement hierarchical tree view for user group management (Issue #296)\n\n- Refactor TenantGroupsPage.tsx to display user groups in a collapsible tree structure.\n- Add buildGroupTree utility and UserGroupTreeNode recursive component.\n- Update group creation form with parent group selection and unit type.\n- Enhance mutation callbacks with toast notifications for better user feedback.\n- Fix: Add parentId to TenantSummary type in adminApi.ts for type safety. --- .../tenants/routes/TenantGroupsPage.tsx | 330 +++++++++++++++--- adminfront/src/lib/adminApi.ts | 1 + 2 files changed, 279 insertions(+), 52 deletions(-) diff --git a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx index 2abacef8..c92e411b 100644 --- a/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx +++ b/adminfront/src/features/tenants/routes/TenantGroupsPage.tsx @@ -1,5 +1,8 @@ import { useMutation, useQuery } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; import { + ChevronDown, + ChevronRight, Plus, RefreshCw, Shield, @@ -8,7 +11,7 @@ import { UserPlus, Users, } from "lucide-react"; -import { useState } from "react"; +import React, { useState } from "react"; import { useParams } from "react-router-dom"; import { Badge } from "../../../components/ui/badge"; import { Button } from "../../../components/ui/button"; @@ -35,8 +38,160 @@ import { deleteGroup, fetchGroups, removeGroupMember, + type GroupSummary, } from "../../../lib/adminApi"; import { t } from "../../../lib/i18n"; +import { toast } from "sonner"; + +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 + groups.forEach((group) => { + childrenOf.set(group.id, []); + }); + + // Second pass: Populate children + groups.forEach((group) => { + 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)); + nodes.forEach(node => { + 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: any; // Simplified type for now + removeMemberMutation: any; // Simplified type for now +} + +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 }>(); @@ -44,6 +199,9 @@ function TenantGroupsPage() { const [newGroupName, setNewGroupName] = useState(""); const [newGroupDesc, setNewGroupNameDesc] = useState(""); + const [newGroupUnitType, setNewGroupUnitType] = useState("Team"); + const [newGroupParentId, setNewGroupParentId] = useState(null); + const [selectedGroupId, setSelectedGroupId] = useState(null); // 그룹 목록 조회 @@ -53,34 +211,74 @@ function TenantGroupsPage() { enabled: tenantId.length > 0, }); - // 사용자 목록 조회 (멤버 추가용) + // 그룹 생성 const createMutation = useMutation({ mutationFn: () => - createGroup(tenantId, { name: newGroupName, description: newGroupDesc }), + 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: () => groupsQuery.refetch(), + 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: () => groupsQuery.refetch(), + 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: () => groupsQuery.refetch(), + 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( @@ -105,6 +303,9 @@ function TenantGroupsPage() { {" "} {t("ui.admin.groups.create.title", "새 그룹 생성")} + + {t("ui.admin.groups.create.description", "새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.")} +
@@ -121,6 +322,38 @@ function TenantGroupsPage() { )} />
+
+ + setNewGroupUnitType(e.target.value)} + placeholder={t( + "ui.admin.groups.form.unit_level_placeholder", + "예: 본부, 팀, 셀", + )} + /> +
+
+ + +