1
0
forked from baron/baron-sso
Files
baron-sso/adminfront/src/features/users/components/UserBulkUploadModal.tsx
chan 349cdf5fcd feat(org): 조직도 렌더링 및 동기화 로직 대폭 개선, 역할(Role) 강제화, 탭 정리
- 조직도 렌더링 시 너비 동적 계산 및 스크롤 문제 해결
- 하위 조직(Leaf)을 부모 박스 내부에 임베딩하여 2열로 깔끔하게 표시되도록 조직도 UI 전면 개편
- 사용자 생성/수정 및 CSV 업로드 시 직급(Position)과 직무(JobTitle)가 정상적으로 Kratos 및 로컬 DB에 동기화되도록 백엔드 API 수정
- CSV 조직도 업로드 시 계층 구분을 '/' 대신 ' > '로 변경하여 이름에 '/'가 포함된 부서(예: 평면/셀)가 분리되지 않도록 보호
- 잘못 입력된 과거 직책 데이터(팀장, 그룹장 등)를 'user' 권한으로 일괄 초기화하고, 이후 'role' 필드에 시스템 권한(user, tenant_admin, super_admin) 외의 값이 들어오지 않도록 백엔드 정규화 로직 강화
- 사용자 목록 페이지의 페이지네이션 제한을 50명에서 1000명으로 상향 조정
- 테넌트 목록 페이지에 이름/슬러그 기반 검색 기능 추가
- 관리자 UI 전반에서 불필요한 배지(Admin only, System 등) 제거 및 테넌트 상세 페이지의 미사용 '외부 연동' 탭 삭제
2026-04-10 13:48:12 +09:00

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>
);
}