1
0
forked from baron/baron-sso

테넌트 및 사용자 목록 UI 개선: 계층 구조 테이블 도입, 벌크 액션 바 통합, 컬럼 순서 조정 및 데이터 관리 드롭다운 적용

This commit is contained in:
2026-05-14 10:01:51 +09:00
parent df543d6203
commit d3e83332fb
3 changed files with 1004 additions and 1064 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -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"
className="h-9"
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="w-9 px-0 h-9"
onClick={() => query.refetch()}
disabled={query.isFetching}
title={t("ui.common.refresh", "새로고침")}
>
<RefreshCw size={16} />
<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,36 +757,29 @@ function UserListPage() {
}
/>
</TableCell>
<TableCell
className="max-w-[260px] 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"
className="font-medium hover:underline text-primary truncate block max-w-[150px]"
title={user.name}
>
{user.name}
</Link>
<span className="text-muted-foreground">
{" "}
</TableCell>
<TableCell
className="text-sm text-muted-foreground truncate max-w-[200px]"
title={user.email}
>
{user.email}
</span>
{user.phone && (
<span className="text-muted-foreground">
{" "}
{user.phone}
</span>
)}
</div>
</div>
</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-2">
@@ -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,32 +900,22 @@ function UserListPage() {
/>
</SelectTrigger>
<SelectContent>
{userStatusValues.map((status) => (
{userStatusValues
.filter((s) => s === "active" || s === "inactive")
.map((status) => (
<SelectItem key={status} value={status}>
{userStatusLabel(status)}
</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"
>
{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"
className="h-8 w-[120px] bg-transparent border-background/20 text-background text-xs"
data-testid="bulk-permission-select"
>
<SelectValue
@@ -899,22 +933,36 @@ function UserListPage() {
))}
</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
onClick={() => {
const payload: any = { userIds: selectedUserIds };
let hasChanges = false;
if (selectedBulkStatus) {
payload.status = selectedBulkStatus;
hasChanges = true;
}
data-testid="bulk-apply-permission-btn"
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" />
</>
)}
<Button
variant="ghost"
size="sm"

View File

@@ -42,6 +42,7 @@ import {
interface UserBulkUploadModalProps {
onSuccess?: () => void;
variant?: "button" | "dropdown";
}
function buildUserTenantPreviewRows(
@@ -120,7 +121,10 @@ function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
return "text-muted-foreground";
}
export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
export function UserBulkUploadModal({
onSuccess,
variant = "button",
}: UserBulkUploadModalProps) {
const [open, setOpen] = React.useState(false);
const [file, setFile] = React.useState<File | null>(null);
const [parsing, setParsing] = React.useState(false);
@@ -323,7 +327,35 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
(preview) => preview?.status === "blockingError",
);
const triggerProps = {
disabled: mutation.isPending,
"data-testid": "bulk-import-btn",
};
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>
<Button variant="outline" className="gap-2" {...triggerProps}>
<Upload size={16} />
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
</Button>
</DialogTrigger>
);
return (
<>
{variant === "dropdown" ? triggerNode : null}
<Dialog
open={open}
onOpenChange={(val) => {
@@ -331,16 +363,7 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
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>
{variant !== "dropdown" && triggerNode}
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle data-testid="bulk-upload-title">
@@ -364,7 +387,10 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
className="gap-2"
>
<Download size={14} />
{t("ui.admin.users.bulk.download_template", "템플릿 다운로드")}
{t(
"ui.admin.users.bulk.download_template",
"템플릿 다운로드",
)}
</Button>
<input
type="file"
@@ -500,7 +526,8 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
<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
hanmacEmailPreviews[index]?.finalEmail ??
u.email
}
onChange={(event) =>
setPreviewData((prev) =>
@@ -627,5 +654,6 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}