Add paginated user retrieval functionality in AdminController and related services. Implemented UsersFilter for filtering user queries and added LastConnectionDate property to User model. Updated database schema and frontend API to support new user management features.
This commit is contained in:
@@ -529,6 +529,63 @@ export class AdminClient extends AuthorizedApiBase {
|
||||
}
|
||||
return Promise.resolve<FileResponse>(null as any);
|
||||
}
|
||||
|
||||
admin_GetUsersPaginated(page: number | undefined, pageSize: number | undefined, sortBy: UserSortableColumn | undefined, sortOrder: string | null | undefined, userNameContains: string | null | undefined, ownerAddressContains: string | null | undefined, agentNameContains: string | null | undefined, telegramChannelContains: string | null | undefined): Promise<PaginatedUsersResponse> {
|
||||
let url_ = this.baseUrl + "/Admin/Users/Paginated?";
|
||||
if (page === null)
|
||||
throw new Error("The parameter 'page' cannot be null.");
|
||||
else if (page !== undefined)
|
||||
url_ += "page=" + encodeURIComponent("" + page) + "&";
|
||||
if (pageSize === null)
|
||||
throw new Error("The parameter 'pageSize' cannot be null.");
|
||||
else if (pageSize !== undefined)
|
||||
url_ += "pageSize=" + encodeURIComponent("" + pageSize) + "&";
|
||||
if (sortBy === null)
|
||||
throw new Error("The parameter 'sortBy' cannot be null.");
|
||||
else if (sortBy !== undefined)
|
||||
url_ += "sortBy=" + encodeURIComponent("" + sortBy) + "&";
|
||||
if (sortOrder !== undefined && sortOrder !== null)
|
||||
url_ += "sortOrder=" + encodeURIComponent("" + sortOrder) + "&";
|
||||
if (userNameContains !== undefined && userNameContains !== null)
|
||||
url_ += "userNameContains=" + encodeURIComponent("" + userNameContains) + "&";
|
||||
if (ownerAddressContains !== undefined && ownerAddressContains !== null)
|
||||
url_ += "ownerAddressContains=" + encodeURIComponent("" + ownerAddressContains) + "&";
|
||||
if (agentNameContains !== undefined && agentNameContains !== null)
|
||||
url_ += "agentNameContains=" + encodeURIComponent("" + agentNameContains) + "&";
|
||||
if (telegramChannelContains !== undefined && telegramChannelContains !== null)
|
||||
url_ += "telegramChannelContains=" + encodeURIComponent("" + telegramChannelContains) + "&";
|
||||
url_ = url_.replace(/[?&]$/, "");
|
||||
|
||||
let options_: RequestInit = {
|
||||
method: "GET",
|
||||
headers: {
|
||||
"Accept": "application/json"
|
||||
}
|
||||
};
|
||||
|
||||
return this.transformOptions(options_).then(transformedOptions_ => {
|
||||
return this.http.fetch(url_, transformedOptions_);
|
||||
}).then((_response: Response) => {
|
||||
return this.processAdmin_GetUsersPaginated(_response);
|
||||
});
|
||||
}
|
||||
|
||||
protected processAdmin_GetUsersPaginated(response: Response): Promise<PaginatedUsersResponse> {
|
||||
const status = response.status;
|
||||
let _headers: any = {}; if (response.headers && response.headers.forEach) { response.headers.forEach((v: any, k: any) => _headers[k] = v); };
|
||||
if (status === 200) {
|
||||
return response.text().then((_responseText) => {
|
||||
let result200: any = null;
|
||||
result200 = _responseText === "" ? null : JSON.parse(_responseText, this.jsonParseReviver) as PaginatedUsersResponse;
|
||||
return result200;
|
||||
});
|
||||
} else if (status !== 200 && status !== 204) {
|
||||
return response.text().then((_responseText) => {
|
||||
return throwException("An unexpected server error occurred.", status, _responseText, _headers);
|
||||
});
|
||||
}
|
||||
return Promise.resolve<PaginatedUsersResponse>(null as any);
|
||||
}
|
||||
}
|
||||
|
||||
export class BacktestClient extends AuthorizedApiBase {
|
||||
@@ -4525,6 +4582,7 @@ export interface User {
|
||||
telegramChannel?: string | null;
|
||||
ownerWalletAddress?: string | null;
|
||||
isAdmin?: boolean;
|
||||
lastConnectionDate?: Date | null;
|
||||
}
|
||||
|
||||
export interface Balance {
|
||||
@@ -4767,6 +4825,34 @@ export interface BundleBacktestRequestStatusSummary {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedUsersResponse {
|
||||
users?: UserListItemResponse[];
|
||||
totalCount?: number;
|
||||
currentPage?: number;
|
||||
pageSize?: number;
|
||||
totalPages?: number;
|
||||
hasNextPage?: boolean;
|
||||
hasPreviousPage?: boolean;
|
||||
}
|
||||
|
||||
export interface UserListItemResponse {
|
||||
id?: number;
|
||||
name?: string;
|
||||
agentName?: string;
|
||||
avatarUrl?: string;
|
||||
telegramChannel?: string;
|
||||
ownerWalletAddress?: string;
|
||||
isAdmin?: boolean;
|
||||
lastConnectionDate?: Date | null;
|
||||
}
|
||||
|
||||
export enum UserSortableColumn {
|
||||
Id = "Id",
|
||||
Name = "Name",
|
||||
OwnerWalletAddress = "OwnerWalletAddress",
|
||||
AgentName = "AgentName",
|
||||
}
|
||||
|
||||
export interface Backtest {
|
||||
id: string;
|
||||
finalPnl: number;
|
||||
|
||||
@@ -48,6 +48,7 @@ export interface User {
|
||||
telegramChannel?: string | null;
|
||||
ownerWalletAddress?: string | null;
|
||||
isAdmin?: boolean;
|
||||
lastConnectionDate?: Date | null;
|
||||
}
|
||||
|
||||
export interface Balance {
|
||||
@@ -290,6 +291,34 @@ export interface BundleBacktestRequestStatusSummary {
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface PaginatedUsersResponse {
|
||||
users?: UserListItemResponse[];
|
||||
totalCount?: number;
|
||||
currentPage?: number;
|
||||
pageSize?: number;
|
||||
totalPages?: number;
|
||||
hasNextPage?: boolean;
|
||||
hasPreviousPage?: boolean;
|
||||
}
|
||||
|
||||
export interface UserListItemResponse {
|
||||
id?: number;
|
||||
name?: string;
|
||||
agentName?: string;
|
||||
avatarUrl?: string;
|
||||
telegramChannel?: string;
|
||||
ownerWalletAddress?: string;
|
||||
isAdmin?: boolean;
|
||||
lastConnectionDate?: Date | null;
|
||||
}
|
||||
|
||||
export enum UserSortableColumn {
|
||||
Id = "Id",
|
||||
Name = "Name",
|
||||
OwnerWalletAddress = "OwnerWalletAddress",
|
||||
AgentName = "AgentName",
|
||||
}
|
||||
|
||||
export interface Backtest {
|
||||
id: string;
|
||||
finalPnl: number;
|
||||
|
||||
9
src/Managing.WebApp/src/hooks/useAdminClient.tsx
Normal file
9
src/Managing.WebApp/src/hooks/useAdminClient.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import {AdminClient} from '../generated/ManagingApi'
|
||||
import useApiUrlStore from '../app/store/apiStore'
|
||||
|
||||
export const useAdminClient = () => {
|
||||
const { apiUrl } = useApiUrlStore()
|
||||
const client = new AdminClient({}, apiUrl)
|
||||
|
||||
return { client }
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import AccountSettings from './account/accountSettings'
|
||||
import WhitelistSettings from './whitelist/whitelistSettings'
|
||||
import JobsSettings from './jobs/jobsSettings'
|
||||
import BundleBacktestRequestsSettings from './bundleBacktestRequests/bundleBacktestRequestsSettings'
|
||||
import UsersSettings from './users/usersSettings'
|
||||
|
||||
type TabsType = {
|
||||
label: string
|
||||
@@ -35,6 +36,11 @@ const tabs: TabsType = [
|
||||
index: 4,
|
||||
label: 'Bundle',
|
||||
},
|
||||
{
|
||||
Component: UsersSettings,
|
||||
index: 5,
|
||||
label: 'Users',
|
||||
},
|
||||
]
|
||||
|
||||
const Admin: React.FC = () => {
|
||||
|
||||
188
src/Managing.WebApp/src/pages/adminPage/users/usersSettings.tsx
Normal file
188
src/Managing.WebApp/src/pages/adminPage/users/usersSettings.tsx
Normal file
@@ -0,0 +1,188 @@
|
||||
import {useState} from 'react'
|
||||
import {useQuery} from '@tanstack/react-query'
|
||||
|
||||
import {useAdminClient} from '../../../hooks/useAdminClient'
|
||||
import {UserSortableColumn,} from '../../../generated/ManagingApi'
|
||||
|
||||
import UsersTable from './usersTable'
|
||||
|
||||
const UsersSettings: React.FC = () => {
|
||||
const { client } = useAdminClient()
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(50)
|
||||
const [sortBy, setSortBy] = useState<UserSortableColumn>(UserSortableColumn.Id)
|
||||
const [sortOrder, setSortOrder] = useState<string>('desc')
|
||||
const [userNameContains, setUserNameContains] = useState<string>('')
|
||||
const [ownerAddressContains, setOwnerAddressContains] = useState<string>('')
|
||||
const [agentNameContains, setAgentNameContains] = useState<string>('')
|
||||
const [telegramChannelContains, setTelegramChannelContains] = useState<string>('')
|
||||
const [filtersOpen, setFiltersOpen] = useState<boolean>(false)
|
||||
|
||||
const {
|
||||
data: usersData,
|
||||
isLoading,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['users', page, pageSize, sortBy, sortOrder, userNameContains, ownerAddressContains, agentNameContains, telegramChannelContains],
|
||||
queryFn: async () => {
|
||||
return await client.admin_GetUsersPaginated(
|
||||
page,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder || null,
|
||||
userNameContains || null,
|
||||
ownerAddressContains || null,
|
||||
agentNameContains || null,
|
||||
telegramChannelContains || null
|
||||
)
|
||||
},
|
||||
staleTime: 10000, // 10 seconds
|
||||
gcTime: 5 * 60 * 1000,
|
||||
})
|
||||
|
||||
const users = usersData?.users || []
|
||||
const totalCount = usersData?.totalCount || 0
|
||||
const totalPages = usersData?.totalPages || 0
|
||||
const currentPage = usersData?.currentPage || 1
|
||||
|
||||
const handlePageChange = (newPage: number) => {
|
||||
setPage(newPage)
|
||||
}
|
||||
|
||||
const handleSortChange = (newSortBy: UserSortableColumn) => {
|
||||
if (sortBy === newSortBy) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')
|
||||
} else {
|
||||
setSortBy(newSortBy)
|
||||
setSortOrder('desc')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFilterChange = () => {
|
||||
setPage(1) // Reset to first page when filters change
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setUserNameContains('')
|
||||
setOwnerAddressContains('')
|
||||
setAgentNameContains('')
|
||||
setTelegramChannelContains('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-4 pb-20">
|
||||
{/* Filters Section */}
|
||||
<div className="card bg-base-100 shadow-md mb-4">
|
||||
<div className="card-body py-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="card-title text-lg">Filters</h3>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
className="btn btn-sm btn-ghost"
|
||||
onClick={() => setFiltersOpen(!filtersOpen)}
|
||||
>
|
||||
{filtersOpen ? 'Hide' : 'Show'} Filters
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={clearFilters}
|
||||
>
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{filtersOpen && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">User Name Contains</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by user name..."
|
||||
className="input input-bordered input-sm"
|
||||
value={userNameContains}
|
||||
onChange={(e) => {
|
||||
setUserNameContains(e.target.value)
|
||||
handleFilterChange()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Owner Address Contains</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by owner address..."
|
||||
className="input input-bordered input-sm"
|
||||
value={ownerAddressContains}
|
||||
onChange={(e) => {
|
||||
setOwnerAddressContains(e.target.value)
|
||||
handleFilterChange()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Agent Name Contains</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by agent name..."
|
||||
className="input input-bordered input-sm"
|
||||
value={agentNameContains}
|
||||
onChange={(e) => {
|
||||
setAgentNameContains(e.target.value)
|
||||
handleFilterChange()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Telegram Channel Contains</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by telegram channel..."
|
||||
className="input input-bordered input-sm"
|
||||
value={telegramChannelContains}
|
||||
onChange={(e) => {
|
||||
setTelegramChannelContains(e.target.value)
|
||||
handleFilterChange()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UsersTable
|
||||
users={users}
|
||||
isLoading={isLoading}
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
pageSize={pageSize}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onPageChange={handlePageChange}
|
||||
onSortChange={handleSortChange}
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error mt-4">
|
||||
<span>Failed to load users. Please try again.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsersSettings
|
||||
197
src/Managing.WebApp/src/pages/adminPage/users/usersTable.tsx
Normal file
197
src/Managing.WebApp/src/pages/adminPage/users/usersTable.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
import React, {useMemo} from 'react'
|
||||
import {type UserListItemResponse, UserSortableColumn} from '../../../generated/ManagingApi'
|
||||
import {Table, Toast} from '../../../components/mollecules'
|
||||
|
||||
interface IUsersTable {
|
||||
users: UserListItemResponse[]
|
||||
isLoading: boolean
|
||||
totalCount: number
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
pageSize: number
|
||||
sortBy: UserSortableColumn
|
||||
sortOrder: string
|
||||
onPageChange: (page: number) => void
|
||||
onSortChange: (sortBy: UserSortableColumn) => void
|
||||
}
|
||||
|
||||
const UsersTable: React.FC<IUsersTable> = ({
|
||||
users,
|
||||
isLoading,
|
||||
totalCount,
|
||||
currentPage,
|
||||
totalPages,
|
||||
pageSize,
|
||||
sortBy,
|
||||
sortOrder,
|
||||
onPageChange,
|
||||
onSortChange
|
||||
}) => {
|
||||
const formatDate = (date: Date | string | null | undefined) => {
|
||||
if (!date) return '-'
|
||||
try {
|
||||
return new Date(date).toLocaleString()
|
||||
} catch {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text: string, label: string) => {
|
||||
const toast = new Toast(`Copying ${label} to clipboard...`)
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
toast.update('success', `${label} copied to clipboard!`)
|
||||
} catch (err) {
|
||||
toast.update('error', 'Failed to copy to clipboard')
|
||||
}
|
||||
}
|
||||
|
||||
const truncateAddress = (address: string | null | undefined) => {
|
||||
if (!address) return '-'
|
||||
if (address.length <= 12) return address
|
||||
return `${address.substring(0, 6)}...${address.substring(address.length - 4)}`
|
||||
}
|
||||
|
||||
const SortableHeader = ({ column, label }: { column: UserSortableColumn; label: string }) => {
|
||||
const isActive = sortBy === column
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-1 cursor-pointer hover:text-primary text-base-content"
|
||||
onClick={() => onSortChange(column)}
|
||||
>
|
||||
<span className="font-semibold">{label}</span>
|
||||
{isActive && (
|
||||
<span className="text-xs">
|
||||
{sortOrder === 'asc' ? '↑' : '↓'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
id: 'id',
|
||||
Header: () => <SortableHeader column={UserSortableColumn.Id} label="ID" />,
|
||||
accessor: (user: UserListItemResponse) => (
|
||||
<span className="font-mono text-sm">{user.id?.toString() || '-'}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
Header: () => <SortableHeader column={UserSortableColumn.Name} label="Name" />,
|
||||
accessor: (user: UserListItemResponse) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{user.name || '-'}</span>
|
||||
{user.name && (
|
||||
<button
|
||||
className="btn btn-ghost btn-xs"
|
||||
onClick={() => copyToClipboard(user.name!, 'Name')}
|
||||
title="Copy name"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-3 h-3">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 015.927-.184z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'agentName',
|
||||
Header: () => <SortableHeader column={UserSortableColumn.AgentName} label="Agent Name" />,
|
||||
accessor: (user: UserListItemResponse) => (
|
||||
<span className="text-sm">{user.agentName || '-'}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'ownerWalletAddress',
|
||||
Header: () => <SortableHeader column={UserSortableColumn.OwnerWalletAddress} label="Owner Address" />,
|
||||
accessor: (user: UserListItemResponse) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-mono text-xs">{truncateAddress(user.ownerWalletAddress)}</span>
|
||||
{user.ownerWalletAddress && (
|
||||
<button
|
||||
className="btn btn-ghost btn-xs"
|
||||
onClick={() => copyToClipboard(user.ownerWalletAddress!, 'Address')}
|
||||
title="Copy full address"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-3 h-3">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.666 3.888A2.25 2.25 0 0013.5 2.25h-3c-1.03 0-1.9.693-2.166 1.638m7.332 0c.055.194.084.4.084.612v0a.75.75 0 01-.75.75H9a.75.75 0 01-.75-.75v0c0-.212.03-.418.084-.612m7.332 0c.646.049 1.288.11 1.927.184 1.1.128 1.907 1.077 1.907 2.185V19.5a2.25 2.25 0 01-2.25 2.25H6.75A2.25 2.25 0 014.5 19.5V6.257c0-1.108.806-2.057 1.907-2.185a48.208 48.208 0 015.927-.184z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'telegramChannel',
|
||||
Header: 'Telegram Channel',
|
||||
accessor: (user: UserListItemResponse) => (
|
||||
<span className="text-sm">{user.telegramChannel || '-'}</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'isAdmin',
|
||||
Header: 'Admin',
|
||||
accessor: (user: UserListItemResponse) => (
|
||||
<span className={`badge badge-sm ${user.isAdmin ? 'badge-success' : 'badge-ghost'}`}>
|
||||
{user.isAdmin ? 'Admin' : 'User'}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'avatarUrl',
|
||||
Header: 'Avatar',
|
||||
accessor: (user: UserListItemResponse) => (
|
||||
user.avatarUrl ? (
|
||||
<div className="avatar">
|
||||
<div className="w-8 rounded-full">
|
||||
<img src={user.avatarUrl} alt="Avatar" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-sm text-base-content/50">-</span>
|
||||
)
|
||||
)
|
||||
},
|
||||
{
|
||||
id: 'lastConnectionDate',
|
||||
Header: 'Last Connection',
|
||||
accessor: (user: UserListItemResponse) => (
|
||||
<span className="text-xs text-base-content/70">
|
||||
{formatDate(user.lastConnectionDate)}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
], [sortBy, sortOrder, onSortChange])
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100 shadow-md">
|
||||
<div className="card-body">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="card-title text-lg">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" strokeWidth="1.5" stroke="currentColor" className="w-6 h-6">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z" />
|
||||
</svg>
|
||||
Users ({totalCount})
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<Table
|
||||
data={users}
|
||||
columns={columns}
|
||||
isLoading={isLoading}
|
||||
totalCount={totalCount}
|
||||
currentPage={currentPage}
|
||||
totalPages={totalPages}
|
||||
pageSize={pageSize}
|
||||
onPageChange={onPageChange}
|
||||
emptyMessage="No users found"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default UsersTable
|
||||
Reference in New Issue
Block a user