forked from baron/baron-sso
632 lines
22 KiB
TypeScript
632 lines
22 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 { 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<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 function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
|
const [open, setOpen] = React.useState(false);
|
|
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: () => 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<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",
|
|
);
|
|
|
|
return (
|
|
<Dialog
|
|
open={open}
|
|
onOpenChange={(val) => {
|
|
setOpen(val);
|
|
if (!val) reset();
|
|
}}
|
|
>
|
|
<DialogTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
className="gap-2"
|
|
data-testid="bulk-import-btn"
|
|
>
|
|
<Upload size={16} />
|
|
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
|
|
</Button>
|
|
</DialogTrigger>
|
|
<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>
|
|
<input
|
|
type="file"
|
|
accept=".csv"
|
|
className="hidden"
|
|
ref={fileInputRef}
|
|
onChange={handleFileChange}
|
|
/>
|
|
<Button
|
|
onClick={() => fileInputRef.current?.click()}
|
|
variant="secondary"
|
|
>
|
|
{file
|
|
? t("ui.common.change_file", "파일 변경")
|
|
: t("ui.common.select_file", "파일 선택")}
|
|
</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>
|
|
);
|
|
}
|