첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
@@ -0,0 +1,334 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user