1
0
forked from baron/baron-sso

feat: implement bulk user group/tenant move functionality

This commit is contained in:
2026-03-04 16:15:13 +09:00
parent 5034785582
commit f55374f827
3 changed files with 230 additions and 11 deletions

View File

@@ -53,6 +53,7 @@ import {
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal";
type UserSchemaField = {
key: string;
@@ -552,6 +553,13 @@ function UserListPage() {
>
{t("ui.common.status.inactive", "비활성화")}
</Button>
<UserBulkMoveGroupModal
userIds={selectedUserIds}
onSuccess={() => {
query.refetch();
setSelectedUserIds([]);
}}
/>
<div className="w-px h-4 bg-background/20 mx-1" />
<Button
variant="ghost"

View File

@@ -0,0 +1,180 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { FolderTree, Loader2, Search } from "lucide-react";
import * as React from "react";
import { toast } from "sonner";
import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { ScrollArea } from "../../../components/ui/scroll-area";
import {
bulkUpdateUsers,
fetchGroups,
fetchTenants,
type TenantSummary,
type GroupSummary
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
interface UserBulkMoveGroupModalProps {
userIds: string[];
onSuccess?: () => void;
}
export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroupModalProps) {
const [open, setOpen] = React.useState(false);
const [selectedTenantSlug, setSelectedTenantSlug] = React.useState<string>("");
const [selectedGroupName, setSelectedGroupName] = React.useState<string>("");
const [searchTerm, setSearchTerm] = React.useState("");
const queryClient = useQueryClient();
const { data: tenantsData } = useQuery({
queryKey: ["tenants", { limit: 100 }],
queryFn: () => fetchTenants(100, 0),
enabled: open,
});
const tenants = tenantsData?.items ?? [];
const selectedTenantId = React.useMemo(() =>
tenants.find(t => t.slug === selectedTenantSlug)?.id ?? "",
[tenants, selectedTenantSlug]);
const { data: groups, isLoading: isGroupsLoading } = useQuery({
queryKey: ["tenant-groups", selectedTenantId],
queryFn: () => fetchGroups(selectedTenantId),
enabled: open && !!selectedTenantId,
});
const mutation = useMutation({
mutationFn: bulkUpdateUsers,
onSuccess: () => {
toast.success(t("msg.admin.users.bulk.move_success", "사용자들의 부서가 이동되었습니다."));
setOpen(false);
onSuccess?.();
},
onError: (error: any) => {
toast.error(t("msg.admin.users.bulk.move_error", "부서 이동 실패"), {
description: error.response?.data?.error || error.message,
});
},
});
const handleMove = () => {
if (!selectedTenantSlug) return;
mutation.mutate({
userIds,
companyCode: selectedTenantSlug,
department: selectedGroupName, // can be empty for "No Department"
});
};
const filteredGroups = React.useMemo(() => {
if (!groups) return [];
if (!searchTerm) return groups;
return groups.filter(g => g.name.toLowerCase().includes(searchTerm.toLowerCase()));
}, [groups, searchTerm]);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-background hover:bg-background/10 h-8">
{t("ui.admin.users.bulk.move_group", "부서 이동")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("ui.admin.users.bulk.move_title", "사용자 부서 이동")}</DialogTitle>
<DialogDescription>
{t("msg.admin.users.bulk.move_description", "선택한 {{count}}명의 사용자를 이동할 테넌트와 부서를 선택하세요.", { count: userIds.length })}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">{t("ui.admin.users.create.form.tenant", "테넌트 선택")}</label>
<select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={selectedTenantSlug}
onChange={(e) => {
setSelectedTenantSlug(e.target.value);
setSelectedGroupName("");
}}
>
<option value="">{t("ui.common.select", "선택하세요...")}</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>{t.name} ({t.slug})</option>
))}
</select>
</div>
{selectedTenantSlug && (
<div className="space-y-2">
<label className="text-sm font-medium">{t("ui.admin.users.bulk.select_group", "부서 선택")}</label>
<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={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<ScrollArea className="h-[200px] rounded-md border p-2">
<div className="space-y-1">
<button
type="button"
onClick={() => setSelectedGroupName("")}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
selectedGroupName === "" ? "bg-primary text-primary-foreground" : "hover:bg-muted"
}`}
>
<FolderTree size={14} />
{t("ui.admin.users.bulk.no_department", "(부서 없음)")}
</button>
{isGroupsLoading ? (
<div className="flex justify-center py-4"><Loader2 className="animate-spin text-muted-foreground" /></div>
) : (
filteredGroups.map((group) => (
<button
key={group.id}
type="button"
onClick={() => setSelectedGroupName(group.name)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
selectedGroupName === group.name ? "bg-primary text-primary-foreground" : "hover:bg-muted"
}`}
>
<FolderTree size={14} />
{group.name}
</button>
))
)}
</div>
</ScrollArea>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={handleMove}
disabled={!selectedTenantSlug || mutation.isPending}
>
{mutation.isPending && <Loader2 size={16} className="mr-2 animate-spin" />}
{t("ui.admin.users.bulk.do_move", "이동 실행")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}