forked from baron/baron-sso
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.
This commit is contained in:
@@ -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<string, UserGroupNode[]>();
|
||||
|
||||
// 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<UserGroupTreeNodeProps> = ({
|
||||
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 (
|
||||
<>
|
||||
<TableRow
|
||||
className={`cursor-pointer ${selectedGroupId === node.id ? "bg-primary/5" : ""}`}
|
||||
onClick={() => onSelect(node.id)}
|
||||
>
|
||||
<TableCell style={{ paddingLeft: `${1 + level * 1.5}rem` }}>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasChildren ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleToggleExpand}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown size={16} />
|
||||
) : (
|
||||
<ChevronRight size={16} />
|
||||
)}
|
||||
</Button>
|
||||
) : (level > 0 && (
|
||||
<span className="inline-block w-6 text-center">
|
||||
<ChevronRight size={16} className="text-muted-foreground inline-block align-middle" />
|
||||
</span>
|
||||
))}
|
||||
<Users size={14} className="text-muted-foreground" />
|
||||
<span className="font-semibold">{node.name}</span>
|
||||
<Badge variant="secondary" className="text-[10px] font-mono">
|
||||
{node.unitType || "Team"}
|
||||
</Badge>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{t("msg.admin.groups.members.count", "{{count}} 명", {
|
||||
count: node.members?.length || 0,
|
||||
})}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onAddSubGroup(node.id);
|
||||
}}
|
||||
>
|
||||
<Plus size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDelete(node.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} className="text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{isExpanded &&
|
||||
hasChildren &&
|
||||
node.children.map((child) => (
|
||||
<UserGroupTreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
level={level + 1}
|
||||
onSelect={onSelect}
|
||||
selectedGroupId={selectedGroupId}
|
||||
onDelete={onDelete}
|
||||
onAddSubGroup={onAddSubGroup}
|
||||
addMemberMutation={addMemberMutation}
|
||||
removeMemberMutation={removeMemberMutation}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
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<string | null>(null);
|
||||
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(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() {
|
||||
<Plus size={16} />{" "}
|
||||
{t("ui.admin.groups.create.title", "새 그룹 생성")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("ui.admin.groups.create.description", "새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="space-y-1">
|
||||
@@ -121,6 +322,38 @@ function TenantGroupsPage() {
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="unitType">
|
||||
{t("ui.admin.groups.form.unit_level_label", "조직 단위 레벨")}
|
||||
</Label>
|
||||
<Input
|
||||
id="unitType"
|
||||
value={newGroupUnitType}
|
||||
onChange={(e) => setNewGroupUnitType(e.target.value)}
|
||||
placeholder={t(
|
||||
"ui.admin.groups.form.unit_level_placeholder",
|
||||
"예: 본부, 팀, 셀",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="parentId">
|
||||
{t("ui.admin.groups.form.parent_label", "상위 그룹 (선택)")}
|
||||
</Label>
|
||||
<select
|
||||
id="parentId"
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
value={newGroupParentId || ""}
|
||||
onChange={(e) => setNewGroupParentId(e.target.value || null)}
|
||||
>
|
||||
<option value="">{t("ui.common.none", "없음")}</option>
|
||||
{groupsQuery.data?.map((group) => (
|
||||
<option key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="desc">
|
||||
{t("ui.admin.groups.form.desc_label", "설명")}
|
||||
@@ -145,7 +378,7 @@ function TenantGroupsPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 그룹 목록 */}
|
||||
{/* 그룹 목록 (트리 뷰) */}
|
||||
<Card className="bg-[var(--color-panel)] md:col-span-2">
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
@@ -183,53 +416,36 @@ function TenantGroupsPage() {
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groupsQuery.data?.map((group) => (
|
||||
<TableRow
|
||||
key={group.id}
|
||||
className={`cursor-pointer ${selectedGroupId === group.id ? "bg-primary/5" : ""}`}
|
||||
onClick={() => setSelectedGroupId(group.id)}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="font-semibold flex items-center gap-2">
|
||||
<Users size={14} className="text-muted-foreground" />
|
||||
{group.name}
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
{group.description}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{t("msg.admin.groups.members.count", "{{count}} 명", {
|
||||
count: group.members?.length || 0,
|
||||
})}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddMember(group.id);
|
||||
}}
|
||||
>
|
||||
<UserPlus size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
deleteMutation.mutate(group.id);
|
||||
}}
|
||||
>
|
||||
<Trash2 size={14} className="text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
{groupsQuery.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3}>
|
||||
{t("msg.admin.groups.list.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!groupsQuery.isLoading && groupTree.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={3} className="text-center py-8 text-muted-foreground">
|
||||
{t("msg.admin.groups.list.empty", "아직 등록된 그룹이 없습니다.")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{groupTree.map((node) => (
|
||||
<UserGroupTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
level={0}
|
||||
onSelect={setSelectedGroupId}
|
||||
selectedGroupId={selectedGroupId}
|
||||
onDelete={(id) => {
|
||||
if (window.confirm(t("msg.admin.groups.list.delete_confirm", "그룹을 삭제하시겠습니까?"))) {
|
||||
deleteMutation.mutate(id);
|
||||
}
|
||||
}}
|
||||
onAddSubGroup={handleAddSubGroup}
|
||||
addMemberMutation={addMemberMutation}
|
||||
removeMemberMutation={removeMemberMutation}
|
||||
/>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
@@ -247,8 +463,17 @@ function TenantGroupsPage() {
|
||||
name: currentGroup.name,
|
||||
})}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("ui.admin.groups.detail.members_subtitle", "그룹에 속한 멤버들을 확인하고 관리합니다.")}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex justify-end mb-4">
|
||||
<Button size="sm" onClick={() => handleAddMember(currentGroup.id)} disabled={addMemberMutation.isPending}>
|
||||
<UserPlus size={14} className="mr-1" />
|
||||
{t("ui.common.add", "멤버 추가")}
|
||||
</Button>
|
||||
</div>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -290,6 +515,7 @@ function TenantGroupsPage() {
|
||||
userId: user.id,
|
||||
})
|
||||
}
|
||||
disabled={removeMemberMutation.isPending}
|
||||
>
|
||||
<UserMinus size={14} className="text-destructive" />
|
||||
</Button>
|
||||
|
||||
@@ -27,6 +27,7 @@ export type TenantSummary = {
|
||||
description: string;
|
||||
status: string;
|
||||
domains?: string[];
|
||||
parentId?: string;
|
||||
config?: Record<string, unknown>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
|
||||
Reference in New Issue
Block a user