1
0
forked from baron/baron-sso

feat: implement CSV bulk user upload functionality

This commit is contained in:
2026-03-04 11:26:37 +09:00
parent db88c7ab1c
commit 7c28bd4867
5 changed files with 437 additions and 0 deletions

View File

@@ -0,0 +1,258 @@
import { useMutation } 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 { bulkCreateUsers, type BulkUserItem, type BulkUserResult } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
interface UserBulkUploadModalProps {
onSuccess?: () => void;
}
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 [results, setResults] = React.useState<BulkUserResult[] | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
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 lines = text.split(/\r?\n/);
if (lines.length < 2) {
setParsing(false);
return;
}
const headers = lines[0].split(",").map(h => h.trim().toLowerCase());
const data: BulkUserItem[] = [];
for (let i = 1; i < lines.length; i++) {
if (!lines[i].trim()) continue;
// Simple CSV split (doesn't handle commas in quotes, but enough for basic template)
const values = lines[i].split(",").map(v => v.trim());
const item: any = { metadata: {} };
headers.forEach((header, index) => {
const value = values[index];
if (!value) return;
if (["email", "name", "phone", "role", "companycode", "department"].includes(header)) {
const key = header === "companycode" ? "companyCode" : header;
item[key] = value;
} else {
item.metadata[header] = value;
}
});
if (item.email && item.name) {
data.push(item as BulkUserItem);
}
}
setPreviewData(data);
setParsing(false);
};
reader.readAsText(file);
};
const handleUpload = () => {
if (previewData.length > 0) {
mutation.mutate(previewData);
}
};
const downloadTemplate = () => {
const headers = "email,name,phone,role,companyCode,department,employee_id";
const example = "user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,EMP001";
const blob = new Blob([`${headers}
${example}`], { type: "text/csv" });
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([]);
setResults(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const successCount = results?.filter(r => r.success).length ?? 0;
const failCount = results ? results.length - successCount : 0;
return (
<Dialog open={open} onOpenChange={(val) => { setOpen(val); if (!val) reset(); }}>
<DialogTrigger asChild>
<Button variant="outline" className="gap-2">
<Upload size={16} />
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{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>
)}
{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>
</tr>
</thead>
<tbody>
{previewData.slice(0, 10).map((u, i) => (
<tr key={i} className="border-t">
<td className="p-2">{u.email}</td>
<td className="p-2">{u.name}</td>
<td className="p-2">{u.companyCode || "-"}</td>
</tr>
))}
{previewData.length > 10 && (
<tr>
<td colSpan={3} 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, i) => (
<div key={i} 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}
className="w-full sm:w-auto"
>
{mutation.isPending && <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">
{t("ui.common.close", "닫기")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}