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; 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) => ( )), [tenants], ); return (
setLocalSearch(event.target.value)} onKeyDown={(event) => { if (event.key === "Enter") { onSearch(localSearch); } }} />
} /> ); }); function UserListPage() { const _navigate = useNavigate(); const [search, setSearch] = React.useState(""); const [selectedCompany, setSelectedCompany] = React.useState(""); const [visibleColumns, setVisibleColumns] = React.useState< Record >({}); const [selectedUserIds, setSelectedUserIds] = React.useState([]); const [selectedBulkStatus, setSelectedBulkStatus] = React.useState< UserStatusValue | "" >(""); const [selectedBulkPermission, setSelectedBulkPermission] = React.useState(""); const [sortConfig, setSortConfig] = React.useState | null>(null); const [bulkUploadOpen, setBulkUploadOpen] = React.useState(false); const userTableViewportRef = React.useRef(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 = {}; 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 >( () => userSchema.reduce>( (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 ; } return sortConfig.direction === "asc" ? ( ) : ( ); }; 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 (
} title={ {t("ui.admin.users.list.title", "사용자 관리")} } description={t( "msg.admin.users.list.subtitle", "시스템 사용자를 조회하고 관리합니다.", )} actions={ <> {t("ui.admin.users.csv_template", "템플릿 다운로드")} { setBulkUploadOpen(true); }} className="cursor-pointer" > {t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")} handleExport(false)} disabled={exportMutation.isPending} data-testid="user-export-menu-item" className="cursor-pointer" > {t("ui.common.export_without_ids", "UUID 제외 내보내기")} handleExport(true)} disabled={exportMutation.isPending} data-testid="user-export-with-ids-menu-item" className="cursor-pointer" > {t("ui.common.export_with_ids", "UUID 포함 내보내기")} query.refetch()} /> {t("ui.admin.users.list.columns.title", "표시 컬럼 설정")} {t( "msg.admin.users.list.columns.description", "사용자 목록에 표시할 커스텀 필드를 선택하세요.", )}
{userSchema.length === 0 && (

{t( "msg.admin.users.list.columns.no_custom", "현재 테넌트에 정의된 커스텀 필드가 없습니다.", )}

)} {userSchema.map((field) => ( ))}
} />
{t("ui.admin.users.list.registry.title", "User Registry")} {t( "msg.admin.users.list.registry.count", "총 {{count}}명의 사용자가 등록되어 있습니다.", { count: total }, )}
{(errorMsg || fallbackError) && (
{errorMsg ?? fallbackError}
)}
0 && selectedUserIds.length === items.length } onChange={toggleSelectAll} />
requestSort("name")} >
{t("ui.admin.users.list.table.name", "이름")} {getSortIcon("name")}
requestSort("email")} >
{t("ui.admin.users.list.table.email", "이메일")} {getSortIcon("email")}
requestSort("phone")} >
{t("ui.admin.users.list.table.phone", "전화번호")} {getSortIcon("phone")}
requestSort("id")} >
{t("ui.admin.users.list.table.id", "ID")} {getSortIcon("id")}
requestSort("status")} >
{t("ui.admin.users.list.table.status", "STATUS")} {getSortIcon("status")}
requestSort("role")} >
{t("ui.admin.users.list.table.role", "ROLE")} {getSortIcon("role")}
requestSort("tenant_dept")} >
{t( "ui.admin.users.list.table.tenant_dept", "TENANT / DEPT", )} {getSortIcon("tenant_dept")}
{/* Dynamic Columns from Schema */} {visibleUserSchemaFields.map((field) => ( ))}
{query.isLoading && ( {t("msg.common.loading", "로딩 중...")} )} {!query.isLoading && items.length === 0 && ( {t( "msg.admin.users.list.empty", "검색 결과가 없습니다.", )} )} {shouldVirtualizeRows && virtualRows.map((virtualRow) => { const user = items[virtualRow.index]; if (!user) return null; return ( toggleSelectUser(user.id)} disabled={user.id === profile?.id} title={ user.id === profile?.id ? t( "msg.admin.users.self_delete_blocked", "본인 계정은 삭제할 수 없습니다.", ) : undefined } /> {user.name} {user.email} {user.phone || "-"} {user.id}
{user.tenant?.name || user.tenantSlug || t("ui.common.unassigned", "미배정")} {user.department && ( {user.department} )}
{/* Dynamic Metadata Cells */} {visibleUserSchemaFields.map((field) => ( {String(user.metadata?.[field.key] ?? "-")} ))} {new Date(user.createdAt).toLocaleDateString()}
); })}
{/* Bulk Action Bar */} {selectedUserIds.length > 0 && (
{t("ui.admin.users.bulk.selected_count", "{{count}}명 선택됨", { count: selectedUserIds.length, })}
{canPromoteSuperAdmin && ( )}
)}
); } export default UserListPage;