forked from baron/baron-sso
feat: enhance organization chart member management
- Replace window.prompt with a searchable user selection modal for adding members to groups. - Introduce a 'Move' feature to seamlessly transfer users between groups within the same tenant. - Improve UX with dedicated Dialogs for member operations (Add, Move, Remove) in the TenantGroupsPage.
This commit is contained in:
@@ -2,13 +2,16 @@ import {
|
|||||||
type UseMutationResult,
|
type UseMutationResult,
|
||||||
useMutation,
|
useMutation,
|
||||||
useQuery,
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
import type { AxiosError } from "axios";
|
||||||
import {
|
import {
|
||||||
|
ArrowRightLeft,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Search,
|
||||||
Shield,
|
Shield,
|
||||||
Trash2,
|
Trash2,
|
||||||
UserMinus,
|
UserMinus,
|
||||||
@@ -27,8 +30,18 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../../components/ui/card";
|
} from "../../../components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "../../../components/ui/dialog";
|
||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import { Label } from "../../../components/ui/label";
|
import { Label } from "../../../components/ui/label";
|
||||||
|
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -44,6 +57,8 @@ import {
|
|||||||
createGroup,
|
createGroup,
|
||||||
deleteGroup,
|
deleteGroup,
|
||||||
fetchGroups,
|
fetchGroups,
|
||||||
|
fetchTenant,
|
||||||
|
fetchUsers,
|
||||||
removeGroupMember,
|
removeGroupMember,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
@@ -223,6 +238,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
|
|||||||
function TenantGroupsPage() {
|
function TenantGroupsPage() {
|
||||||
const params = useParams<{ tenantId: string }>();
|
const params = useParams<{ tenantId: string }>();
|
||||||
const tenantId = params.tenantId ?? "";
|
const tenantId = params.tenantId ?? "";
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const [newGroupName, setNewGroupName] = useState("");
|
const [newGroupName, setNewGroupName] = useState("");
|
||||||
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
const [newGroupDesc, setNewGroupNameDesc] = useState("");
|
||||||
@@ -231,13 +247,35 @@ function TenantGroupsPage() {
|
|||||||
|
|
||||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Modal States
|
||||||
|
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
|
||||||
|
const [isMoveMemberModalOpen, setIsMoveMemberModalOpen] = useState(false);
|
||||||
|
const [memberActionTargetUserId, setMemberActionTargetUserId] = useState<string | null>(null);
|
||||||
|
const [userSearchTerm, setUserSearchTerm] = useState("");
|
||||||
|
const [groupSearchTerm, setGroupSearchTerm] = useState("");
|
||||||
|
|
||||||
|
// 테넌트 정보 조회 (slug 획득)
|
||||||
|
const tenantQuery = useQuery({
|
||||||
|
queryKey: ["tenant", tenantId],
|
||||||
|
queryFn: () => fetchTenant(tenantId),
|
||||||
|
enabled: tenantId.length > 0,
|
||||||
|
});
|
||||||
|
const tenantSlug = tenantQuery.data?.slug;
|
||||||
|
|
||||||
|
// 해당 테넌트의 사용자 목록 조회
|
||||||
|
const usersQuery = useQuery({
|
||||||
|
queryKey: ["users", { tenantSlug }],
|
||||||
|
queryFn: () => fetchUsers(1000, 0, undefined, tenantSlug),
|
||||||
|
enabled: !!tenantSlug,
|
||||||
|
});
|
||||||
|
const users = usersQuery.data?.items ?? [];
|
||||||
|
|
||||||
// 그룹 목록 조회
|
// 그룹 목록 조회
|
||||||
const groupsQuery = useQuery({
|
const groupsQuery = useQuery({
|
||||||
queryKey: ["groups", tenantId],
|
queryKey: ["groups", tenantId],
|
||||||
queryFn: () => fetchGroups(tenantId),
|
queryFn: () => fetchGroups(tenantId),
|
||||||
enabled: tenantId.length > 0,
|
enabled: tenantId.length > 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 그룹 생성
|
// 그룹 생성
|
||||||
const createMutation = useMutation({
|
const createMutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
@@ -318,30 +356,46 @@ function TenantGroupsPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 멤버 이동 (Remove -> Add)
|
||||||
|
const moveMemberMutation = useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
sourceGroupId,
|
||||||
|
targetGroupId,
|
||||||
|
userId,
|
||||||
|
}: {
|
||||||
|
sourceGroupId: string;
|
||||||
|
targetGroupId: string;
|
||||||
|
userId: string;
|
||||||
|
}) => {
|
||||||
|
await removeGroupMember(tenantId, sourceGroupId, userId);
|
||||||
|
await addGroupMember(tenantId, targetGroupId, userId);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(
|
||||||
|
t("msg.admin.groups.members.move_success", "멤버가 이동되었습니다."),
|
||||||
|
);
|
||||||
|
groupsQuery.refetch();
|
||||||
|
setIsMoveMemberModalOpen(false);
|
||||||
|
setMemberActionTargetUserId(null);
|
||||||
|
},
|
||||||
|
onError: (error: AxiosError<{ error?: string }>) => {
|
||||||
|
toast.error(t("msg.common.error", "오류 발생"), {
|
||||||
|
description: error.response?.data?.error || error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const groupTree = groupsQuery.data
|
const groupTree = groupsQuery.data
|
||||||
? buildGroupTree(groupsQuery.data, tenantId)
|
? buildGroupTree(groupsQuery.data, tenantId)
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
const handleAddSubGroup = (parentId: string) => {
|
const handleAddSubGroup = (parentId: string) => {
|
||||||
setNewGroupParentId(parentId);
|
setNewGroupParentId(parentId);
|
||||||
// Optionally scroll to the create form or highlight it
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAddMember = (groupId: string) => {
|
|
||||||
const userId = window.prompt(
|
|
||||||
t(
|
|
||||||
"msg.admin.groups.prompt.user_id",
|
|
||||||
"추가할 사용자의 UUID를 입력하세요:",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (userId) {
|
|
||||||
addMemberMutation.mutate({ groupId, userId });
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
|
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||||
<div className="grid gap-6 md:grid-cols-3 flex-1 min-h-0">
|
<div className="grid gap-6 md:grid-cols-3 flex-1 min-h-0">
|
||||||
{/* 그룹 생성 폼 */}
|
{/* 그룹 생성 폼 */}
|
||||||
@@ -544,7 +598,7 @@ function TenantGroupsPage() {
|
|||||||
<div className="flex justify-end mb-4 flex-shrink-0">
|
<div className="flex justify-end mb-4 flex-shrink-0">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() => handleAddMember(currentGroup.id)}
|
onClick={() => setIsAddMemberModalOpen(true)}
|
||||||
disabled={addMemberMutation.isPending}
|
disabled={addMemberMutation.isPending}
|
||||||
>
|
>
|
||||||
<UserPlus size={14} className="mr-1" />
|
<UserPlus size={14} className="mr-1" />
|
||||||
@@ -563,7 +617,7 @@ function TenantGroupsPage() {
|
|||||||
{t("ui.admin.groups.members.table.email", "이메일")}
|
{t("ui.admin.groups.members.table.email", "이메일")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead className="text-right">
|
<TableHead className="text-right">
|
||||||
{t("ui.admin.groups.members.table.remove", "제거")}
|
{t("ui.admin.groups.members.table.actions", "관리")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
@@ -590,19 +644,44 @@ function TenantGroupsPage() {
|
|||||||
{user.email}
|
{user.email}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() => {
|
||||||
|
setMemberActionTargetUserId(user.id);
|
||||||
|
setIsMoveMemberModalOpen(true);
|
||||||
|
}}
|
||||||
|
disabled={moveMemberMutation.isPending}
|
||||||
|
title={t("ui.common.move", "이동")}
|
||||||
|
>
|
||||||
|
<ArrowRightLeft size={14} className="text-primary" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (
|
||||||
|
window.confirm(
|
||||||
|
t(
|
||||||
|
"msg.admin.groups.members.remove_confirm",
|
||||||
|
"'{{name}}' 님을 이 그룹에서 제외하시겠습니까?",
|
||||||
|
{ name: user.name },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) {
|
||||||
removeMemberMutation.mutate({
|
removeMemberMutation.mutate({
|
||||||
groupId: currentGroup.id,
|
groupId: currentGroup.id,
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
}}
|
||||||
disabled={removeMemberMutation.isPending}
|
disabled={removeMemberMutation.isPending}
|
||||||
|
title={t("ui.common.remove", "제거")}
|
||||||
>
|
>
|
||||||
<UserMinus size={14} className="text-destructive" />
|
<UserMinus size={14} className="text-destructive" />
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -614,6 +693,200 @@ function TenantGroupsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Add Member Modal */}
|
||||||
|
<Dialog
|
||||||
|
open={isAddMemberModalOpen}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setIsAddMemberModalOpen(val);
|
||||||
|
if (!val) setUserSearchTerm("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("ui.admin.groups.members.add_modal_title", "그룹에 멤버 추가")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t(
|
||||||
|
"msg.admin.groups.members.add_modal_desc",
|
||||||
|
"이 테넌트에 속한 사용자 중 추가할 멤버를 검색하여 선택하세요.",
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={t("ui.common.search", "검색...")}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
value={userSearchTerm}
|
||||||
|
onChange={(e) => setUserSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-[250px] rounded-md border p-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{usersQuery.isLoading ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
{t("ui.common.loading", "로딩 중...")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
users
|
||||||
|
.filter((u) => {
|
||||||
|
const term = userSearchTerm.toLowerCase();
|
||||||
|
return (
|
||||||
|
u.name.toLowerCase().includes(term) ||
|
||||||
|
u.email.toLowerCase().includes(term)
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter(
|
||||||
|
(u) =>
|
||||||
|
!currentGroup?.members?.some((m) => m.id === u.id),
|
||||||
|
) // Exclude existing members
|
||||||
|
.map((user) => (
|
||||||
|
<div
|
||||||
|
key={user.id}
|
||||||
|
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition hover:bg-muted"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">{user.name}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{user.email}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={() => {
|
||||||
|
if (currentGroup) {
|
||||||
|
addMemberMutation.mutate({
|
||||||
|
groupId: currentGroup.id,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={addMemberMutation.isPending}
|
||||||
|
>
|
||||||
|
{t("ui.common.add", "추가")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{users.length > 0 &&
|
||||||
|
users.filter(
|
||||||
|
(u) => !currentGroup?.members?.some((m) => m.id === u.id),
|
||||||
|
).length === 0 && (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
{t("msg.admin.groups.members.all_added", "모든 테넌트 멤버가 이미 이 그룹에 속해 있습니다.")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsAddMemberModalOpen(false)}
|
||||||
|
>
|
||||||
|
{t("ui.common.close", "닫기")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Move Member Modal */}
|
||||||
|
<Dialog
|
||||||
|
open={isMoveMemberModalOpen}
|
||||||
|
onOpenChange={(val) => {
|
||||||
|
setIsMoveMemberModalOpen(val);
|
||||||
|
if (!val) {
|
||||||
|
setMemberActionTargetUserId(null);
|
||||||
|
setGroupSearchTerm("");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("ui.admin.groups.members.move_modal_title", "부서 이동")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t(
|
||||||
|
"msg.admin.groups.members.move_modal_desc",
|
||||||
|
"선택한 멤버를 이동할 대상 그룹을 선택하세요.",
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={t("ui.common.search_group", "그룹 검색...")}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
value={groupSearchTerm}
|
||||||
|
onChange={(e) => setGroupSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-[250px] rounded-md border p-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
{groupsQuery.isLoading ? (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
{t("ui.common.loading", "로딩 중...")}
|
||||||
|
</div>
|
||||||
|
) : groupsQuery.data && groupsQuery.data.length > 0 ? (
|
||||||
|
groupsQuery.data
|
||||||
|
.filter((g) =>
|
||||||
|
g.name
|
||||||
|
.toLowerCase()
|
||||||
|
.includes(groupSearchTerm.toLowerCase()),
|
||||||
|
)
|
||||||
|
.filter((g) => g.id !== currentGroup?.id) // Exclude current group
|
||||||
|
.map((group) => (
|
||||||
|
<div
|
||||||
|
key={group.id}
|
||||||
|
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition hover:bg-muted"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users size={14} className="text-muted-foreground" />
|
||||||
|
<span className="font-medium">{group.name}</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => {
|
||||||
|
if (currentGroup && memberActionTargetUserId) {
|
||||||
|
moveMemberMutation.mutate({
|
||||||
|
sourceGroupId: currentGroup.id,
|
||||||
|
targetGroupId: group.id,
|
||||||
|
userId: memberActionTargetUserId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={moveMemberMutation.isPending}
|
||||||
|
>
|
||||||
|
{t("ui.common.move", "이동")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="p-4 text-center text-sm text-muted-foreground">
|
||||||
|
{t("msg.admin.groups.list.no_results", "그룹이 없습니다.")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setIsMoveMemberModalOpen(false)}
|
||||||
|
>
|
||||||
|
{t("ui.common.cancel", "취소")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user