forked from baron/baron-sso
- 조직도 렌더링 시 너비 동적 계산 및 스크롤 문제 해결 - 하위 조직(Leaf)을 부모 박스 내부에 임베딩하여 2열로 깔끔하게 표시되도록 조직도 UI 전면 개편 - 사용자 생성/수정 및 CSV 업로드 시 직급(Position)과 직무(JobTitle)가 정상적으로 Kratos 및 로컬 DB에 동기화되도록 백엔드 API 수정 - CSV 조직도 업로드 시 계층 구분을 '/' 대신 ' > '로 변경하여 이름에 '/'가 포함된 부서(예: 평면/셀)가 분리되지 않도록 보호 - 잘못 입력된 과거 직책 데이터(팀장, 그룹장 등)를 'user' 권한으로 일괄 초기화하고, 이후 'role' 필드에 시스템 권한(user, tenant_admin, super_admin) 외의 값이 들어오지 않도록 백엔드 정규화 로직 강화 - 사용자 목록 페이지의 페이지네이션 제한을 50명에서 1000명으로 상향 조정 - 테넌트 목록 페이지에 이름/슬러그 기반 검색 기능 추가 - 관리자 UI 전반에서 불필요한 배지(Admin only, System 등) 제거 및 테넌트 상세 페이지의 미사용 '외부 연동' 탭 삭제
303 lines
10 KiB
TypeScript
303 lines
10 KiB
TypeScript
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 {
|
|
type BulkUserItem,
|
|
type BulkUserResult,
|
|
bulkCreateUsers,
|
|
} from "../../../lib/adminApi";
|
|
import { t } from "../../../lib/i18n";
|
|
import { parseUserCSV } from "../utils/csvParser";
|
|
|
|
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 data = parseUserCSV(text);
|
|
setPreviewData(data);
|
|
setParsing(false);
|
|
};
|
|
reader.readAsText(file);
|
|
};
|
|
|
|
const handleUpload = () => {
|
|
if (previewData.length > 0) {
|
|
mutation.mutate(previewData);
|
|
}
|
|
};
|
|
|
|
const downloadTemplate = () => {
|
|
const headers =
|
|
"email,name,phone,role,tenant,department,position,jobTitle,employee_id";
|
|
const example =
|
|
"user1@example.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,프론트엔드,EMP001";
|
|
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([]);
|
|
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"
|
|
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>
|
|
)}
|
|
|
|
{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) => (
|
|
<tr key={u.email} className="border-t">
|
|
<td className="p-2">{u.email}</td>
|
|
<td className="p-2">{u.name}</td>
|
|
<td className="p-2">{u.tenantSlug || "-"}</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) => (
|
|
<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}
|
|
className="w-full sm:w-auto"
|
|
data-testid="bulk-start-btn"
|
|
>
|
|
{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"
|
|
data-testid="bulk-close-dialog-btn"
|
|
>
|
|
{t("ui.common.close", "닫기")}
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|