forked from baron/baron-sso
1249 lines
45 KiB
TypeScript
1249 lines
45 KiB
TypeScript
import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query";
|
|
import {
|
|
observeElementRect,
|
|
type Rect,
|
|
useVirtualizer,
|
|
type Virtualizer,
|
|
} from "@tanstack/react-virtual";
|
|
import type { AxiosError } from "axios";
|
|
import {
|
|
ArrowDown,
|
|
ArrowUp,
|
|
ArrowUpDown,
|
|
ChevronDown,
|
|
FileDown,
|
|
FileSpreadsheet,
|
|
LayoutDashboard,
|
|
Plus,
|
|
RefreshCw,
|
|
Search,
|
|
Settings2,
|
|
ShieldCheck,
|
|
Trash2,
|
|
Upload,
|
|
Users,
|
|
} from "lucide-react";
|
|
import * as React from "react";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import { PageHeader } from "../../../../common/core/components/page";
|
|
import {
|
|
SortableTableHead,
|
|
sortableTableHeaderClassName,
|
|
} from "../../../../common/core/components/sort";
|
|
import {
|
|
type SortConfig,
|
|
type SortResolverMap,
|
|
sortItems,
|
|
toggleSort,
|
|
} from "../../../../common/core/utils";
|
|
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
|
|
import {
|
|
commonTableShellClass,
|
|
commonTableViewportClass,
|
|
} from "../../../../common/ui/table";
|
|
import { Button } from "../../components/ui/button";
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "../../components/ui/card";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
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,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "../../components/ui/select";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "../../components/ui/table";
|
|
import { toast } from "../../components/ui/use-toast";
|
|
import {
|
|
bulkDeleteUsers,
|
|
bulkUpdateUsers,
|
|
deleteUser,
|
|
exportUsersCSV,
|
|
fetchAllTenants,
|
|
fetchMe,
|
|
fetchTenant,
|
|
fetchUsers,
|
|
type TenantSummary,
|
|
type UserSummary,
|
|
updateUser,
|
|
} from "../../lib/adminApi";
|
|
import { t } from "../../lib/i18n";
|
|
import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles";
|
|
import {
|
|
downloadUserTemplate,
|
|
UserBulkUploadModal,
|
|
} from "./components/UserBulkUploadModal";
|
|
import {
|
|
normalizeUserStatusValue,
|
|
type UserStatusValue,
|
|
userStatusLabel,
|
|
userStatusValues,
|
|
} from "./userStatus";
|
|
|
|
type UserSchemaField = {
|
|
key: string;
|
|
label: string;
|
|
type: string;
|
|
};
|
|
|
|
type UserSortKey = string;
|
|
|
|
const USER_ROW_ESTIMATED_HEIGHT = 64;
|
|
const USER_ROW_OVERSCAN = 20;
|
|
const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640;
|
|
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const;
|
|
const userMetadataColumnWidth = 160;
|
|
const userCreatedColumnWidth = 150;
|
|
type UserRowVirtualizer = Virtualizer<HTMLDivElement, HTMLTableRowElement>;
|
|
const userTableHeadClassName =
|
|
"h-9 px-3 py-1 text-xs leading-tight align-middle whitespace-nowrap";
|
|
const userTableHeadInteractiveClassName = `${userTableHeadClassName} cursor-pointer transition-colors hover:bg-muted/50`;
|
|
const userTableHeadContentClassName = "flex h-full items-center gap-1";
|
|
const userSortableTableHeadClassName =
|
|
"!h-9 !px-3 !py-1 leading-tight whitespace-nowrap";
|
|
const userSortableTableHeadContentClassName = "h-full items-center";
|
|
const userTableStateCellClassName =
|
|
"flex h-24 items-center justify-center p-0 text-center text-sm text-muted-foreground";
|
|
|
|
const bulkPermissionOptions = [
|
|
{
|
|
value: "super_admin",
|
|
labelKey: "ui.admin.role.super_admin",
|
|
fallback: "시스템 관리자",
|
|
},
|
|
{
|
|
value: "user",
|
|
labelKey: "ui.admin.role.user",
|
|
fallback: "일반 사용자",
|
|
},
|
|
] as const;
|
|
|
|
function assignableSystemRoleValue(role?: string | null) {
|
|
return isSuperAdminRole(role) ? "super_admin" : "user";
|
|
}
|
|
|
|
function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
|
|
return {
|
|
width: rect.width > 0 ? rect.width : fallbackWidth,
|
|
height:
|
|
rect.height > 0 ? rect.height : USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT,
|
|
};
|
|
}
|
|
|
|
type UserListSearchControlsProps = {
|
|
initialSearch: string;
|
|
selectedCompany: string;
|
|
tenants: TenantSummary[];
|
|
profileRole?: string | null;
|
|
onSearch: (value: string) => void;
|
|
onCompanyChange: (value: string) => void;
|
|
};
|
|
|
|
const UserListSearchControls = React.memo(function UserListSearchControls({
|
|
initialSearch,
|
|
selectedCompany,
|
|
tenants,
|
|
profileRole,
|
|
onSearch,
|
|
onCompanyChange,
|
|
}: UserListSearchControlsProps) {
|
|
const [localSearch, setLocalSearch] = React.useState(initialSearch);
|
|
|
|
React.useEffect(() => {
|
|
setLocalSearch(initialSearch);
|
|
}, [initialSearch]);
|
|
|
|
React.useEffect(() => {
|
|
const timer = setTimeout(() => {
|
|
if (localSearch !== initialSearch) {
|
|
onSearch(localSearch);
|
|
}
|
|
}, 300);
|
|
return () => clearTimeout(timer);
|
|
}, [localSearch, onSearch, initialSearch]);
|
|
|
|
const tenantOptions = React.useMemo(
|
|
() =>
|
|
tenants.map((tenant) => (
|
|
<option key={tenant.id} value={tenant.slug}>
|
|
{tenant.name}
|
|
</option>
|
|
)),
|
|
[tenants],
|
|
);
|
|
|
|
return (
|
|
<SearchFilterBar
|
|
primary={
|
|
<>
|
|
<div className="relative w-56">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
id="user-list-search"
|
|
name="user-list-search"
|
|
placeholder={t(
|
|
"ui.admin.users.list.search_placeholder",
|
|
"이름 또는 이메일 검색",
|
|
)}
|
|
className="h-9 pl-9"
|
|
value={localSearch}
|
|
onChange={(event) => setLocalSearch(event.target.value)}
|
|
onKeyDown={(event) => {
|
|
if (event.key === "Enter") {
|
|
onSearch(localSearch);
|
|
}
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<select
|
|
id="user-list-tenant-filter"
|
|
name="user-list-tenant-filter"
|
|
className="flex h-9 w-[160px] rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
|
value={selectedCompany}
|
|
onChange={(event) => onCompanyChange(event.target.value)}
|
|
disabled={profileRole !== "super_admin"}
|
|
>
|
|
<option value="">{t("ui.common.all", "전체 테넌트")}</option>
|
|
{tenantOptions}
|
|
</select>
|
|
|
|
<Button
|
|
variant="secondary"
|
|
size="sm"
|
|
onClick={() => onSearch(localSearch)}
|
|
className="h-9"
|
|
>
|
|
{t("ui.common.search", "검색")}
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
);
|
|
});
|
|
|
|
function UserListPage() {
|
|
const _navigate = useNavigate();
|
|
const [search, setSearch] = React.useState("");
|
|
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
|
|
const [visibleColumns, setVisibleColumns] = React.useState<
|
|
Record<string, boolean>
|
|
>({});
|
|
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
|
const [selectedBulkStatus, setSelectedBulkStatus] = React.useState<
|
|
UserStatusValue | ""
|
|
>("");
|
|
const [selectedBulkPermission, setSelectedBulkPermission] =
|
|
React.useState("");
|
|
const [sortConfig, setSortConfig] =
|
|
React.useState<SortConfig<UserSortKey> | null>(null);
|
|
const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false);
|
|
const userTableViewportRef = React.useRef<HTMLDivElement | null>(null);
|
|
|
|
const { data: profile } = useQuery({
|
|
queryKey: ["me"],
|
|
queryFn: fetchMe,
|
|
});
|
|
const profileRole = normalizeAdminRole(profile?.role);
|
|
|
|
const { data: tenantsData } = useQuery({
|
|
queryKey: ["tenants", "all"],
|
|
queryFn: () => fetchAllTenants(),
|
|
});
|
|
const tenants = tenantsData?.items ?? [];
|
|
|
|
// Lock company for non-super_admin
|
|
React.useEffect(() => {
|
|
if (profileRole !== "super_admin" && profile?.tenantSlug) {
|
|
setSelectedCompany(profile.tenantSlug);
|
|
}
|
|
}, [profile, profileRole]);
|
|
|
|
const selectedTenantId = React.useMemo(() => {
|
|
return tenants.find((t) => t.slug === selectedCompany)?.id ?? "";
|
|
}, [tenants, selectedCompany]);
|
|
|
|
const { data: tenantDetail } = useQuery({
|
|
queryKey: ["tenant", selectedTenantId],
|
|
queryFn: () => fetchTenant(selectedTenantId),
|
|
enabled: selectedTenantId.length > 0,
|
|
});
|
|
|
|
const userSchema: UserSchemaField[] = Array.isArray(
|
|
tenantDetail?.config?.userSchema,
|
|
)
|
|
? (tenantDetail?.config?.userSchema as UserSchemaField[])
|
|
: [];
|
|
|
|
// Initialize visible columns when schema changes
|
|
React.useEffect(() => {
|
|
if (userSchema.length > 0) {
|
|
const initial: Record<string, boolean> = {};
|
|
for (const field of userSchema) {
|
|
initial[field.key] = true;
|
|
}
|
|
setVisibleColumns((prev) => {
|
|
// Only set if not already set for these keys to avoid reset on every render
|
|
const next = { ...initial, ...prev };
|
|
return next;
|
|
});
|
|
}
|
|
}, [userSchema]);
|
|
|
|
const toggleColumn = (key: string) => {
|
|
setVisibleColumns((prev) => ({
|
|
...prev,
|
|
[key]: !prev[key],
|
|
}));
|
|
};
|
|
|
|
const query = useInfiniteQuery({
|
|
queryKey: ["users", { search, tenantSlug: selectedCompany }],
|
|
queryFn: ({ pageParam }) =>
|
|
fetchUsers(50, 0, search, selectedCompany, pageParam as string),
|
|
initialPageParam: undefined as string | undefined,
|
|
getNextPageParam: (lastPage) => lastPage.next_cursor || lastPage.nextCursor,
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (userId: string) => deleteUser(userId),
|
|
onSuccess: () => {
|
|
query.refetch();
|
|
},
|
|
});
|
|
|
|
const exportMutation = useMutation({
|
|
mutationFn: (includeIds: boolean) =>
|
|
exportUsersCSV(search, selectedCompany, includeIds),
|
|
onSuccess: ({ blob, filename }) => {
|
|
const url = window.URL.createObjectURL(blob);
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = filename;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
link.remove();
|
|
window.URL.revokeObjectURL(url);
|
|
},
|
|
onError: () => {
|
|
toast.error(
|
|
t("msg.admin.users.export_error", "사용자 내보내기에 실패했습니다."),
|
|
);
|
|
},
|
|
});
|
|
|
|
const statusMutation = useMutation({
|
|
mutationFn: ({ userId, status }: { userId: string; status: string }) =>
|
|
updateUser(userId, { status }),
|
|
onSuccess: () => {
|
|
query.refetch();
|
|
},
|
|
onError: () => {
|
|
toast.error(
|
|
t("msg.admin.users.status_error", "사용자 상태 변경에 실패했습니다."),
|
|
);
|
|
},
|
|
});
|
|
|
|
const handleSearch = React.useCallback((nextSearch: string) => {
|
|
setSearch(nextSearch);
|
|
}, []);
|
|
|
|
const handleCompanyChange = React.useCallback((nextCompany: string) => {
|
|
setSelectedCompany(nextCompany);
|
|
}, []);
|
|
|
|
const handleExport = (includeIds = false) => {
|
|
exportMutation.mutate(includeIds);
|
|
};
|
|
|
|
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
|
?.data?.error;
|
|
const fallbackError =
|
|
!errorMsg && query.isError
|
|
? t(
|
|
"msg.admin.users.list.fetch_error",
|
|
"사용자 목록 조회에 실패했습니다.",
|
|
)
|
|
: null;
|
|
|
|
const serverItems = React.useMemo(
|
|
() => query.data?.pages.flatMap((page) => page.items) ?? [],
|
|
[query.data],
|
|
);
|
|
const rawItems = serverItems;
|
|
const userSortResolvers = React.useMemo<
|
|
SortResolverMap<UserSummary, UserSortKey>
|
|
>(
|
|
() =>
|
|
userSchema.reduce<SortResolverMap<UserSummary, UserSortKey>>(
|
|
(accumulator, field) => {
|
|
accumulator[field.key] = (user) => {
|
|
const value = user.metadata?.[field.key];
|
|
return typeof value === "string" ||
|
|
typeof value === "number" ||
|
|
typeof value === "boolean"
|
|
? value
|
|
: null;
|
|
};
|
|
return accumulator;
|
|
},
|
|
{
|
|
name_email: (user) =>
|
|
`${user.name ?? ""} ${user.email ?? ""} ${user.phone ?? ""}`,
|
|
tenant_dept: (user) =>
|
|
`${user.tenant?.name ?? user.tenantSlug ?? ""} ${user.department ?? ""}`,
|
|
},
|
|
),
|
|
[userSchema],
|
|
);
|
|
const items = React.useMemo(() => {
|
|
if (!sortConfig) {
|
|
return rawItems;
|
|
}
|
|
|
|
return sortItems(rawItems, sortConfig, userSortResolvers);
|
|
}, [rawItems, sortConfig, userSortResolvers]);
|
|
const visibleUserSchemaFields = React.useMemo(
|
|
() => userSchema.filter((field) => visibleColumns[field.key] !== false),
|
|
[userSchema, visibleColumns],
|
|
);
|
|
const userTableColumnWidths = React.useMemo(
|
|
() => [
|
|
...userFixedColumnWidths,
|
|
...visibleUserSchemaFields.map(() => userMetadataColumnWidth),
|
|
userCreatedColumnWidth,
|
|
],
|
|
[visibleUserSchemaFields],
|
|
);
|
|
const userTableGridTemplateColumns = React.useMemo(
|
|
() => userTableColumnWidths.map((width) => `${width}px`).join(" "),
|
|
[userTableColumnWidths],
|
|
);
|
|
const userTableMinWidth = React.useMemo(
|
|
() => userTableColumnWidths.reduce((sum, width) => sum + width, 0),
|
|
[userTableColumnWidths],
|
|
);
|
|
const observeUserTableElementRect = React.useCallback(
|
|
(instance: UserRowVirtualizer, callback: (rect: Rect) => void) =>
|
|
observeElementRect(instance, (rect) => {
|
|
callback(normalizeUserTableRect(rect, userTableMinWidth));
|
|
}),
|
|
[userTableMinWidth],
|
|
);
|
|
const rowVirtualizer = useVirtualizer({
|
|
count: items.length,
|
|
getScrollElement: () => userTableViewportRef.current,
|
|
estimateSize: () => USER_ROW_ESTIMATED_HEIGHT,
|
|
measureElement: (element) =>
|
|
element.getBoundingClientRect().height || USER_ROW_ESTIMATED_HEIGHT,
|
|
observeElementRect: observeUserTableElementRect,
|
|
overscan: USER_ROW_OVERSCAN,
|
|
initialRect: {
|
|
width: userTableMinWidth,
|
|
height: USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT,
|
|
},
|
|
});
|
|
const virtualRows = rowVirtualizer.getVirtualItems();
|
|
|
|
const lastItem = virtualRows[virtualRows.length - 1];
|
|
React.useEffect(() => {
|
|
if (!lastItem) return;
|
|
if (
|
|
lastItem.index >= serverItems.length - 1 &&
|
|
query.hasNextPage &&
|
|
!query.isFetchingNextPage
|
|
) {
|
|
query.fetchNextPage();
|
|
}
|
|
}, [
|
|
lastItem,
|
|
serverItems.length,
|
|
query.hasNextPage,
|
|
query.isFetchingNextPage,
|
|
query.fetchNextPage,
|
|
]);
|
|
|
|
const shouldVirtualizeRows = !query.isLoading && items.length > 0;
|
|
const tableColumnCount = 9 + visibleUserSchemaFields.length;
|
|
|
|
const requestSort = (key: UserSortKey) => {
|
|
setSortConfig((current) => toggleSort(current, key));
|
|
};
|
|
|
|
const getSortIcon = (key: UserSortKey) => {
|
|
if (!sortConfig || sortConfig.key !== key) {
|
|
return <ArrowUpDown size={14} className="ml-1 opacity-50" />;
|
|
}
|
|
return sortConfig.direction === "asc" ? (
|
|
<ArrowUp size={14} className="ml-1" />
|
|
) : (
|
|
<ArrowDown size={14} className="ml-1" />
|
|
);
|
|
};
|
|
|
|
const total = query.data?.pages[0]?.total ?? 0;
|
|
const canPromoteSuperAdmin = isSuperAdminRole(profile?.role);
|
|
|
|
const toggleSelectAll = () => {
|
|
if (selectedUserIds.length === items.length) {
|
|
setSelectedUserIds([]);
|
|
} else {
|
|
setSelectedUserIds(items.map((u) => u.id));
|
|
}
|
|
};
|
|
|
|
const toggleSelectUser = (id: string) => {
|
|
setSelectedUserIds((prev) =>
|
|
prev.includes(id) ? prev.filter((i) => i !== id) : [...prev, id],
|
|
);
|
|
};
|
|
|
|
const bulkDeleteMutation = useMutation({
|
|
mutationFn: bulkDeleteUsers,
|
|
onSuccess: (_, variables) => {
|
|
query.refetch();
|
|
setSelectedUserIds([]);
|
|
toast.success(
|
|
t(
|
|
"msg.admin.users.bulk.delete_success",
|
|
"{{count}}명의 사용자가 삭제되었습니다.",
|
|
{ count: variables.length },
|
|
),
|
|
);
|
|
},
|
|
});
|
|
|
|
const bulkUpdateMutation = useMutation({
|
|
mutationFn: bulkUpdateUsers,
|
|
onSuccess: () => {
|
|
query.refetch();
|
|
setSelectedUserIds([]);
|
|
setSelectedBulkStatus("");
|
|
setSelectedBulkPermission("");
|
|
toast.success(
|
|
t(
|
|
"msg.admin.users.bulk.update_success",
|
|
"선택한 사용자들의 정보가 수정되었습니다.",
|
|
),
|
|
);
|
|
},
|
|
});
|
|
|
|
const _handleApplyBulkStatus = () => {
|
|
if (selectedUserIds.length === 0 || !selectedBulkStatus) return;
|
|
bulkUpdateMutation.mutate({
|
|
userIds: selectedUserIds,
|
|
status: selectedBulkStatus,
|
|
});
|
|
};
|
|
|
|
const _handleApplyBulkPermission = () => {
|
|
if (selectedUserIds.length === 0 || !selectedBulkPermission) return;
|
|
bulkUpdateMutation.mutate({
|
|
userIds: selectedUserIds,
|
|
role: selectedBulkPermission,
|
|
});
|
|
};
|
|
|
|
const handleBulkDelete = () => {
|
|
if (selectedUserIds.length === 0) return;
|
|
if (
|
|
window.confirm(
|
|
t(
|
|
"msg.admin.users.bulk.delete_confirm",
|
|
"{{count}}명의 사용자를 정말 삭제하시겠습니까?",
|
|
{ count: selectedUserIds.length },
|
|
),
|
|
)
|
|
) {
|
|
bulkDeleteMutation.mutate(selectedUserIds);
|
|
}
|
|
};
|
|
|
|
const _handleDelete = (userId: string, userName: string) => {
|
|
if (
|
|
!window.confirm(
|
|
t(
|
|
"msg.admin.users.list.delete_confirm",
|
|
'사용자 "{{name}}"을(를) 정말 삭제하시겠습니까?',
|
|
{ name: userName },
|
|
),
|
|
)
|
|
) {
|
|
return;
|
|
}
|
|
deleteMutation.mutate(userId);
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
|
<PageHeader
|
|
sticky
|
|
titleAs="h2"
|
|
icon={<Users size={20} />}
|
|
title={
|
|
<span data-testid="page-title">
|
|
{t("ui.admin.users.list.title", "사용자 관리")}
|
|
</span>
|
|
}
|
|
description={t(
|
|
"msg.admin.users.list.subtitle",
|
|
"시스템 사용자를 조회하고 관리합니다.",
|
|
)}
|
|
actions={
|
|
<>
|
|
<UserListSearchControls
|
|
initialSearch={search}
|
|
selectedCompany={selectedCompany}
|
|
tenants={tenants}
|
|
profileRole={profileRole}
|
|
onSearch={handleSearch}
|
|
onCompanyChange={handleCompanyChange}
|
|
/>
|
|
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
className="h-9"
|
|
onClick={() => query.refetch()}
|
|
disabled={query.isFetching}
|
|
>
|
|
<RefreshCw size={16} />
|
|
{t("ui.common.refresh", "새로고침")}
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
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={downloadUserTemplate}
|
|
data-testid="user-template-menu-item"
|
|
className="cursor-pointer"
|
|
>
|
|
<FileSpreadsheet size={16} className="mr-2 opacity-50" />
|
|
{t("ui.admin.users.csv_template", "템플릿 다운로드")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onSelect={() => {
|
|
setBulkUploadOpen(true);
|
|
}}
|
|
className="cursor-pointer"
|
|
>
|
|
<Upload size={16} className="mr-2 opacity-50" />
|
|
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<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>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<UserBulkUploadModal
|
|
variant="custom"
|
|
open={bulkUploadOpen}
|
|
onOpenChange={setBulkUploadOpen}
|
|
onSuccess={() => query.refetch()}
|
|
/>
|
|
<Dialog>
|
|
<DialogTrigger asChild>
|
|
<Button variant="outline" size="icon" className="h-9 w-9">
|
|
<Settings2 size={16} />
|
|
</Button>
|
|
</DialogTrigger>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{t("ui.admin.users.list.columns.title", "표시 컬럼 설정")}
|
|
</DialogTitle>
|
|
<DialogDescription>
|
|
{t(
|
|
"msg.admin.users.list.columns.description",
|
|
"사용자 목록에 표시할 커스텀 필드를 선택하세요.",
|
|
)}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="grid gap-4 py-4">
|
|
{userSchema.length === 0 && (
|
|
<p className="py-4 text-center text-sm text-muted-foreground">
|
|
{t(
|
|
"msg.admin.users.list.columns.no_custom",
|
|
"현재 테넌트에 정의된 커스텀 필드가 없습니다.",
|
|
)}
|
|
</p>
|
|
)}
|
|
{userSchema.map((field) => (
|
|
<label
|
|
key={field.key}
|
|
className="flex cursor-pointer items-center gap-3 rounded-lg p-2 hover:bg-muted/50"
|
|
>
|
|
<input
|
|
name={`user-list-column-${field.key}`}
|
|
type="checkbox"
|
|
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
|
|
checked={visibleColumns[field.key] !== false}
|
|
onChange={() => toggleColumn(field.key)}
|
|
/>
|
|
<div className="flex flex-col">
|
|
<span className="text-sm font-medium">
|
|
{field.label}
|
|
</span>
|
|
<span className="font-mono text-xs text-muted-foreground">
|
|
{field.key}
|
|
</span>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<DialogFooter>
|
|
<DialogTrigger asChild>
|
|
<Button variant="secondary">
|
|
{t("ui.common.close", "닫기")}
|
|
</Button>
|
|
</DialogTrigger>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<Button asChild size="sm" className="h-9">
|
|
<Link to="/users/new">
|
|
<Plus size={16} />
|
|
{t("ui.admin.users.list.add", "사용자 추가")}
|
|
</Link>
|
|
</Button>
|
|
</>
|
|
}
|
|
/>
|
|
|
|
<Card className="flex-1 flex flex-col min-h-0 bg-[var(--color-panel)] overflow-hidden">
|
|
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
|
|
<div>
|
|
<CardTitle className="text-lg font-bold flex items-center gap-2">
|
|
{t("ui.admin.users.list.registry.title", "User Registry")}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
"msg.admin.users.list.registry.count",
|
|
"총 {{count}}명의 사용자가 등록되어 있습니다.",
|
|
{ count: total },
|
|
)}
|
|
</CardDescription>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
|
{(errorMsg || fallbackError) && (
|
|
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
|
|
{errorMsg ?? fallbackError}
|
|
</div>
|
|
)}
|
|
|
|
<div className={commonTableShellClass}>
|
|
<div
|
|
ref={userTableViewportRef}
|
|
data-testid="user-table-viewport"
|
|
className={commonTableViewportClass}
|
|
>
|
|
<Table style={{ display: "grid", minWidth: userTableMinWidth }}>
|
|
<TableHeader className={sortableTableHeaderClassName}>
|
|
<TableRow
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: userTableGridTemplateColumns,
|
|
minWidth: userTableMinWidth,
|
|
}}
|
|
>
|
|
<TableHead className={`${userTableHeadClassName} w-12`}>
|
|
<div className="flex h-full items-center justify-center">
|
|
<input
|
|
name="user-list-select-all"
|
|
type="checkbox"
|
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
|
checked={
|
|
items.length > 0 &&
|
|
selectedUserIds.length === items.length
|
|
}
|
|
onChange={toggleSelectAll}
|
|
/>
|
|
</div>
|
|
</TableHead>
|
|
<TableHead
|
|
className={`${userTableHeadInteractiveClassName} min-w-[120px]`}
|
|
onClick={() => requestSort("name")}
|
|
>
|
|
<div className={userTableHeadContentClassName}>
|
|
{t("ui.admin.users.list.table.name", "이름")}
|
|
{getSortIcon("name")}
|
|
</div>
|
|
</TableHead>
|
|
<TableHead
|
|
className={`${userTableHeadInteractiveClassName} min-w-[180px]`}
|
|
onClick={() => requestSort("email")}
|
|
>
|
|
<div className={userTableHeadContentClassName}>
|
|
{t("ui.admin.users.list.table.email", "이메일")}
|
|
{getSortIcon("email")}
|
|
</div>
|
|
</TableHead>
|
|
<TableHead
|
|
className={`${userTableHeadInteractiveClassName} min-w-[140px]`}
|
|
onClick={() => requestSort("phone")}
|
|
>
|
|
<div className={userTableHeadContentClassName}>
|
|
{t("ui.admin.users.list.table.phone", "전화번호")}
|
|
{getSortIcon("phone")}
|
|
</div>
|
|
</TableHead>
|
|
<TableHead
|
|
className={`${userTableHeadInteractiveClassName} min-w-[220px]`}
|
|
onClick={() => requestSort("id")}
|
|
>
|
|
<div className={userTableHeadContentClassName}>
|
|
{t("ui.admin.users.list.table.id", "ID")}
|
|
{getSortIcon("id")}
|
|
</div>
|
|
</TableHead>
|
|
<TableHead
|
|
className={userTableHeadInteractiveClassName}
|
|
onClick={() => requestSort("status")}
|
|
>
|
|
<div className={userTableHeadContentClassName}>
|
|
{t("ui.admin.users.list.table.status", "STATUS")}
|
|
{getSortIcon("status")}
|
|
</div>
|
|
</TableHead>
|
|
<TableHead
|
|
className={userTableHeadInteractiveClassName}
|
|
onClick={() => requestSort("role")}
|
|
>
|
|
<div className={userTableHeadContentClassName}>
|
|
{t("ui.admin.users.list.table.role", "ROLE")}
|
|
{getSortIcon("role")}
|
|
</div>
|
|
</TableHead>
|
|
<TableHead
|
|
className={userTableHeadInteractiveClassName}
|
|
onClick={() => requestSort("tenant_dept")}
|
|
>
|
|
<div className={userTableHeadContentClassName}>
|
|
{t(
|
|
"ui.admin.users.list.table.tenant_dept",
|
|
"TENANT / DEPT",
|
|
)}
|
|
{getSortIcon("tenant_dept")}
|
|
</div>
|
|
</TableHead>
|
|
{/* Dynamic Columns from Schema */}
|
|
{visibleUserSchemaFields.map((field) => (
|
|
<SortableTableHead
|
|
key={field.key}
|
|
className={userSortableTableHeadClassName}
|
|
contentClassName={userSortableTableHeadContentClassName}
|
|
label={field.label}
|
|
onSort={requestSort}
|
|
sortConfig={sortConfig}
|
|
sortKey={field.key}
|
|
/>
|
|
))}
|
|
<SortableTableHead
|
|
className={userSortableTableHeadClassName}
|
|
contentClassName={userSortableTableHeadContentClassName}
|
|
label={t("ui.admin.users.list.table.created", "CREATED")}
|
|
onSort={requestSort}
|
|
sortConfig={sortConfig}
|
|
sortKey="createdAt"
|
|
/>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody
|
|
style={
|
|
shouldVirtualizeRows
|
|
? {
|
|
display: "grid",
|
|
height: `${rowVirtualizer.getTotalSize()}px`,
|
|
minWidth: userTableMinWidth,
|
|
position: "relative",
|
|
}
|
|
: undefined
|
|
}
|
|
>
|
|
{query.isLoading && (
|
|
<TableRow
|
|
data-testid="user-table-loading-row"
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: userTableGridTemplateColumns,
|
|
minWidth: userTableMinWidth,
|
|
}}
|
|
>
|
|
<TableCell
|
|
colSpan={tableColumnCount}
|
|
data-testid="user-table-loading-cell"
|
|
className={userTableStateCellClassName}
|
|
style={{ gridColumn: "1 / -1" }}
|
|
>
|
|
{t("msg.common.loading", "로딩 중...")}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{!query.isLoading && items.length === 0 && (
|
|
<TableRow
|
|
data-testid="user-table-empty-row"
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: userTableGridTemplateColumns,
|
|
minWidth: userTableMinWidth,
|
|
}}
|
|
>
|
|
<TableCell
|
|
colSpan={tableColumnCount}
|
|
data-testid="user-table-empty-cell"
|
|
className={userTableStateCellClassName}
|
|
style={{ gridColumn: "1 / -1" }}
|
|
>
|
|
{t(
|
|
"msg.admin.users.list.empty",
|
|
"검색 결과가 없습니다.",
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{shouldVirtualizeRows &&
|
|
virtualRows.map((virtualRow) => {
|
|
const user = items[virtualRow.index];
|
|
if (!user) return null;
|
|
|
|
return (
|
|
<TableRow
|
|
key={user.id}
|
|
data-index={virtualRow.index}
|
|
ref={rowVirtualizer.measureElement}
|
|
className={
|
|
selectedUserIds.includes(user.id)
|
|
? "bg-primary/5"
|
|
: ""
|
|
}
|
|
style={{
|
|
display: "grid",
|
|
gridTemplateColumns: userTableGridTemplateColumns,
|
|
height: `${virtualRow.size}px`,
|
|
minWidth: userTableMinWidth,
|
|
position: "absolute",
|
|
transform: `translateY(${virtualRow.start}px)`,
|
|
width: "100%",
|
|
}}
|
|
>
|
|
<TableCell>
|
|
<input
|
|
name={`user-list-select-${user.id}`}
|
|
type="checkbox"
|
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer disabled:opacity-30 disabled:cursor-not-allowed"
|
|
checked={selectedUserIds.includes(user.id)}
|
|
onChange={() => toggleSelectUser(user.id)}
|
|
disabled={user.id === profile?.id}
|
|
title={
|
|
user.id === profile?.id
|
|
? t(
|
|
"msg.admin.users.self_delete_blocked",
|
|
"본인 계정은 삭제할 수 없습니다.",
|
|
)
|
|
: undefined
|
|
}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Link
|
|
to={`/users/${user.id}`}
|
|
className="block max-w-[150px] truncate font-medium text-foreground transition-colors hover:text-primary hover:underline"
|
|
title={user.name}
|
|
>
|
|
{user.name}
|
|
</Link>
|
|
</TableCell>
|
|
<TableCell
|
|
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>
|
|
<Select
|
|
value={normalizeUserStatusValue(user.status)}
|
|
onValueChange={(status) =>
|
|
statusMutation.mutate({
|
|
userId: user.id,
|
|
status,
|
|
})
|
|
}
|
|
disabled={
|
|
statusMutation.isPending ||
|
|
user.id === profile?.id
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
className="h-8 w-[150px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium"
|
|
aria-label={t(
|
|
"ui.admin.users.list.change_status",
|
|
"{{name}} 상태 변경",
|
|
{ name: user.name },
|
|
)}
|
|
data-testid={`user-status-select-${user.id}`}
|
|
>
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{userStatusValues.map((status) => (
|
|
<SelectItem key={status} value={status}>
|
|
{userStatusLabel(status)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Select
|
|
value={assignableSystemRoleValue(user.role)}
|
|
onValueChange={(value) =>
|
|
bulkUpdateMutation.mutate({
|
|
userIds: [user.id],
|
|
role: value,
|
|
})
|
|
}
|
|
disabled={
|
|
bulkUpdateMutation.isPending ||
|
|
!isSuperAdminRole(profile?.role) ||
|
|
user.id === profile?.id
|
|
}
|
|
>
|
|
<SelectTrigger className="h-8 w-[140px] border-none bg-transparent hover:bg-muted/50 transition-colors px-0 font-medium">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{bulkPermissionOptions.map((option) => (
|
|
<SelectItem
|
|
key={option.value}
|
|
value={option.value}
|
|
>
|
|
{t(option.labelKey, option.fallback)}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-col gap-1">
|
|
<span className="text-sm font-medium">
|
|
{user.tenant?.name ||
|
|
user.tenantSlug ||
|
|
t("ui.common.unassigned", "미배정")}
|
|
</span>
|
|
{user.department && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{user.department}
|
|
</span>
|
|
)}
|
|
</div>
|
|
</TableCell>
|
|
{/* Dynamic Metadata Cells */}
|
|
{visibleUserSchemaFields.map((field) => (
|
|
<TableCell key={field.key} className="text-sm">
|
|
{String(user.metadata?.[field.key] ?? "-")}
|
|
</TableCell>
|
|
))}
|
|
<TableCell className="text-sm text-muted-foreground">
|
|
{new Date(user.createdAt).toLocaleDateString()}
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bulk Action Bar */}
|
|
{selectedUserIds.length > 0 && (
|
|
<div
|
|
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 flex items-center gap-4 px-6 py-3 rounded-2xl bg-foreground text-background shadow-2xl animate-in slide-in-from-bottom-4 duration-300"
|
|
data-testid="bulk-action-bar"
|
|
>
|
|
<span className="text-sm font-medium border-r border-background/20 pr-4 mr-2">
|
|
{t("ui.admin.users.bulk.selected_count", "{{count}}명 선택됨", {
|
|
count: selectedUserIds.length,
|
|
})}
|
|
</span>
|
|
<div className="flex items-center gap-2">
|
|
<Select
|
|
value={selectedBulkStatus}
|
|
onValueChange={(value) =>
|
|
setSelectedBulkStatus(value as UserStatusValue)
|
|
}
|
|
>
|
|
<SelectTrigger
|
|
className="h-8 w-[120px] bg-transparent border-background/20 text-background text-xs"
|
|
data-testid="bulk-status-select"
|
|
>
|
|
<SelectValue
|
|
placeholder={t(
|
|
"ui.admin.users.bulk.status_placeholder",
|
|
"상태 선택",
|
|
)}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{userStatusValues.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 gap-1.5"
|
|
onClick={() => {
|
|
const payload: {
|
|
userIds: string[];
|
|
status?: UserStatusValue;
|
|
role?: string;
|
|
} = { 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" />
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-destructive-foreground hover:bg-destructive/20 h-8 gap-1.5"
|
|
onClick={handleBulkDelete}
|
|
data-testid="bulk-delete-btn"
|
|
>
|
|
<Trash2 size={14} />
|
|
{t("ui.common.delete", "삭제")}
|
|
</Button>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-background/50 hover:text-background h-8 w-8 ml-2"
|
|
onClick={() => setSelectedUserIds([])}
|
|
aria-label={t("ui.common.close", "닫기")}
|
|
data-testid="bulk-close-btn"
|
|
>
|
|
<Plus size={16} className="rotate-45" />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default UserListPage;
|