forked from baron/baron-sso
335 lines
12 KiB
TypeScript
335 lines
12 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type { AxiosError } from "axios";
|
|
import { AlertTriangle, FolderTree, Loader2, Search } from "lucide-react";
|
|
import * as React from "react";
|
|
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 { toast } from "../../../components/ui/use-toast";
|
|
import {
|
|
bulkUpdateUsers,
|
|
fetchAllTenants,
|
|
fetchGroups,
|
|
type UserSummary,
|
|
} from "../../../lib/adminApi";
|
|
import { t } from "../../../lib/i18n";
|
|
|
|
type UserSchemaField = {
|
|
key: string;
|
|
label: string;
|
|
required?: boolean;
|
|
};
|
|
|
|
interface UserBulkMoveGroupModalProps {
|
|
userIds: string[];
|
|
selectedUsers?: UserSummary[];
|
|
onSuccess?: () => void;
|
|
}
|
|
|
|
export function UserBulkMoveGroupModal({
|
|
userIds,
|
|
selectedUsers = [],
|
|
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 [acknowledgeWarning, setAcknowledgeWarning] = React.useState(false);
|
|
|
|
const _queryClient = useQueryClient();
|
|
|
|
const { data: tenantsData } = useQuery({
|
|
queryKey: ["tenants", "all"],
|
|
queryFn: () => fetchAllTenants(),
|
|
enabled: open,
|
|
});
|
|
const tenants = tenantsData?.items ?? [];
|
|
|
|
const selectedTenant = React.useMemo(
|
|
() => tenants.find((t) => t.slug === selectedTenantSlug),
|
|
[tenants, selectedTenantSlug],
|
|
);
|
|
const selectedTenantId = selectedTenant?.id ?? "";
|
|
|
|
const { data: groups, isLoading: isGroupsLoading } = useQuery({
|
|
queryKey: ["tenant-groups", selectedTenantId],
|
|
queryFn: () => fetchGroups(selectedTenantId),
|
|
enabled: open && !!selectedTenantId,
|
|
});
|
|
|
|
const schemaWarnings = React.useMemo(() => {
|
|
if (!selectedTenant || selectedUsers.length === 0) return null;
|
|
|
|
const targetSchema =
|
|
(selectedTenant.config?.userSchema as UserSchemaField[]) || [];
|
|
const targetSchemaKeys = new Set(targetSchema.map((f) => f.key));
|
|
const requiredKeys = targetSchema
|
|
.filter((f) => f.required)
|
|
.map((f) => f.key);
|
|
|
|
const missingRequiredFields = new Set<string>();
|
|
const incompatibleFields = new Set<string>();
|
|
|
|
for (const user of selectedUsers) {
|
|
const userMeta = user.metadata || {};
|
|
|
|
// 1. Check for missing required fields
|
|
for (const key of requiredKeys) {
|
|
if (
|
|
userMeta[key] === undefined ||
|
|
userMeta[key] === null ||
|
|
userMeta[key] === ""
|
|
) {
|
|
missingRequiredFields.add(key);
|
|
}
|
|
}
|
|
|
|
// 2. Check for fields that exist in user metadata but not in the target schema (data loss)
|
|
for (const key of Object.keys(userMeta)) {
|
|
if (!targetSchemaKeys.has(key)) {
|
|
incompatibleFields.add(key);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (missingRequiredFields.size === 0 && incompatibleFields.size === 0) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
missing: Array.from(missingRequiredFields),
|
|
incompatible: Array.from(incompatibleFields),
|
|
};
|
|
}, [selectedTenant, selectedUsers]);
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: bulkUpdateUsers,
|
|
onSuccess: () => {
|
|
toast.success(
|
|
t(
|
|
"msg.admin.users.bulk.move_success",
|
|
"사용자들의 부서가 이동되었습니다.",
|
|
),
|
|
);
|
|
setOpen(false);
|
|
onSuccess?.();
|
|
},
|
|
onError: (error: AxiosError<{ error?: string }>) => {
|
|
toast.error(t("msg.admin.users.bulk.move_error", "부서 이동 실패"), {
|
|
description: error.response?.data?.error || error.message,
|
|
});
|
|
},
|
|
});
|
|
|
|
const handleMove = () => {
|
|
if (!selectedTenantSlug) return;
|
|
mutation.mutate({
|
|
userIds,
|
|
tenantSlug: 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={(val) => {
|
|
setOpen(val);
|
|
if (!val) {
|
|
setSelectedTenantSlug("");
|
|
setSelectedGroupName("");
|
|
setAcknowledgeWarning(false);
|
|
setSearchTerm("");
|
|
}
|
|
}}
|
|
>
|
|
<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
|
|
id="bulk-move-target-tenant"
|
|
name="bulk-move-target-tenant"
|
|
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("");
|
|
setAcknowledgeWarning(false);
|
|
}}
|
|
>
|
|
<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>
|
|
)}
|
|
|
|
{schemaWarnings && (
|
|
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 space-y-2 mt-4 text-sm">
|
|
<div className="flex items-center gap-2 text-destructive font-semibold">
|
|
<AlertTriangle size={16} />
|
|
{t("ui.admin.users.bulk.schema_warning", "스키마 호환성 경고")}
|
|
</div>
|
|
<div className="text-destructive/80 text-xs">
|
|
{schemaWarnings.missing.length > 0 && (
|
|
<p>
|
|
{t(
|
|
"msg.admin.users.bulk.schema_missing",
|
|
"대상 테넌트의 필수 필드가 누락되어 있습니다:",
|
|
)}{" "}
|
|
<strong>{schemaWarnings.missing.join(", ")}</strong>
|
|
</p>
|
|
)}
|
|
{schemaWarnings.incompatible.length > 0 && (
|
|
<p>
|
|
{t(
|
|
"msg.admin.users.bulk.schema_incompatible",
|
|
"대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:",
|
|
)}{" "}
|
|
<strong>{schemaWarnings.incompatible.join(", ")}</strong>
|
|
</p>
|
|
)}
|
|
</div>
|
|
<label className="flex items-center gap-2 cursor-pointer mt-2 pt-2 border-t border-destructive/10">
|
|
<input
|
|
id="bulk-move-acknowledge-warning"
|
|
name="bulk-move-acknowledge-warning"
|
|
type="checkbox"
|
|
checked={acknowledgeWarning}
|
|
onChange={(e) => setAcknowledgeWarning(e.target.checked)}
|
|
className="w-4 h-4 rounded border-gray-300 text-destructive focus:ring-destructive"
|
|
/>
|
|
<span className="font-medium text-destructive/90">
|
|
{t(
|
|
"ui.admin.users.bulk.acknowledge_warning",
|
|
"경고를 확인했으며 계속 진행합니다.",
|
|
)}
|
|
</span>
|
|
</label>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
|
{t("ui.common.cancel", "취소")}
|
|
</Button>
|
|
<Button
|
|
onClick={handleMove}
|
|
disabled={
|
|
!selectedTenantSlug ||
|
|
mutation.isPending ||
|
|
(!!schemaWarnings && !acknowledgeWarning)
|
|
}
|
|
>
|
|
{mutation.isPending && (
|
|
<Loader2 size={16} className="mr-2 animate-spin" />
|
|
)}
|
|
{t("ui.admin.users.bulk.do_move", "이동 실행")}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|