forked from baron/baron-sso
687 lines
24 KiB
TypeScript
687 lines
24 KiB
TypeScript
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import type { AxiosError } from "axios";
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
FileDown,
|
|
Pencil,
|
|
Plus,
|
|
RefreshCw,
|
|
Search,
|
|
Settings2,
|
|
Trash2,
|
|
User,
|
|
} from "lucide-react";
|
|
import * as React from "react";
|
|
import { Link, useNavigate } from "react-router-dom";
|
|
import { Badge } from "../../components/ui/badge";
|
|
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 { Input } from "../../components/ui/input";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "../../components/ui/table";
|
|
import {
|
|
bulkDeleteUsers,
|
|
bulkUpdateUsers,
|
|
deleteUser,
|
|
exportUsersCSVUrl,
|
|
fetchMe,
|
|
fetchTenant,
|
|
fetchTenants,
|
|
fetchUsers,
|
|
} from "../../lib/adminApi";
|
|
import { t } from "../../lib/i18n";
|
|
import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal";
|
|
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
|
|
|
|
type UserSchemaField = {
|
|
key: string;
|
|
label: string;
|
|
type: string;
|
|
};
|
|
|
|
function UserListPage() {
|
|
const navigate = useNavigate();
|
|
const [page, setPage] = React.useState(1);
|
|
const [search, setSearch] = React.useState("");
|
|
const [searchDraft, setSearchDraft] = React.useState("");
|
|
const [selectedCompany, setSelectedCompany] = React.useState<string>("");
|
|
const [visibleColumns, setVisibleColumns] = React.useState<
|
|
Record<string, boolean>
|
|
>({});
|
|
const [selectedUserIds, setSelectedUserIds] = React.useState<string[]>([]);
|
|
const limit = 50;
|
|
const offset = (page - 1) * limit;
|
|
|
|
const { data: profile } = useQuery({
|
|
queryKey: ["me"],
|
|
queryFn: fetchMe,
|
|
});
|
|
|
|
const { data: tenantsData } = useQuery({
|
|
queryKey: ["tenants", { limit: 100 }],
|
|
queryFn: () => fetchTenants(100, 0),
|
|
});
|
|
const tenants = tenantsData?.items ?? [];
|
|
|
|
// Lock company for tenant_admin
|
|
React.useEffect(() => {
|
|
if (profile?.role === "tenant_admin" && profile.companyCode) {
|
|
setSelectedCompany(profile.companyCode);
|
|
}
|
|
}, [profile]);
|
|
|
|
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 = useQuery({
|
|
queryKey: [
|
|
"users",
|
|
{ limit, offset, search, companyCode: selectedCompany },
|
|
],
|
|
queryFn: () => fetchUsers(limit, offset, search, selectedCompany),
|
|
placeholderData: (previousData) => previousData,
|
|
});
|
|
|
|
const deleteMutation = useMutation({
|
|
mutationFn: (userId: string) => deleteUser(userId),
|
|
onSuccess: () => {
|
|
query.refetch();
|
|
},
|
|
});
|
|
|
|
const handleSearch = () => {
|
|
setSearch(searchDraft);
|
|
setPage(1);
|
|
};
|
|
|
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
|
if (e.key === "Enter") {
|
|
handleSearch();
|
|
}
|
|
};
|
|
|
|
const handleExport = () => {
|
|
const url = exportUsersCSVUrl(search, selectedCompany);
|
|
window.open(url, "_blank");
|
|
};
|
|
|
|
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 items = query.data?.items ?? [];
|
|
const total = query.data?.total ?? 0;
|
|
const totalPages = Math.ceil(total / limit);
|
|
|
|
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: () => {
|
|
query.refetch();
|
|
setSelectedUserIds([]);
|
|
toast.success(
|
|
t(
|
|
"msg.admin.users.bulk.delete_success",
|
|
"선택한 사용자들이 삭제되었습니다.",
|
|
),
|
|
);
|
|
},
|
|
});
|
|
|
|
const bulkUpdateMutation = useMutation({
|
|
mutationFn: bulkUpdateUsers,
|
|
onSuccess: () => {
|
|
query.refetch();
|
|
setSelectedUserIds([]);
|
|
toast.success(
|
|
t(
|
|
"msg.admin.users.bulk.update_success",
|
|
"선택한 사용자들의 정보가 수정되었습니다.",
|
|
),
|
|
);
|
|
},
|
|
});
|
|
|
|
const handleBulkStatusChange = (status: string) => {
|
|
if (selectedUserIds.length === 0) return;
|
|
bulkUpdateMutation.mutate({ userIds: selectedUserIds, status });
|
|
};
|
|
|
|
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))]">
|
|
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
|
<div className="space-y-2">
|
|
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
|
|
<span>{t("ui.admin.users.list.breadcrumb.section", "Users")}</span>
|
|
<span>/</span>
|
|
<span className="text-foreground">
|
|
{t("ui.admin.users.list.breadcrumb.list", "List")}
|
|
</span>
|
|
</div>
|
|
<h2 className="text-3xl font-semibold" data-testid="page-title">
|
|
{t("ui.admin.users.list.title", "사용자 관리")}
|
|
</h2>
|
|
<p className="text-sm text-[var(--color-muted)]">
|
|
{t(
|
|
"msg.admin.users.list.subtitle",
|
|
"시스템 사용자를 조회하고 관리합니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => query.refetch()}
|
|
disabled={query.isFetching}
|
|
>
|
|
<RefreshCw size={16} />
|
|
{t("ui.common.refresh", "새로고침")}
|
|
</Button>
|
|
<Button variant="outline" onClick={handleExport} className="gap-2">
|
|
<FileDown size={16} />
|
|
{t("ui.common.export", "내보내기")}
|
|
</Button>
|
|
<UserBulkUploadModal onSuccess={() => query.refetch()} />
|
|
<Dialog>
|
|
<DialogTrigger asChild>
|
|
<Button variant="outline" size="icon">
|
|
<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="text-sm text-muted-foreground text-center py-4">
|
|
{t(
|
|
"msg.admin.users.list.columns.no_custom",
|
|
"현재 테넌트에 정의된 커스텀 필드가 없습니다.",
|
|
)}
|
|
</p>
|
|
)}
|
|
{userSchema.map((field) => (
|
|
<label
|
|
key={field.key}
|
|
className="flex items-center gap-3 p-2 rounded-lg hover:bg-muted/50 cursor-pointer"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
className="w-4 h-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="text-xs text-muted-foreground font-mono">
|
|
{field.key}
|
|
</span>
|
|
</div>
|
|
</label>
|
|
))}
|
|
</div>
|
|
<DialogFooter>
|
|
<DialogTrigger asChild>
|
|
<Button variant="secondary">
|
|
{t("ui.common.close", "닫기")}
|
|
</Button>
|
|
</DialogTrigger>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
<Button asChild>
|
|
<Link to="/users/new">
|
|
<Plus size={16} />
|
|
{t("ui.admin.users.list.add", "사용자 추가")}
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</header>
|
|
|
|
<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>
|
|
{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">
|
|
<div className="mb-6 flex flex-wrap items-center gap-4 flex-shrink-0">
|
|
<div className="relative flex-1 min-w-[240px] max-w-sm">
|
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
|
<Input
|
|
placeholder={t(
|
|
"ui.admin.users.list.search_placeholder",
|
|
"이름 또는 이메일 검색...",
|
|
)}
|
|
className="pl-9"
|
|
value={searchDraft}
|
|
onChange={(e) => setSearchDraft(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-sm font-medium text-muted-foreground whitespace-nowrap">
|
|
{t("ui.admin.users.list.filter.tenant", "테넌트 필터:")}
|
|
</span>
|
|
<select
|
|
className="flex h-9 w-[200px] 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={(e) => {
|
|
setSelectedCompany(e.target.value);
|
|
setPage(1);
|
|
}}
|
|
disabled={profile?.role === "tenant_admin"}
|
|
>
|
|
<option value="">{t("ui.common.all", "전체")}</option>
|
|
{tenants.map((t) => (
|
|
<option key={t.id} value={t.slug}>
|
|
{t.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
<Button variant="secondary" onClick={handleSearch}>
|
|
{t("ui.common.search", "검색")}
|
|
</Button>
|
|
</div>
|
|
|
|
{(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="flex-1 rounded-md border overflow-hidden flex flex-col">
|
|
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
|
<Table>
|
|
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
|
<TableRow>
|
|
<TableHead className="w-12">
|
|
<input
|
|
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}
|
|
/>
|
|
</TableHead>
|
|
<TableHead className="min-w-[200px]">
|
|
{t(
|
|
"ui.admin.users.list.table.name_email",
|
|
"NAME / EMAIL",
|
|
)}
|
|
</TableHead>
|
|
<TableHead>
|
|
{t("ui.admin.users.list.table.role", "ROLE")}
|
|
</TableHead>
|
|
<TableHead>
|
|
{t("ui.admin.users.list.table.status", "STATUS")}
|
|
</TableHead>
|
|
<TableHead>
|
|
{t(
|
|
"ui.admin.users.list.table.tenant_dept",
|
|
"TENANT / DEPT",
|
|
)}
|
|
</TableHead>
|
|
{/* Dynamic Columns from Schema */}
|
|
{userSchema.map(
|
|
(field) =>
|
|
visibleColumns[field.key] !== false && (
|
|
<TableHead key={field.key} className="uppercase">
|
|
{field.label}
|
|
</TableHead>
|
|
),
|
|
)}
|
|
<TableHead>
|
|
{t("ui.admin.users.list.table.created", "CREATED")}
|
|
</TableHead>
|
|
<TableHead className="text-right">
|
|
{t("ui.admin.users.list.table.actions", "ACTIONS")}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{query.isLoading && (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={7 + userSchema.length}
|
|
className="h-24 text-center"
|
|
>
|
|
{t("msg.common.loading", "로딩 중...")}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{!query.isLoading && items.length === 0 && (
|
|
<TableRow>
|
|
<TableCell
|
|
colSpan={7 + userSchema.length}
|
|
className="h-24 text-center"
|
|
>
|
|
{t(
|
|
"msg.admin.users.list.empty",
|
|
"검색 결과가 없습니다.",
|
|
)}
|
|
</TableCell>
|
|
</TableRow>
|
|
)}
|
|
{items.map((user) => (
|
|
<TableRow
|
|
key={user.id}
|
|
className={
|
|
selectedUserIds.includes(user.id) ? "bg-primary/5" : ""
|
|
}
|
|
>
|
|
<TableCell>
|
|
<input
|
|
type="checkbox"
|
|
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary cursor-pointer"
|
|
checked={selectedUserIds.includes(user.id)}
|
|
onChange={() => toggleSelectUser(user.id)}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-secondary text-secondary-foreground">
|
|
<User size={16} />
|
|
</div>
|
|
<div className="flex flex-col">
|
|
<span className="font-medium">{user.name}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{user.email}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge variant="outline">
|
|
{t(`ui.admin.role.${user.role}`, user.role)}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Badge
|
|
variant={
|
|
user.status === "active" ? "default" : "secondary"
|
|
}
|
|
>
|
|
{t(`ui.common.status.${user.status}`, user.status)}
|
|
</Badge>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex flex-col text-sm">
|
|
<span className="font-medium text-blue-600">
|
|
{user.tenant?.name || user.companyCode || "-"}
|
|
</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{user.department || "-"}
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
{/* Dynamic Metadata Cells */}
|
|
{userSchema.map(
|
|
(field) =>
|
|
visibleColumns[field.key] !== false && (
|
|
<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>
|
|
<TableCell className="text-right">
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => navigate(`/users/${user.id}`)}
|
|
>
|
|
<Pencil size={16} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="text-destructive hover:text-destructive"
|
|
onClick={() => handleDelete(user.id, user.name)}
|
|
disabled={deleteMutation.isPending}
|
|
>
|
|
<Trash2 size={16} />
|
|
</Button>
|
|
</div>
|
|
</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">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-background hover:bg-background/10 h-8"
|
|
onClick={() => handleBulkStatusChange("active")}
|
|
data-testid="bulk-active-btn"
|
|
>
|
|
{t("ui.common.status.active", "활성화")}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="text-background hover:bg-background/10 h-8"
|
|
onClick={() => handleBulkStatusChange("inactive")}
|
|
data-testid="bulk-inactive-btn"
|
|
>
|
|
{t("ui.common.status.inactive", "비활성화")}
|
|
</Button>
|
|
<UserBulkMoveGroupModal
|
|
userIds={selectedUserIds}
|
|
selectedUsers={items.filter((u) =>
|
|
selectedUserIds.includes(u.id),
|
|
)}
|
|
onSuccess={() => {
|
|
query.refetch();
|
|
setSelectedUserIds([]);
|
|
}}
|
|
/>
|
|
<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>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{totalPages > 1 && (
|
|
<div className="mt-4 flex flex-shrink-0 items-center justify-end gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
disabled={page === 1 || query.isFetching}
|
|
>
|
|
<ChevronLeft size={16} />
|
|
{t("ui.common.previous", "Previous")}
|
|
</Button>
|
|
<div className="text-sm text-muted-foreground">
|
|
{t("ui.common.page_of", "Page {{page}} of {{total}}", {
|
|
page,
|
|
total: totalPages,
|
|
})}
|
|
</div>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
|
disabled={page === totalPages || query.isFetching}
|
|
>
|
|
{t("ui.common.next", "Next")}
|
|
<ChevronRight size={16} />
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default UserListPage;
|