1
0
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:
2026-05-06 16:35:20 +09:00
parent 97fb89b831
commit 3064126709

View File

@@ -2,13 +2,16 @@ import {
type UseMutationResult,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowRightLeft,
ChevronDown,
ChevronRight,
Plus,
RefreshCw,
Search,
Shield,
Trash2,
UserMinus,
@@ -27,8 +30,18 @@ import {
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 { ScrollArea } from "../../../components/ui/scroll-area";
import {
Table,
TableBody,
@@ -44,6 +57,8 @@ import {
createGroup,
deleteGroup,
fetchGroups,
fetchTenant,
fetchUsers,
removeGroupMember,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
@@ -223,6 +238,7 @@ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
function TenantGroupsPage() {
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
const queryClient = useQueryClient();
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupNameDesc] = useState("");
@@ -231,13 +247,35 @@ function TenantGroupsPage() {
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({
queryKey: ["groups", tenantId],
queryFn: () => fetchGroups(tenantId),
enabled: tenantId.length > 0,
});
// 그룹 생성
const createMutation = useMutation({
mutationFn: () =>
@@ -318,32 +356,48 @@ 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
? 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(
"msg.admin.groups.prompt.user_id",
"추가할 사용자의 UUID를 입력하세요:",
),
);
if (userId) {
addMemberMutation.mutate({ groupId, userId });
}
};
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
return (
<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="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">
{/* 그룹 생성 폼 */}
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<CardHeader className="flex-shrink-0">
@@ -544,7 +598,7 @@ function TenantGroupsPage() {
<div className="flex justify-end mb-4 flex-shrink-0">
<Button
size="sm"
onClick={() => handleAddMember(currentGroup.id)}
onClick={() => setIsAddMemberModalOpen(true)}
disabled={addMemberMutation.isPending}
>
<UserPlus size={14} className="mr-1" />
@@ -563,7 +617,7 @@ function TenantGroupsPage() {
{t("ui.admin.groups.members.table.email", "이메일")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.members.table.remove", "제거")}
{t("ui.admin.groups.members.table.actions", "관리")}
</TableHead>
</TableRow>
</TableHeader>
@@ -590,19 +644,44 @@ function TenantGroupsPage() {
{user.email}
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() =>
removeMemberMutation.mutate({
groupId: currentGroup.id,
userId: user.id,
})
}
disabled={removeMemberMutation.isPending}
>
<UserMinus size={14} className="text-destructive" />
</Button>
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
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({
groupId: currentGroup.id,
userId: user.id,
});
}
}}
disabled={removeMemberMutation.isPending}
title={t("ui.common.remove", "제거")}
>
<UserMinus size={14} className="text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
@@ -610,11 +689,205 @@ function TenantGroupsPage() {
</Table>
</div>
</div>
</CardContent>
</Card>
)}
</div>
);
}
</CardContent>
</Card>
)}
</div>
export default TenantGroupsPage;
{/* 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>
</>
);
}
export default TenantGroupsPage;