forked from baron/baron-sso
테넌트 및 사용자 목록 UI 개선: 계층 구조 테이블 도입, 벌크 액션 바 통합, 컬럼 순서 조정 및 데이터 관리 드롭다운 적용
This commit is contained in:
@@ -4,9 +4,11 @@ import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ArrowUpDown,
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
FileDown,
|
||||
LayoutDashboard,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
@@ -39,6 +41,13 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "../../components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "../../components/ui/dropdown-menu";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
@@ -448,35 +457,62 @@ function UserListPage() {
|
||||
</Button>
|
||||
</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
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-9"
|
||||
className="w-9 px-0 h-9"
|
||||
onClick={() => query.refetch()}
|
||||
disabled={query.isFetching}
|
||||
title={t("ui.common.refresh", "새로고침")}
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
<span className="sr-only">
|
||||
{t("ui.common.refresh", "새로고침")}
|
||||
</span>
|
||||
</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>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="icon" className="h-9 w-9">
|
||||
@@ -580,6 +616,33 @@ function UserListPage() {
|
||||
onChange={toggleSelectAll}
|
||||
/>
|
||||
</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
|
||||
className="min-w-[220px] whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("id")}
|
||||
@@ -589,18 +652,6 @@ function UserListPage() {
|
||||
{getSortIcon("id")}
|
||||
</div>
|
||||
</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
|
||||
className="whitespace-nowrap cursor-pointer hover:bg-muted/50 transition-colors"
|
||||
onClick={() => requestSort("status")}
|
||||
@@ -706,37 +757,30 @@ function UserListPage() {
|
||||
}
|
||||
/>
|
||||
</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
|
||||
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}`}
|
||||
>
|
||||
{user.id}
|
||||
</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>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch
|
||||
@@ -845,7 +889,7 @@ function UserListPage() {
|
||||
}
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<SelectValue
|
||||
@@ -856,65 +900,69 @@ function UserListPage() {
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{userStatusValues.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{userStatusLabel(status)}
|
||||
</SelectItem>
|
||||
))}
|
||||
{userStatusValues
|
||||
.filter((s) => s === "active" || s === "inactive")
|
||||
.map((status) => (
|
||||
<SelectItem key={status} value={status}>
|
||||
{userStatusLabel(status)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8"
|
||||
onClick={handleApplyBulkStatus}
|
||||
disabled={!selectedBulkStatus || bulkUpdateMutation.isPending}
|
||||
data-testid="bulk-apply-status-btn"
|
||||
className="text-background hover:bg-background/10 h-8 gap-1.5"
|
||||
onClick={() => {
|
||||
const payload: any = { userIds: selectedUserIds };
|
||||
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", "적용")}
|
||||
</Button>
|
||||
<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
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
Reference in New Issue
Block a user