1
0
forked from baron/baron-sso
Files
baron-sso/adminfront/src/features/users/UserListPage.tsx

1334 lines
47 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,
Key,
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 { normalizeAdminRole } from "../../lib/roles";
import {
downloadUserTemplate,
UserBulkUploadModal,
} from "./components/UserBulkUploadModal";
import { formatUserPolicyMessage } from "./userPolicyMessages";
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 = 2;
const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640;
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 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";
type RepresentativeTenantCandidate = {
id?: string;
slug?: string;
name?: string;
config?: Record<string, unknown>;
};
function stringValue(value: unknown) {
return typeof value === "string" ? value.trim() : "";
}
function tenantVisibility(tenant?: RepresentativeTenantCandidate) {
const visibility = tenant?.config?.visibility;
return typeof visibility === "string" ? visibility.trim() : "";
}
function findTenantCandidate(
candidate: RepresentativeTenantCandidate,
tenants: TenantSummary[],
) {
const id = candidate.id?.toLowerCase() ?? "";
const slug = candidate.slug?.toLowerCase() ?? "";
if (!id && !slug) return undefined;
return tenants.find(
(tenant) =>
(id && tenant.id.toLowerCase() === id) ||
(slug && tenant.slug.toLowerCase() === slug),
);
}
function isPrivateTenantCandidate(
candidate: RepresentativeTenantCandidate,
tenants: TenantSummary[],
) {
const tenant = findTenantCandidate(candidate, tenants) ?? candidate;
return tenantVisibility(tenant) === "private";
}
function candidateLabel(candidate: RepresentativeTenantCandidate) {
return candidate.name || candidate.slug || candidate.id || "";
}
function metadataTenantCandidate(
metadata: Record<string, unknown> | undefined,
): RepresentativeTenantCandidate | null {
const id = stringValue(metadata?.primaryTenantId);
const slug = stringValue(metadata?.primaryTenantSlug);
const name = stringValue(metadata?.primaryTenantName);
if (!id && !slug && !name) return null;
return { id, slug, name };
}
function appointmentTenantCandidate(
appointment: unknown,
): RepresentativeTenantCandidate | null {
if (!appointment || typeof appointment !== "object") return null;
const value = appointment as Record<string, unknown>;
const id = stringValue(value.tenantId);
const slug = stringValue(value.tenantSlug ?? value.slug);
const name = stringValue(value.tenantName ?? value.name);
if (!id && !slug && !name) return null;
return { id, slug, name };
}
function resolveRepresentativeTenantLabel(
user: UserSummary,
tenants: TenantSummary[],
) {
const candidates: RepresentativeTenantCandidate[] = [];
const knownTenants = [
...(user.tenant ? [user.tenant] : []),
...(user.joinedTenants ?? []),
...tenants,
];
const primaryFromMetadata = metadataTenantCandidate(user.metadata);
if (primaryFromMetadata) candidates.push(primaryFromMetadata);
if (user.tenant) candidates.push(user.tenant);
for (const tenant of user.joinedTenants ?? []) {
candidates.push(tenant);
}
const appointments = user.metadata?.additionalAppointments;
if (Array.isArray(appointments)) {
for (const appointment of appointments) {
if (
appointment &&
typeof appointment === "object" &&
(appointment as Record<string, unknown>).isPrimary !== true
) {
continue;
}
const candidate = appointmentTenantCandidate(appointment);
if (candidate) candidates.push(candidate);
}
}
if (user.tenantSlug) candidates.push({ slug: user.tenantSlug });
const representative = candidates.find(
(candidate) =>
candidateLabel(candidate) &&
!isPrivateTenantCandidate(candidate, knownTenants),
);
return candidateLabel(representative ?? {});
}
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 [selectedBulkRole, setSelectedBulkRole] = React.useState<
"super_admin" | "user" | ""
>("");
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 isWritable =
profileRole === "super_admin" || !!profile?.systemPermissions?.manage_users;
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) =>
`${resolveRepresentativeTenantLabel(user, tenants)} ${user.department ?? ""}`,
},
),
[tenants, 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 = 8 + 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 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: (data) => {
const failed = data.results?.filter((result) => !result.success) ?? [];
if (failed.length > 0) {
toast.error(
t(
"msg.admin.users.bulk.update_partial_error",
"{{count}}명의 사용자 정보 수정에 실패했습니다.",
{ count: failed.length },
),
{
description: formatUserPolicyMessage(failed[0]?.message),
},
);
return;
}
query.refetch();
setSelectedUserIds([]);
setSelectedBulkStatus("");
setSelectedBulkRole("");
toast.success(
t(
"msg.admin.users.bulk.update_success",
"선택한 사용자들의 정보가 수정되었습니다.",
),
);
},
});
const _handleApplyBulkStatus = () => {
if (selectedUserIds.length === 0 || !selectedBulkStatus) return;
bulkUpdateMutation.mutate({
userIds: selectedUserIds,
status: selectedBulkStatus,
});
};
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",
"Kratos identity mirror 기준으로 시스템 사용자를 조회하고 관리합니다.",
)}
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>
<Button asChild variant="outline" size="sm" className="h-9 gap-2">
<Link to="/users/custom-claims">
<Key size={16} />
{t(
"ui.admin.users.global_custom_claims.title",
"전역 Claim 설정",
)}
</Link>
</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={() => {
if (isWritable) setBulkUploadOpen(true);
}}
disabled={!isWritable}
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>
{isWritable ? (
<Button asChild size="sm" className="h-9">
<Link to="/users/new">
<Plus size={16} />
{t("ui.admin.users.list.add", "사용자 추가")}
</Link>
</Button>
) : (
<Button size="sm" className="h-9" disabled>
<Plus size={16} />
{t("ui.admin.users.list.add", "사용자 추가")}
</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("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;
const representativeTenantLabel =
resolveRepresentativeTenantLabel(user, tenants) ||
t("ui.common.unassigned", "미배정");
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 ||
!isWritable
}
>
<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>
<div className="flex flex-col gap-1">
<span className="text-sm font-medium">
{representativeTenantLabel}
</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>
<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?: "super_admin" | "user";
} = { userIds: selectedUserIds };
let hasChanges = false;
if (selectedBulkStatus) {
payload.status = selectedBulkStatus;
hasChanges = true;
}
if (selectedBulkRole) {
payload.role = selectedBulkRole;
hasChanges = true;
}
if (hasChanges) {
bulkUpdateMutation.mutate(payload);
}
}}
disabled={
(!selectedBulkStatus && !selectedBulkRole) ||
bulkUpdateMutation.isPending ||
!isWritable
}
data-testid="bulk-apply-btn"
>
<ShieldCheck size={14} />
{t("ui.common.apply", "적용")}
</Button>
<Select
value={selectedBulkRole}
onValueChange={(value) =>
setSelectedBulkRole(value as "super_admin" | "user")
}
>
<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>
<SelectItem value="super_admin">
{t(
"ui.admin.users.detail.form.role_super_admin",
"시스템 관리자",
)}
</SelectItem>
<SelectItem value="user">
{t("ui.admin.users.detail.form.role_user", "일반 사용자")}
</SelectItem>
</SelectContent>
</Select>
<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}
disabled={!isWritable}
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;