forked from baron/baron-sso
테넌트 및 사용자 목록 UI 개선: 계층 구조 테이블 도입, 벌크 액션 바 통합, 컬럼 순서 조정 및 데이터 관리 드롭다운 적용
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -4,9 +4,11 @@ import {
|
|||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ArrowUpDown,
|
ArrowUpDown,
|
||||||
|
ChevronDown,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FileDown,
|
FileDown,
|
||||||
|
LayoutDashboard,
|
||||||
Plus,
|
Plus,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
@@ -39,6 +41,13 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "../../components/ui/dialog";
|
} from "../../components/ui/dialog";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from "../../components/ui/dropdown-menu";
|
||||||
import { Input } from "../../components/ui/input";
|
import { Input } from "../../components/ui/input";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -448,35 +457,62 @@ function UserListPage() {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
data-testid="user-data-mgmt-btn"
|
||||||
|
className="gap-2 h-9"
|
||||||
|
>
|
||||||
|
<LayoutDashboard size={16} />
|
||||||
|
{t("ui.admin.users.data_mgmt", "데이터 관리")}
|
||||||
|
<ChevronDown size={14} className="opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-56">
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleExport(false)}
|
||||||
|
disabled={exportMutation.isPending}
|
||||||
|
data-testid="user-export-menu-item"
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<FileDown size={16} className="mr-2 opacity-50" />
|
||||||
|
{t("ui.common.export_without_ids", "UUID 제외 내보내기")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => handleExport(true)}
|
||||||
|
disabled={exportMutation.isPending}
|
||||||
|
data-testid="user-export-with-ids-menu-item"
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
<FileDown size={16} className="mr-2 opacity-50" />
|
||||||
|
{t("ui.common.export_with_ids", "UUID 포함 내보내기")}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<div className="px-2 py-1.5" onClick={(e) => e.preventDefault()}>
|
||||||
|
<UserBulkUploadModal
|
||||||
|
onSuccess={() => query.refetch()}
|
||||||
|
variant="dropdown"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="h-9"
|
className="w-9 px-0 h-9"
|
||||||
onClick={() => query.refetch()}
|
onClick={() => query.refetch()}
|
||||||
disabled={query.isFetching}
|
disabled={query.isFetching}
|
||||||
|
title={t("ui.common.refresh", "새로고침")}
|
||||||
>
|
>
|
||||||
<RefreshCw size={16} />
|
<RefreshCw size={16} />
|
||||||
{t("ui.common.refresh", "새로고침")}
|
<span className="sr-only">
|
||||||
|
{t("ui.common.refresh", "새로고침")}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleExport(false)}
|
|
||||||
className="gap-2"
|
|
||||||
disabled={exportMutation.isPending}
|
|
||||||
>
|
|
||||||
<FileDown size={16} />
|
|
||||||
{t("ui.common.export_without_ids", "UUID 제외 내보내기")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => handleExport(true)}
|
|
||||||
className="gap-2"
|
|
||||||
disabled={exportMutation.isPending}
|
|
||||||
>
|
|
||||||
<FileDown size={16} />
|
|
||||||
{t("ui.common.export_with_ids", "UUID 포함")}
|
|
||||||
</Button>
|
|
||||||
<UserBulkUploadModal onSuccess={() => query.refetch()} />
|
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||||
@@ -580,6 +616,33 @@ function UserListPage() {
|
|||||||
onChange={toggleSelectAll}
|
onChange={toggleSelectAll}
|
||||||
/>
|
/>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="min-w-[120px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => requestSort("name")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{t("ui.admin.users.list.table.name", "이름")}
|
||||||
|
{getSortIcon("name")}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="min-w-[180px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => requestSort("email")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{t("ui.admin.users.list.table.email", "이메일")}
|
||||||
|
{getSortIcon("email")}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
|
<TableHead
|
||||||
|
className="min-w-[140px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => requestSort("phone")}
|
||||||
|
>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{t("ui.admin.users.list.table.phone", "전화번호")}
|
||||||
|
{getSortIcon("phone")}
|
||||||
|
</div>
|
||||||
|
</TableHead>
|
||||||
<TableHead
|
<TableHead
|
||||||
className="min-w-[220px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
className="min-w-[220px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
onClick={() => requestSort("id")}
|
onClick={() => requestSort("id")}
|
||||||
@@ -589,18 +652,6 @@ function UserListPage() {
|
|||||||
{getSortIcon("id")}
|
{getSortIcon("id")}
|
||||||
</div>
|
</div>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableHead
|
|
||||||
className="min-w-[200px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
|
||||||
onClick={() => requestSort("name_email")}
|
|
||||||
>
|
|
||||||
<div className="flex items-center">
|
|
||||||
{t(
|
|
||||||
"ui.admin.users.list.table.name_email",
|
|
||||||
"이름 / 이메일 / 전화번호",
|
|
||||||
)}
|
|
||||||
{getSortIcon("name_email")}
|
|
||||||
</div>
|
|
||||||
</TableHead>
|
|
||||||
<TableHead
|
<TableHead
|
||||||
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
onClick={() => requestSort("status")}
|
onClick={() => requestSort("status")}
|
||||||
@@ -706,37 +757,30 @@ function UserListPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Link
|
||||||
|
to={`/users/${user.id}`}
|
||||||
|
className="font-medium hover:underline text-primary truncate block max-w-[150px]"
|
||||||
|
title={user.name}
|
||||||
|
>
|
||||||
|
{user.name}
|
||||||
|
</Link>
|
||||||
|
</TableCell>
|
||||||
<TableCell
|
<TableCell
|
||||||
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
|
className="text-sm text-muted-foreground truncate max-w-[200px]"
|
||||||
|
title={user.email}
|
||||||
|
>
|
||||||
|
{user.email}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-sm text-muted-foreground whitespace-nowrap">
|
||||||
|
{user.phone || "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell
|
||||||
|
className="max-w-[220px] break-all font-mono text-xs text-muted-foreground"
|
||||||
data-testid={`user-internal-id-${user.id}`}
|
data-testid={`user-internal-id-${user.id}`}
|
||||||
>
|
>
|
||||||
{user.id}
|
{user.id}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div
|
|
||||||
className="truncate text-sm"
|
|
||||||
data-testid={`user-contact-${user.id}`}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
to={`/users/${user.id}`}
|
|
||||||
className="font-medium hover:underline text-primary"
|
|
||||||
>
|
|
||||||
{user.name}
|
|
||||||
</Link>
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{" "}
|
|
||||||
{user.email}
|
|
||||||
</span>
|
|
||||||
{user.phone && (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
{" "}
|
|
||||||
{user.phone}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Switch
|
<Switch
|
||||||
@@ -845,7 +889,7 @@ function UserListPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<SelectTrigger
|
<SelectTrigger
|
||||||
className="h-8 w-[150px] bg-transparent border-background/20 text-background text-xs"
|
className="h-8 w-[120px] bg-transparent border-background/20 text-background text-xs"
|
||||||
data-testid="bulk-status-select"
|
data-testid="bulk-status-select"
|
||||||
>
|
>
|
||||||
<SelectValue
|
<SelectValue
|
||||||
@@ -856,65 +900,69 @@ function UserListPage() {
|
|||||||
/>
|
/>
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{userStatusValues.map((status) => (
|
{userStatusValues
|
||||||
<SelectItem key={status} value={status}>
|
.filter((s) => s === "active" || s === "inactive")
|
||||||
{userStatusLabel(status)}
|
.map((status) => (
|
||||||
</SelectItem>
|
<SelectItem key={status} value={status}>
|
||||||
))}
|
{userStatusLabel(status)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
|
{canPromoteSuperAdmin && (
|
||||||
|
<Select
|
||||||
|
value={selectedBulkPermission}
|
||||||
|
onValueChange={setSelectedBulkPermission}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
className="h-8 w-[120px] bg-transparent border-background/20 text-background text-xs"
|
||||||
|
data-testid="bulk-permission-select"
|
||||||
|
>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t(
|
||||||
|
"ui.admin.users.bulk.permission_placeholder",
|
||||||
|
"권한 선택",
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{bulkPermissionOptions.map((option) => (
|
||||||
|
<SelectItem key={option.value} value={option.value}>
|
||||||
|
{t(option.labelKey, option.fallback)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-background hover:bg-background/10 h-8"
|
className="text-background hover:bg-background/10 h-8 gap-1.5"
|
||||||
onClick={handleApplyBulkStatus}
|
onClick={() => {
|
||||||
disabled={!selectedBulkStatus || bulkUpdateMutation.isPending}
|
const payload: any = { userIds: selectedUserIds };
|
||||||
data-testid="bulk-apply-status-btn"
|
let hasChanges = false;
|
||||||
|
if (selectedBulkStatus) {
|
||||||
|
payload.status = selectedBulkStatus;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
if (selectedBulkPermission && canPromoteSuperAdmin) {
|
||||||
|
payload.role = selectedBulkPermission;
|
||||||
|
hasChanges = true;
|
||||||
|
}
|
||||||
|
if (hasChanges) {
|
||||||
|
bulkUpdateMutation.mutate(payload);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={
|
||||||
|
(!selectedBulkStatus && !selectedBulkPermission) ||
|
||||||
|
bulkUpdateMutation.isPending
|
||||||
|
}
|
||||||
|
data-testid="bulk-apply-btn"
|
||||||
>
|
>
|
||||||
|
<ShieldCheck size={14} />
|
||||||
{t("ui.common.apply", "적용")}
|
{t("ui.common.apply", "적용")}
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||||
{canPromoteSuperAdmin && (
|
|
||||||
<>
|
|
||||||
<Select
|
|
||||||
value={selectedBulkPermission}
|
|
||||||
onValueChange={setSelectedBulkPermission}
|
|
||||||
>
|
|
||||||
<SelectTrigger
|
|
||||||
className="h-8 w-[150px] bg-transparent border-background/20 text-background text-xs"
|
|
||||||
data-testid="bulk-permission-select"
|
|
||||||
>
|
|
||||||
<SelectValue
|
|
||||||
placeholder={t(
|
|
||||||
"ui.admin.users.bulk.permission_placeholder",
|
|
||||||
"권한 선택",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{bulkPermissionOptions.map((option) => (
|
|
||||||
<SelectItem key={option.value} value={option.value}>
|
|
||||||
{t(option.labelKey, option.fallback)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="text-background hover:bg-background/10 h-8 gap-1.5"
|
|
||||||
onClick={handleApplyBulkPermission}
|
|
||||||
disabled={
|
|
||||||
!selectedBulkPermission || bulkUpdateMutation.isPending
|
|
||||||
}
|
|
||||||
data-testid="bulk-apply-permission-btn"
|
|
||||||
>
|
|
||||||
<ShieldCheck size={14} />
|
|
||||||
{t("ui.common.apply", "적용")}
|
|
||||||
</Button>
|
|
||||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ import {
|
|||||||
|
|
||||||
interface UserBulkUploadModalProps {
|
interface UserBulkUploadModalProps {
|
||||||
onSuccess?: () => void;
|
onSuccess?: () => void;
|
||||||
|
variant?: "button" | "dropdown";
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildUserTenantPreviewRows(
|
function buildUserTenantPreviewRows(
|
||||||
@@ -120,7 +121,10 @@ function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
|
|||||||
return "text-muted-foreground";
|
return "text-muted-foreground";
|
||||||
}
|
}
|
||||||
|
|
||||||
export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
export function UserBulkUploadModal({
|
||||||
|
onSuccess,
|
||||||
|
variant = "button",
|
||||||
|
}: UserBulkUploadModalProps) {
|
||||||
const [open, setOpen] = React.useState(false);
|
const [open, setOpen] = React.useState(false);
|
||||||
const [file, setFile] = React.useState<File | null>(null);
|
const [file, setFile] = React.useState<File | null>(null);
|
||||||
const [parsing, setParsing] = React.useState(false);
|
const [parsing, setParsing] = React.useState(false);
|
||||||
@@ -323,309 +327,333 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
|||||||
(preview) => preview?.status === "blockingError",
|
(preview) => preview?.status === "blockingError",
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
const triggerProps = {
|
||||||
<Dialog
|
disabled: mutation.isPending,
|
||||||
open={open}
|
"data-testid": "bulk-import-btn",
|
||||||
onOpenChange={(val) => {
|
};
|
||||||
setOpen(val);
|
|
||||||
if (!val) reset();
|
const triggerNode =
|
||||||
}}
|
variant === "dropdown" ? (
|
||||||
>
|
<div
|
||||||
|
className="relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
|
||||||
|
role="menuitem"
|
||||||
|
tabIndex={-1}
|
||||||
|
onClick={() => setOpen(true)}
|
||||||
|
{...triggerProps}
|
||||||
|
>
|
||||||
|
<Upload size={16} className="mr-2 opacity-50" />
|
||||||
|
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<DialogTrigger asChild>
|
<DialogTrigger asChild>
|
||||||
<Button
|
<Button variant="outline" className="gap-2" {...triggerProps}>
|
||||||
variant="outline"
|
|
||||||
className="gap-2"
|
|
||||||
data-testid="bulk-import-btn"
|
|
||||||
>
|
|
||||||
<Upload size={16} />
|
<Upload size={16} />
|
||||||
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
|
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogTrigger>
|
</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 ? (
|
return (
|
||||||
<div className="space-y-4 py-4">
|
<>
|
||||||
<div className="flex justify-between items-center">
|
{variant === "dropdown" ? triggerNode : null}
|
||||||
<Button
|
<Dialog
|
||||||
variant="ghost"
|
open={open}
|
||||||
size="sm"
|
onOpenChange={(val) => {
|
||||||
onClick={downloadTemplate}
|
setOpen(val);
|
||||||
className="gap-2"
|
if (!val) reset();
|
||||||
>
|
}}
|
||||||
<Download size={14} />
|
>
|
||||||
{t("ui.admin.users.bulk.download_template", "템플릿 다운로드")}
|
{variant !== "dropdown" && triggerNode}
|
||||||
</Button>
|
<DialogContent className="max-w-2xl">
|
||||||
<input
|
<DialogHeader>
|
||||||
type="file"
|
<DialogTitle data-testid="bulk-upload-title">
|
||||||
accept=".csv"
|
{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}
|
||||||
className="hidden"
|
</DialogTitle>
|
||||||
ref={fileInputRef}
|
<DialogDescription>
|
||||||
onChange={handleFileChange}
|
{t(
|
||||||
/>
|
"msg.admin.users.bulk.description",
|
||||||
<Button
|
"CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.",
|
||||||
onClick={() => fileInputRef.current?.click()}
|
)}
|
||||||
variant="secondary"
|
</DialogDescription>
|
||||||
>
|
</DialogHeader>
|
||||||
{file
|
|
||||||
? t("ui.common.change_file", "파일 변경")
|
|
||||||
: t("ui.common.select_file", "파일 선택")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{file && (
|
{!results ? (
|
||||||
<div className="rounded-lg border p-4 bg-muted/20">
|
<div className="space-y-4 py-4">
|
||||||
<div className="flex items-center gap-3 mb-2">
|
<div className="flex justify-between items-center">
|
||||||
<FileText className="text-primary" />
|
<Button
|
||||||
<span className="font-medium">{file.name}</span>
|
variant="ghost"
|
||||||
<span className="text-xs text-muted-foreground">
|
size="sm"
|
||||||
({(file.size / 1024).toFixed(1)} KB)
|
onClick={downloadTemplate}
|
||||||
</span>
|
className="gap-2"
|
||||||
</div>
|
>
|
||||||
{parsing ? (
|
<Download size={14} />
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
{t(
|
||||||
<Loader2 size={14} className="animate-spin" />
|
"ui.admin.users.bulk.download_template",
|
||||||
{t("msg.common.parsing", "파싱 중...")}
|
"템플릿 다운로드",
|
||||||
</div>
|
)}
|
||||||
) : (
|
</Button>
|
||||||
<div className="text-sm text-muted-foreground">
|
<input
|
||||||
{t(
|
type="file"
|
||||||
"msg.admin.users.bulk.parsed_count",
|
accept=".csv"
|
||||||
"{{count}}명의 사용자가 감지되었습니다.",
|
className="hidden"
|
||||||
{ count: previewData.length },
|
ref={fileInputRef}
|
||||||
)}
|
onChange={handleFileChange}
|
||||||
</div>
|
/>
|
||||||
)}
|
<Button
|
||||||
|
onClick={() => fileInputRef.current?.click()}
|
||||||
|
variant="secondary"
|
||||||
|
>
|
||||||
|
{file
|
||||||
|
? t("ui.common.change_file", "파일 변경")
|
||||||
|
: t("ui.common.select_file", "파일 선택")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{tenantPreviewRows.length > 0 && (
|
{file && (
|
||||||
<div
|
<div className="rounded-lg border p-4 bg-muted/20">
|
||||||
className="rounded-md border p-3 text-sm"
|
<div className="flex items-center gap-3 mb-2">
|
||||||
data-testid="user-import-tenant-resolution"
|
<FileText className="text-primary" />
|
||||||
>
|
<span className="font-medium">{file.name}</span>
|
||||||
<div className="mb-2 font-medium">
|
<span className="text-xs text-muted-foreground">
|
||||||
{t("ui.admin.users.bulk.tenant_resolution", "테넌트 매핑")}
|
({(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>
|
</div>
|
||||||
<div className="space-y-2">
|
)}
|
||||||
{tenantPreviewRows.map((preview) => (
|
|
||||||
<div
|
{tenantPreviewRows.length > 0 && (
|
||||||
key={preview.row.rowNumber}
|
<div
|
||||||
className="grid gap-2 sm:grid-cols-[1fr_1fr]"
|
className="rounded-md border p-3 text-sm"
|
||||||
>
|
data-testid="user-import-tenant-resolution"
|
||||||
<div>
|
>
|
||||||
<div className="font-medium">{preview.row.name}</div>
|
<div className="mb-2 font-medium">
|
||||||
<div className="font-mono text-xs text-muted-foreground">
|
{t("ui.admin.users.bulk.tenant_resolution", "테넌트 매핑")}
|
||||||
{preview.row.slug}
|
</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>
|
||||||
</div>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<select
|
||||||
<select
|
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
|
||||||
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={
|
value={
|
||||||
selectedTenantCreateSlugs[
|
selectedTenantMatches[preview.row.rowNumber] ??
|
||||||
preview.row.rowNumber
|
"__create__"
|
||||||
] ?? ""
|
|
||||||
}
|
}
|
||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setSelectedTenantCreateSlugs((prev) => ({
|
setSelectedTenantMatches((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
[preview.row.rowNumber]: event.target.value,
|
[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>
|
</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>
|
</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>
|
</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>
|
<DialogFooter>
|
||||||
</Dialog>
|
{!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>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user