forked from baron/baron-sso
Refactored the file input button to use a native HTML label with Radix UI's asChild prop. This ensures the file dialog opens reliably on the first click without relying on JS synthetic click events.
687 lines
24 KiB
TypeScript
687 lines
24 KiB
TypeScript
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import {
|
|
AlertCircle,
|
|
CheckCircle2,
|
|
Download,
|
|
FileText,
|
|
Loader2,
|
|
Upload,
|
|
} 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 { DropdownMenuItem } from "../../../components/ui/dropdown-menu";
|
|
import { ScrollArea } from "../../../components/ui/scroll-area";
|
|
import {
|
|
type BulkUserItem,
|
|
type BulkUserResult,
|
|
bulkCreateUsers,
|
|
createTenant,
|
|
fetchAllTenants,
|
|
fetchUsers,
|
|
} from "../../../lib/adminApi";
|
|
import { t } from "../../../lib/i18n";
|
|
import {
|
|
type TenantCSVRow,
|
|
type TenantImportPreviewRow,
|
|
buildTenantImportPreview,
|
|
} from "../../tenants/utils/tenantCsvImport";
|
|
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
|
|
import { parseUserCSV } from "../utils/csvParser";
|
|
import {
|
|
type HanmacImportEmailPreview,
|
|
buildHanmacImportEmailPreview,
|
|
} from "../utils/hanmacImportEmail";
|
|
|
|
interface UserBulkUploadModalProps {
|
|
onSuccess?: () => void;
|
|
variant?: "button" | "dropdown" | "custom";
|
|
open?: boolean;
|
|
onOpenChange?: (open: boolean) => void;
|
|
}
|
|
|
|
function buildUserTenantPreviewRows(
|
|
users: BulkUserItem[],
|
|
tenants: Parameters<typeof buildTenantImportPreview>[1],
|
|
) {
|
|
const rowsByKey = new Map<string, TenantCSVRow>();
|
|
|
|
users.forEach((user, index) => {
|
|
const key = tenantImportKeyFromUser(user);
|
|
if (!key || rowsByKey.has(key)) {
|
|
return;
|
|
}
|
|
|
|
rowsByKey.set(key, {
|
|
rowNumber: index + 2,
|
|
tenantId: user.tenantImport?.sourceTenantId ?? "",
|
|
name: user.tenantImport?.name || user.tenantSlug || key,
|
|
type: user.tenantImport?.type || "COMPANY",
|
|
parentTenantId: user.tenantImport?.parentTenantId ?? "",
|
|
parentTenantSlug: user.tenantImport?.parentTenantSlug ?? "",
|
|
slug: user.tenantImport?.slug || user.tenantSlug || key,
|
|
memo: user.tenantImport?.memo ?? "",
|
|
emailDomain: user.tenantImport?.emailDomain ?? "",
|
|
visibility: "public",
|
|
orgUnitType: "node",
|
|
});
|
|
});
|
|
|
|
return buildTenantImportPreview([...rowsByKey.values()], tenants);
|
|
}
|
|
|
|
function tenantImportKeyFromUser(user: BulkUserItem) {
|
|
return (
|
|
user.tenantImport?.sourceTenantId ||
|
|
user.tenantImport?.slug ||
|
|
user.tenantSlug ||
|
|
user.tenantImport?.name ||
|
|
""
|
|
);
|
|
}
|
|
|
|
function tenantImportKeyFromRow(row: TenantCSVRow) {
|
|
return row.tenantId || row.slug || row.name;
|
|
}
|
|
|
|
function splitTenantImportDomains(value: string) {
|
|
return value
|
|
.replaceAll("\n", ";")
|
|
.replaceAll(",", ";")
|
|
.split(";")
|
|
.map((domain) => domain.trim().toLowerCase())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function emailLocalPart(email: string) {
|
|
return email.trim().toLowerCase().split("@")[0] || "";
|
|
}
|
|
|
|
function hanmacEmailStatusLabel(preview?: HanmacImportEmailPreview) {
|
|
if (!preview) return "";
|
|
if (preview.status === "suggested") return "제안";
|
|
if (preview.status === "needsReview") return "확인 필요";
|
|
if (preview.status === "ruleMismatch") return "규칙 확인";
|
|
if (preview.status === "blockingError") return "오류";
|
|
return "";
|
|
}
|
|
|
|
function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
|
|
if (!preview) return "text-muted-foreground";
|
|
if (preview.status === "blockingError") return "text-destructive";
|
|
if (preview.status === "ruleMismatch" || preview.status === "needsReview") {
|
|
return "text-amber-600";
|
|
}
|
|
if (preview.status === "suggested") return "text-blue-600";
|
|
return "text-muted-foreground";
|
|
}
|
|
|
|
export const downloadUserTemplate = () => {
|
|
const headers =
|
|
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
|
|
const example =
|
|
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
|
|
const blob = new Blob([`${headers}\n${example}`], {
|
|
type: "text/csv;charset=utf-8;",
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "user_bulk_template.csv";
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
export function UserBulkUploadModal({
|
|
onSuccess,
|
|
variant = "button",
|
|
open: controlledOpen,
|
|
onOpenChange: controlledOnOpenChange,
|
|
}: UserBulkUploadModalProps) {
|
|
const [localOpen, setLocalOpen] = React.useState(false);
|
|
const open = controlledOpen !== undefined ? controlledOpen : localOpen;
|
|
const setOpen = (val: boolean) => {
|
|
setLocalOpen(val);
|
|
controlledOnOpenChange?.(val);
|
|
};
|
|
const [file, setFile] = React.useState<File | null>(null);
|
|
const [parsing, setParsing] = React.useState(false);
|
|
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
|
|
const [tenantPreviewRows, setTenantPreviewRows] = React.useState<
|
|
TenantImportPreviewRow[]
|
|
>([]);
|
|
const [selectedTenantMatches, setSelectedTenantMatches] = React.useState<
|
|
Record<number, string>
|
|
>({});
|
|
const [selectedTenantCreateSlugs, setSelectedTenantCreateSlugs] =
|
|
React.useState<Record<number, string>>({});
|
|
const [results, setResults] = React.useState<BulkUserResult[] | null>(null);
|
|
const [preparing, setPreparing] = React.useState(false);
|
|
const fileInputRef = React.useRef<HTMLInputElement>(null);
|
|
|
|
const tenantQuery = useQuery({
|
|
queryKey: ["tenants", "user-bulk-import"],
|
|
queryFn: () => fetchAllTenants(),
|
|
});
|
|
|
|
const usersQuery = useQuery({
|
|
queryKey: ["users", "user-bulk-import-email-policy"],
|
|
queryFn: () => fetchUsers(10000, 0),
|
|
enabled: open,
|
|
});
|
|
|
|
const mutation = useMutation({
|
|
mutationFn: bulkCreateUsers,
|
|
onSuccess: (data) => {
|
|
setResults(data.results);
|
|
onSuccess?.();
|
|
},
|
|
});
|
|
|
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const selectedFile = e.target.files?.[0];
|
|
if (selectedFile) {
|
|
setFile(selectedFile);
|
|
parseCSV(selectedFile);
|
|
}
|
|
};
|
|
|
|
const parseCSV = (file: File) => {
|
|
setParsing(true);
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const text = e.target?.result as string;
|
|
const data = parseUserCSV(text);
|
|
setPreviewData(data);
|
|
const tenantRows = buildUserTenantPreviewRows(
|
|
data,
|
|
tenantQuery.data?.items ?? [],
|
|
);
|
|
setTenantPreviewRows(tenantRows);
|
|
setSelectedTenantMatches(
|
|
Object.fromEntries(
|
|
tenantRows.map((row) => [
|
|
row.row.rowNumber,
|
|
row.defaultTenantId || "__create__",
|
|
]),
|
|
),
|
|
);
|
|
setSelectedTenantCreateSlugs(
|
|
Object.fromEntries(
|
|
tenantRows.map((row) => [row.row.rowNumber, row.defaultCreateSlug]),
|
|
),
|
|
);
|
|
setParsing(false);
|
|
};
|
|
reader.readAsText(file);
|
|
};
|
|
|
|
const handleUpload = async () => {
|
|
if (previewData.length > 0) {
|
|
setPreparing(true);
|
|
try {
|
|
const users = await resolveUserImportTenants();
|
|
mutation.mutate(users);
|
|
} finally {
|
|
setPreparing(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const resolveUserImportTenants = async () => {
|
|
const tenants = tenantQuery.data?.items ?? [];
|
|
const tenantByKey = new Map<
|
|
string,
|
|
{ id: string; slug: string; emailDomain: string }
|
|
>();
|
|
|
|
for (const preview of tenantPreviewRows) {
|
|
const key = tenantImportKeyFromRow(preview.row);
|
|
const selected =
|
|
selectedTenantMatches[preview.row.rowNumber] ?? "__create__";
|
|
if (selected !== "__create__") {
|
|
const tenant = tenants.find((item) => item.id === selected);
|
|
if (tenant) {
|
|
tenantByKey.set(key, {
|
|
id: tenant.id,
|
|
slug: tenant.slug,
|
|
emailDomain: preview.row.emailDomain,
|
|
});
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const created = await createTenant({
|
|
name: preview.row.name || preview.row.slug,
|
|
slug:
|
|
selectedTenantCreateSlugs[preview.row.rowNumber] ||
|
|
preview.defaultCreateSlug,
|
|
type: preview.row.type || "COMPANY",
|
|
parentId: preview.row.parentTenantId || undefined,
|
|
description: preview.row.memo,
|
|
domains: splitTenantImportDomains(preview.row.emailDomain),
|
|
status: "active",
|
|
});
|
|
tenantByKey.set(key, {
|
|
id: created.id,
|
|
slug: created.slug,
|
|
emailDomain: preview.row.emailDomain,
|
|
});
|
|
}
|
|
|
|
return previewData.map((user, index) => {
|
|
const key = tenantImportKeyFromUser(user);
|
|
const resolvedTenant = key ? tenantByKey.get(key) : undefined;
|
|
const emailPreview = hanmacEmailPreviews[index];
|
|
const { tenantImport: _tenantImport, ...payload } = user;
|
|
return {
|
|
...payload,
|
|
email: emailPreview?.finalEmail ?? payload.email,
|
|
tenantId: resolvedTenant?.id ?? payload.tenantId,
|
|
tenantSlug: resolvedTenant?.slug ?? payload.tenantSlug,
|
|
emailDomain: resolvedTenant?.emailDomain ?? payload.emailDomain,
|
|
};
|
|
});
|
|
};
|
|
|
|
const downloadTemplate = () => {
|
|
const headers =
|
|
"email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
|
|
const example =
|
|
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
|
|
const blob = new Blob([`${headers}\n${example}`], {
|
|
type: "text/csv;charset=utf-8;",
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "user_bulk_template.csv";
|
|
a.click();
|
|
URL.revokeObjectURL(url);
|
|
};
|
|
|
|
const reset = () => {
|
|
setFile(null);
|
|
setPreviewData([]);
|
|
setTenantPreviewRows([]);
|
|
setSelectedTenantMatches({});
|
|
setSelectedTenantCreateSlugs({});
|
|
setResults(null);
|
|
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
};
|
|
|
|
const successCount = results?.filter((r) => r.success).length ?? 0;
|
|
const failCount = results ? results.length - successCount : 0;
|
|
const tenants = tenantQuery.data?.items ?? [];
|
|
const existingHanmacLocalParts = React.useMemo(() => {
|
|
const values = new Set<string>();
|
|
for (const user of usersQuery.data?.items ?? []) {
|
|
if (!isHanmacFamilyUser(user, tenants)) {
|
|
continue;
|
|
}
|
|
const localPart = emailLocalPart(user.email);
|
|
if (localPart) values.add(localPart);
|
|
}
|
|
return values;
|
|
}, [tenants, usersQuery.data?.items]);
|
|
const hanmacEmailPreviews = React.useMemo(() => {
|
|
const batchLocalParts = new Set<string>();
|
|
return previewData.map((user) => {
|
|
const tenant = tenants.find(
|
|
(item) =>
|
|
item.slug.toLowerCase() === user.tenantSlug?.trim().toLowerCase(),
|
|
);
|
|
if (!isHanmacFamilyTenant(tenant, tenants)) {
|
|
return undefined;
|
|
}
|
|
return buildHanmacImportEmailPreview(
|
|
user,
|
|
existingHanmacLocalParts,
|
|
batchLocalParts,
|
|
);
|
|
});
|
|
}, [existingHanmacLocalParts, previewData, tenants]);
|
|
const hasBlockingHanmacEmailRows = hanmacEmailPreviews.some(
|
|
(preview) => preview?.status === "blockingError",
|
|
);
|
|
|
|
const triggerProps = {
|
|
disabled: mutation.isPending,
|
|
"data-testid": "bulk-import-btn",
|
|
};
|
|
|
|
const triggerNode =
|
|
variant === "dropdown" ? (
|
|
<DropdownMenuItem
|
|
onClick={() => setOpen(true)}
|
|
className="cursor-pointer"
|
|
{...triggerProps}
|
|
>
|
|
<Upload size={16} className="mr-2 opacity-50" />
|
|
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
|
|
</DropdownMenuItem>
|
|
) : variant === "custom" ? null : (
|
|
<DialogTrigger asChild>
|
|
<Button variant="outline" className="gap-2" {...triggerProps}>
|
|
<Upload size={16} />
|
|
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
|
|
</Button>
|
|
</DialogTrigger>
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{variant === "dropdown" ? triggerNode : null}
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(val) => {
|
|
setOpen(val);
|
|
if (!val) reset();
|
|
}}
|
|
>
|
|
{variant !== "dropdown" && variant !== "custom" && triggerNode}
|
|
<DialogContent className="max-w-2xl">
|
|
<DialogHeader>
|
|
<DialogTitle data-testid="bulk-upload-title">
|
|
{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t(
|
|
"msg.admin.users.bulk.description",
|
|
"CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.",
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
{!results ? (
|
|
<div className="space-y-4 py-4">
|
|
<div className="flex justify-between items-center">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={downloadTemplate}
|
|
className="gap-2"
|
|
>
|
|
<Download size={14} />
|
|
{t(
|
|
"ui.admin.users.bulk.download_template",
|
|
"템플릿 다운로드",
|
|
)}
|
|
</Button>
|
|
<Button asChild variant="secondary" className="cursor-pointer">
|
|
<label>
|
|
{file
|
|
? t("ui.common.change_file", "파일 변경")
|
|
: t("ui.common.select_file", "파일 선택")}
|
|
<input
|
|
type="file"
|
|
accept=".csv"
|
|
className="hidden"
|
|
ref={fileInputRef}
|
|
onChange={handleFileChange}
|
|
onClick={(e) => {
|
|
// Allow picking the same file again if it was cleared
|
|
(e.target as HTMLInputElement).value = "";
|
|
}}
|
|
/>
|
|
</label>
|
|
</Button>
|
|
</div>
|
|
|
|
{file && (
|
|
<div className="rounded-lg border p-4 bg-muted/20">
|
|
<div className="flex items-center gap-3 mb-2">
|
|
<FileText className="text-primary" />
|
|
<span className="font-medium">{file.name}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
({(file.size / 1024).toFixed(1)} KB)
|
|
</span>
|
|
</div>
|
|
{parsing ? (
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 size={14} className="animate-spin" />
|
|
{t("msg.common.parsing", "파싱 중...")}
|
|
</div>
|
|
) : (
|
|
<div className="text-sm text-muted-foreground">
|
|
{t(
|
|
"msg.admin.users.bulk.parsed_count",
|
|
"{{count}}명의 사용자가 감지되었습니다.",
|
|
{ count: previewData.length },
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tenantPreviewRows.length > 0 && (
|
|
<div
|
|
className="rounded-md border p-3 text-sm"
|
|
data-testid="user-import-tenant-resolution"
|
|
>
|
|
<div className="mb-2 font-medium">
|
|
{t("ui.admin.users.bulk.tenant_resolution", "테넌트 매핑")}
|
|
</div>
|
|
<div className="space-y-2">
|
|
{tenantPreviewRows.map((preview) => (
|
|
<div
|
|
key={preview.row.rowNumber}
|
|
className="grid gap-2 sm:grid-cols-[1fr_1fr]"
|
|
>
|
|
<div>
|
|
<div className="font-medium">{preview.row.name}</div>
|
|
<div className="font-mono text-xs text-muted-foreground">
|
|
{preview.row.slug}
|
|
</div>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<select
|
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
|
value={
|
|
selectedTenantMatches[preview.row.rowNumber] ??
|
|
"__create__"
|
|
}
|
|
onChange={(event) =>
|
|
setSelectedTenantMatches((prev) => ({
|
|
...prev,
|
|
[preview.row.rowNumber]: event.target.value,
|
|
}))
|
|
}
|
|
>
|
|
<option value="__create__">
|
|
{t(
|
|
"ui.admin.users.bulk.create_missing_tenant",
|
|
"신규 생성",
|
|
)}
|
|
</option>
|
|
{preview.candidates.map((candidate) => (
|
|
<option
|
|
key={candidate.tenantId}
|
|
value={candidate.tenantId}
|
|
>
|
|
{candidate.name} ({candidate.slug})
|
|
</option>
|
|
))}
|
|
</select>
|
|
{(selectedTenantMatches[preview.row.rowNumber] ??
|
|
"__create__") === "__create__" && (
|
|
<input
|
|
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm"
|
|
value={
|
|
selectedTenantCreateSlugs[
|
|
preview.row.rowNumber
|
|
] ?? ""
|
|
}
|
|
onChange={(event) =>
|
|
setSelectedTenantCreateSlugs((prev) => ({
|
|
...prev,
|
|
[preview.row.rowNumber]: event.target.value,
|
|
}))
|
|
}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{previewData.length > 0 && (
|
|
<ScrollArea className="h-[200px] rounded-md border">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-muted sticky top-0">
|
|
<tr>
|
|
<th className="p-2 text-left">Email</th>
|
|
<th className="p-2 text-left">Name</th>
|
|
<th className="p-2 text-left">Tenant</th>
|
|
<th className="p-2 text-left">Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{previewData.slice(0, 10).map((u, index) => (
|
|
<tr key={`${u.email}-${index}`} className="border-t">
|
|
<td className="p-2">
|
|
<input
|
|
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"
|
|
value={
|
|
hanmacEmailPreviews[index]?.finalEmail ??
|
|
u.email
|
|
}
|
|
onChange={(event) =>
|
|
setPreviewData((prev) =>
|
|
prev.map((item, itemIndex) =>
|
|
itemIndex === index
|
|
? { ...item, email: event.target.value }
|
|
: item,
|
|
),
|
|
)
|
|
}
|
|
/>
|
|
</td>
|
|
<td className="p-2">{u.name}</td>
|
|
<td className="p-2">{u.tenantSlug || "-"}</td>
|
|
<td
|
|
className={`p-2 text-xs ${hanmacEmailStatusClass(
|
|
hanmacEmailPreviews[index],
|
|
)}`}
|
|
>
|
|
{hanmacEmailStatusLabel(hanmacEmailPreviews[index])}
|
|
{hanmacEmailPreviews[index]?.reason && (
|
|
<div>{hanmacEmailPreviews[index]?.reason}</div>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
{previewData.length > 10 && (
|
|
<tr>
|
|
<td
|
|
colSpan={4}
|
|
className="p-2 text-center text-muted-foreground italic"
|
|
>
|
|
... and {previewData.length - 10} more users
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</ScrollArea>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-4 py-4">
|
|
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
|
|
<div className="flex-1 text-center">
|
|
<div className="text-2xl font-bold text-green-600">
|
|
{successCount}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground uppercase">
|
|
{t("ui.common.success", "성공")}
|
|
</div>
|
|
</div>
|
|
<div className="w-px h-10 bg-border" />
|
|
<div className="flex-1 text-center">
|
|
<div className="text-2xl font-bold text-destructive">
|
|
{failCount}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground uppercase">
|
|
{t("ui.common.fail", "실패")}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<ScrollArea className="h-[250px] rounded-md border">
|
|
<div className="p-2 space-y-2">
|
|
{results.map((r) => (
|
|
<div
|
|
key={r.email}
|
|
className="flex items-start gap-3 p-2 rounded border bg-card text-sm"
|
|
>
|
|
{r.success ? (
|
|
<CheckCircle2
|
|
size={16}
|
|
className="text-green-500 mt-0.5"
|
|
/>
|
|
) : (
|
|
<AlertCircle
|
|
size={16}
|
|
className="text-destructive mt-0.5"
|
|
/>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="font-medium truncate">{r.email}</div>
|
|
{!r.success && (
|
|
<div className="text-xs text-destructive">
|
|
{r.message}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</ScrollArea>
|
|
</div>
|
|
)}
|
|
|
|
<DialogFooter>
|
|
{!results ? (
|
|
<Button
|
|
onClick={handleUpload}
|
|
disabled={
|
|
previewData.length === 0 ||
|
|
mutation.isPending ||
|
|
preparing ||
|
|
hasBlockingHanmacEmailRows
|
|
}
|
|
className="w-full sm:w-auto"
|
|
data-testid="bulk-start-btn"
|
|
>
|
|
{(mutation.isPending || preparing) && (
|
|
<Loader2 size={16} className="mr-2 animate-spin" />
|
|
)}
|
|
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
|
|
</Button>
|
|
) : (
|
|
<Button
|
|
onClick={() => setOpen(false)}
|
|
className="w-full sm:w-auto"
|
|
data-testid="bulk-close-dialog-btn"
|
|
>
|
|
{t("ui.common.close", "닫기")}
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|