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 { ScrollArea } from "../../../components/ui/scroll-area"; import { type BulkUserItem, type BulkUserResult, bulkCreateUsers, createTenant, fetchTenants, 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; } function buildUserTenantPreviewRows( users: BulkUserItem[], tenants: Parameters[1], ) { const rowsByKey = new Map(); 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 ?? "", }); }); 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 function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) { const [open, setOpen] = React.useState(false); const [file, setFile] = React.useState(null); const [parsing, setParsing] = React.useState(false); const [previewData, setPreviewData] = React.useState([]); const [tenantPreviewRows, setTenantPreviewRows] = React.useState< TenantImportPreviewRow[] >([]); const [selectedTenantMatches, setSelectedTenantMatches] = React.useState< Record >({}); const [selectedTenantCreateSlugs, setSelectedTenantCreateSlugs] = React.useState>({}); const [results, setResults] = React.useState(null); const [preparing, setPreparing] = React.useState(false); const fileInputRef = React.useRef(null); const tenantQuery = useQuery({ queryKey: ["tenants", "user-bulk-import"], queryFn: () => fetchTenants(1000, 0), }); 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) => { 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(); 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(); 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", ); return ( { setOpen(val); if (!val) reset(); }} > {t("ui.admin.users.bulk.title", "사용자 일괄 등록")} {t( "msg.admin.users.bulk.description", "CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.", )} {!results ? (
{file && (
{file.name} ({(file.size / 1024).toFixed(1)} KB)
{parsing ? (
{t("msg.common.parsing", "파싱 중...")}
) : (
{t( "msg.admin.users.bulk.parsed_count", "{{count}}명의 사용자가 감지되었습니다.", { count: previewData.length }, )}
)}
)} {tenantPreviewRows.length > 0 && (
{t("ui.admin.users.bulk.tenant_resolution", "테넌트 매핑")}
{tenantPreviewRows.map((preview) => (
{preview.row.name}
{preview.row.slug}
{(selectedTenantMatches[preview.row.rowNumber] ?? "__create__") === "__create__" && ( setSelectedTenantCreateSlugs((prev) => ({ ...prev, [preview.row.rowNumber]: event.target.value, })) } /> )}
))}
)} {previewData.length > 0 && ( {previewData.slice(0, 10).map((u, index) => ( ))} {previewData.length > 10 && ( )}
Email Name Tenant Status
setPreviewData((prev) => prev.map((item, itemIndex) => itemIndex === index ? { ...item, email: event.target.value } : item, ), ) } /> {u.name} {u.tenantSlug || "-"} {hanmacEmailStatusLabel(hanmacEmailPreviews[index])} {hanmacEmailPreviews[index]?.reason && (
{hanmacEmailPreviews[index]?.reason}
)}
... and {previewData.length - 10} more users
)}
) : (
{successCount}
{t("ui.common.success", "성공")}
{failCount}
{t("ui.common.fail", "실패")}
{results.map((r) => (
{r.success ? ( ) : ( )}
{r.email}
{!r.success && (
{r.message}
)}
))}
)} {!results ? ( ) : ( )}
); }