2026-04-11 17:04:14 +07:00
|
|
|
import { createFileRoute } from '@tanstack/react-router'
|
2025-12-10 08:41:30 +07:00
|
|
|
import {
|
|
|
|
|
FC,
|
|
|
|
|
ReactElement,
|
|
|
|
|
useState,
|
|
|
|
|
useEffect,
|
|
|
|
|
useMemo,
|
|
|
|
|
useCallback,
|
2026-04-11 17:04:14 +07:00
|
|
|
} from 'react'
|
|
|
|
|
import ModalUserDetail from './_components/hackathon-users/modal-user-detail'
|
2025-12-05 11:55:28 +07:00
|
|
|
import {
|
|
|
|
|
BackofficeWrapper,
|
|
|
|
|
DataTable,
|
2026-04-11 17:04:14 +07:00
|
|
|
} from '@imphnen-frontend-service/ui/organisms'
|
|
|
|
|
import { ColumnDef } from '@tanstack/react-table'
|
|
|
|
|
import { Button } from '@imphnen-frontend-service/ui/atoms'
|
|
|
|
|
import { cn } from '@imphnen-frontend-service/utils'
|
2025-12-05 11:55:28 +07:00
|
|
|
import {
|
|
|
|
|
EditOutlined,
|
|
|
|
|
UserOutlined,
|
|
|
|
|
SearchOutlined,
|
|
|
|
|
FilterOutlined,
|
|
|
|
|
PlusOutlined,
|
2025-12-10 08:41:30 +07:00
|
|
|
LoadingOutlined,
|
2026-04-11 17:04:14 +07:00
|
|
|
} from '@ant-design/icons'
|
|
|
|
|
import { CityFilterSelect } from '../../components/city-filter-select'
|
|
|
|
|
import { useQuery } from '@tanstack/react-query'
|
2025-12-10 08:41:30 +07:00
|
|
|
import {
|
|
|
|
|
getAdminUsers,
|
|
|
|
|
TAdminUserItem,
|
2026-04-11 17:04:14 +07:00
|
|
|
} from '@imphnen-frontend-service/service'
|
2026-04-11 20:49:55 +07:00
|
|
|
import { useNavigate } from '@tanstack/react-router'
|
2025-12-05 11:55:28 +07:00
|
|
|
|
2026-04-11 17:04:14 +07:00
|
|
|
type UserType = TAdminUserItem
|
2025-12-05 11:55:28 +07:00
|
|
|
|
|
|
|
|
const skillsOptions = [
|
|
|
|
|
'Frontend Developer',
|
|
|
|
|
'Backend Developer',
|
|
|
|
|
'Full Stack Developer',
|
|
|
|
|
'DevOps Engineer',
|
|
|
|
|
'UI/UX Designer',
|
|
|
|
|
'Product Manager',
|
|
|
|
|
'Data Scientist',
|
|
|
|
|
'Mobile Developer',
|
2026-04-11 17:04:14 +07:00
|
|
|
]
|
2025-12-05 11:55:28 +07:00
|
|
|
|
2026-04-11 17:04:14 +07:00
|
|
|
export const Route = createFileRoute('/_authenticated/hackathon-users')({
|
|
|
|
|
component: HackathonUsersPage,
|
|
|
|
|
validateSearch: (search: Record<string, unknown>) => ({
|
|
|
|
|
page: Number(search.page) || 1,
|
|
|
|
|
search: (search.search as string) || '',
|
|
|
|
|
per_page: Number(search.per_page) || 10,
|
|
|
|
|
}),
|
|
|
|
|
})
|
2025-12-05 11:55:28 +07:00
|
|
|
|
2026-04-11 17:04:14 +07:00
|
|
|
function HackathonUsersPage() {
|
|
|
|
|
const searchParams = Route.useSearch()
|
|
|
|
|
const navigate = useNavigate()
|
|
|
|
|
const currentPage = Math.max(1, searchParams.page)
|
|
|
|
|
const searchQuery = searchParams.search || ''
|
|
|
|
|
const perPage = searchParams.per_page || 10
|
|
|
|
|
const [showDetailModal, setShowDetailModal] = useState(false)
|
|
|
|
|
const [showNewUserModal, setShowNewUserModal] = useState(false)
|
|
|
|
|
const [selectedUser, setSelectedUser] = useState<UserType | null>(null)
|
|
|
|
|
const [globalFilter, setGlobalFilter] = useState(searchQuery)
|
|
|
|
|
|
|
|
|
|
const [statusFilter, setStatusFilter] = useState('all')
|
|
|
|
|
const [cityFilter, setCityFilter] = useState('all')
|
|
|
|
|
const [skillsFilter, setSkillsFilter] = useState<string[]>([])
|
2025-12-05 11:55:28 +07:00
|
|
|
|
2025-12-10 08:41:30 +07:00
|
|
|
const {
|
|
|
|
|
data: usersResponse,
|
|
|
|
|
isLoading,
|
|
|
|
|
isFetching,
|
|
|
|
|
} = useQuery({
|
|
|
|
|
queryKey: [
|
|
|
|
|
'admin-users',
|
|
|
|
|
currentPage,
|
|
|
|
|
perPage,
|
|
|
|
|
cityFilter,
|
|
|
|
|
statusFilter,
|
|
|
|
|
searchQuery,
|
|
|
|
|
],
|
|
|
|
|
queryFn: () =>
|
|
|
|
|
getAdminUsers({
|
|
|
|
|
page: currentPage,
|
|
|
|
|
per_page: perPage,
|
|
|
|
|
search: searchQuery || undefined,
|
|
|
|
|
}),
|
2026-03-31 01:44:17 +07:00
|
|
|
staleTime: 30000,
|
|
|
|
|
gcTime: 5 * 60 * 1000,
|
2026-04-11 17:04:14 +07:00
|
|
|
})
|
2025-12-10 08:41:30 +07:00
|
|
|
|
2026-04-11 17:04:14 +07:00
|
|
|
const totalData = usersResponse?.meta?.total_data || 0
|
|
|
|
|
const totalPages = usersResponse?.meta?.total_page || 1
|
2025-12-10 08:41:30 +07:00
|
|
|
|
|
|
|
|
const handlePageChange = useCallback(
|
|
|
|
|
(newPage: number) => {
|
2026-04-11 17:04:14 +07:00
|
|
|
navigate({
|
|
|
|
|
search: {
|
|
|
|
|
page: newPage,
|
|
|
|
|
per_page: perPage !== 10 ? perPage : undefined,
|
|
|
|
|
search: searchQuery || undefined,
|
|
|
|
|
} as any,
|
|
|
|
|
})
|
|
|
|
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
2025-12-10 08:41:30 +07:00
|
|
|
},
|
2026-04-11 17:04:14 +07:00
|
|
|
[navigate, perPage, searchQuery]
|
|
|
|
|
)
|
2025-12-10 08:41:30 +07:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
2026-04-11 17:04:14 +07:00
|
|
|
navigate({ search: { page: totalPages } as any })
|
2025-12-10 08:41:30 +07:00
|
|
|
}
|
2026-04-11 17:04:14 +07:00
|
|
|
}, [currentPage, totalPages, navigate, isLoading])
|
2025-12-10 08:41:30 +07:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
2026-04-11 17:04:14 +07:00
|
|
|
setGlobalFilter(searchQuery)
|
|
|
|
|
}, [searchQuery])
|
2025-12-10 08:41:30 +07:00
|
|
|
|
|
|
|
|
const handleSearch = useCallback(() => {
|
2026-04-11 17:04:14 +07:00
|
|
|
navigate({
|
|
|
|
|
search: {
|
|
|
|
|
page: 1,
|
|
|
|
|
per_page: perPage !== 10 ? perPage : undefined,
|
|
|
|
|
search: globalFilter.trim() || undefined,
|
|
|
|
|
} as any,
|
|
|
|
|
})
|
|
|
|
|
}, [globalFilter, navigate, perPage])
|
2025-12-10 08:41:30 +07:00
|
|
|
|
|
|
|
|
const handleSearchKeyPress = useCallback(
|
|
|
|
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
|
|
|
if (e.key === 'Enter') {
|
2026-04-11 17:04:14 +07:00
|
|
|
handleSearch()
|
2025-12-10 08:41:30 +07:00
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[handleSearch]
|
2026-04-11 17:04:14 +07:00
|
|
|
)
|
2025-12-10 08:41:30 +07:00
|
|
|
|
|
|
|
|
const handlePerPageChange = useCallback(
|
|
|
|
|
(newPerPage: number) => {
|
2026-04-11 17:04:14 +07:00
|
|
|
navigate({
|
|
|
|
|
search: {
|
|
|
|
|
page: 1,
|
|
|
|
|
per_page: newPerPage,
|
|
|
|
|
search: searchQuery || undefined,
|
|
|
|
|
} as any,
|
|
|
|
|
})
|
2025-12-10 08:41:30 +07:00
|
|
|
},
|
2026-04-11 17:04:14 +07:00
|
|
|
[navigate, searchQuery]
|
|
|
|
|
)
|
2025-12-05 11:55:28 +07:00
|
|
|
|
|
|
|
|
const handleShowDetailModal = useCallback((user: UserType) => {
|
2026-04-11 17:04:14 +07:00
|
|
|
setSelectedUser(user)
|
|
|
|
|
setShowDetailModal(true)
|
|
|
|
|
}, [])
|
2025-12-05 11:55:28 +07:00
|
|
|
|
|
|
|
|
const handleCloseDetailModal = useCallback(() => {
|
2026-04-11 17:04:14 +07:00
|
|
|
setShowDetailModal(false)
|
|
|
|
|
setSelectedUser(null)
|
|
|
|
|
}, [])
|
2025-12-05 11:55:28 +07:00
|
|
|
|
|
|
|
|
const handleShowNewUserModal = useCallback(() => {
|
2026-04-11 17:04:14 +07:00
|
|
|
setShowNewUserModal(true)
|
|
|
|
|
}, [])
|
2025-12-05 11:55:28 +07:00
|
|
|
|
|
|
|
|
const handleCloseNewUserModal = useCallback(() => {
|
2026-04-11 17:04:14 +07:00
|
|
|
setShowNewUserModal(false)
|
|
|
|
|
}, [])
|
2025-12-05 11:55:28 +07:00
|
|
|
|
|
|
|
|
const filteredData = useMemo(() => {
|
2026-04-11 18:11:07 +07:00
|
|
|
const usersData = usersResponse?.data?.data || usersResponse?.data || []
|
2025-12-10 08:41:30 +07:00
|
|
|
return usersData.filter((user: UserType) => {
|
2025-12-05 11:55:28 +07:00
|
|
|
if (statusFilter !== 'all') {
|
2026-04-11 17:04:14 +07:00
|
|
|
const isActive = statusFilter === 'active'
|
|
|
|
|
if (user.is_active !== isActive) return false
|
2025-12-05 11:55:28 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (skillsFilter.length > 0) {
|
2026-04-11 17:04:14 +07:00
|
|
|
const userSkills = user.skills || []
|
2025-12-05 11:55:28 +07:00
|
|
|
const hasMatchingSkill = skillsFilter.some((skill) =>
|
2025-12-10 08:41:30 +07:00
|
|
|
userSkills.includes(skill)
|
2026-04-11 17:04:14 +07:00
|
|
|
)
|
|
|
|
|
if (!hasMatchingSkill) return false
|
2025-12-05 11:55:28 +07:00
|
|
|
}
|
|
|
|
|
|
2026-04-11 17:04:14 +07:00
|
|
|
return true
|
|
|
|
|
})
|
|
|
|
|
}, [usersResponse, statusFilter, skillsFilter])
|
2025-12-05 11:55:28 +07:00
|
|
|
|
|
|
|
|
const columns: ColumnDef<UserType>[] = useMemo(
|
|
|
|
|
() => [
|
|
|
|
|
{
|
|
|
|
|
accessorKey: 'fullname',
|
|
|
|
|
header: 'User',
|
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden shrink-0">
|
|
|
|
|
{row.original.avatar ? (
|
|
|
|
|
<img
|
|
|
|
|
src={row.original.avatar}
|
|
|
|
|
alt={row.original.fullname}
|
|
|
|
|
className="w-full h-full object-cover"
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<UserOutlined className="text-neutral-500 text-lg" />
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="min-w-0 flex-1">
|
|
|
|
|
<p className="font-medium text-neutral-900 truncate">
|
|
|
|
|
{row.original.fullname}
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
),
|
|
|
|
|
enableSorting: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
accessorKey: 'skills',
|
|
|
|
|
header: 'Skills',
|
2025-12-10 08:41:30 +07:00
|
|
|
cell: ({ row }) => {
|
2026-04-11 17:04:14 +07:00
|
|
|
const skills = row.original.skills || []
|
2025-12-10 08:41:30 +07:00
|
|
|
return (
|
|
|
|
|
<div className="flex flex-wrap gap-1 max-w-xs">
|
|
|
|
|
{skills.length > 0 ? (
|
|
|
|
|
<>
|
|
|
|
|
{skills.slice(0, 2).map((skill, index) => (
|
|
|
|
|
<span
|
|
|
|
|
key={index}
|
|
|
|
|
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
|
|
|
|
|
>
|
|
|
|
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
{skills.length > 2 && (
|
|
|
|
|
<span className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-200 text-success-700">
|
|
|
|
|
+{skills.length - 2}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<span className="text-neutral-400">-</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2026-04-11 17:04:14 +07:00
|
|
|
)
|
2025-12-10 08:41:30 +07:00
|
|
|
},
|
2025-12-05 11:55:28 +07:00
|
|
|
enableSorting: false,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
accessorKey: 'location',
|
|
|
|
|
header: 'Location',
|
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
<span className="text-neutral-700">{row.original.location}</span>
|
|
|
|
|
),
|
|
|
|
|
enableSorting: true,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
accessorKey: 'is_active',
|
|
|
|
|
header: 'Status',
|
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
'w-2 h-2 rounded-full',
|
|
|
|
|
row.original.is_active ? 'bg-success-500' : 'bg-neutral-400'
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
<span
|
|
|
|
|
className={cn(
|
|
|
|
|
'text-sm font-medium',
|
|
|
|
|
row.original.is_active ? 'text-success-700' : 'text-neutral-500'
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
{row.original.is_active ? 'Active' : 'Inactive'}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
),
|
|
|
|
|
enableSorting: true,
|
|
|
|
|
sortingFn: (rowA, rowB) => {
|
2026-04-11 17:04:14 +07:00
|
|
|
const aActive = rowA.original.is_active
|
|
|
|
|
const bActive = rowB.original.is_active
|
|
|
|
|
if (aActive && !bActive) return -1
|
|
|
|
|
if (!aActive && bActive) return 1
|
|
|
|
|
return 0
|
2025-12-05 11:55:28 +07:00
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
accessorKey: 'created_at',
|
|
|
|
|
header: 'Joined',
|
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
<span className="text-neutral-900 text-sm">
|
|
|
|
|
{new Date(row.original.created_at).toLocaleDateString('en-US', {
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
month: 'short',
|
|
|
|
|
day: 'numeric',
|
|
|
|
|
})}
|
|
|
|
|
</span>
|
|
|
|
|
),
|
|
|
|
|
enableSorting: true,
|
|
|
|
|
sortingFn: 'datetime',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: 'actions',
|
|
|
|
|
header: 'Actions',
|
|
|
|
|
meta: { cellClassName: cn('w-48') },
|
|
|
|
|
cell: ({ row }) => (
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
variant="primary"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="flex items-center gap-2 text-sm px-4 py-2"
|
|
|
|
|
onClick={() => handleShowDetailModal(row.original)}
|
|
|
|
|
>
|
|
|
|
|
<EditOutlined className="text-sm" />
|
|
|
|
|
Manage
|
|
|
|
|
</Button>
|
2026-03-31 01:44:17 +07:00
|
|
|
{
|
|
|
|
|
}
|
2025-12-05 11:55:28 +07:00
|
|
|
</div>
|
|
|
|
|
),
|
|
|
|
|
enableSorting: false,
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
[handleShowDetailModal]
|
2026-04-11 17:04:14 +07:00
|
|
|
)
|
2025-12-05 11:55:28 +07:00
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
|
|
|
|
<h1 className="mb-8 text-p1 font-semibold text-neutral-700">
|
|
|
|
|
User Management
|
|
|
|
|
</h1>
|
|
|
|
|
<section className="bg-white rounded-md shadow p-8 flex flex-col gap-6">
|
|
|
|
|
<div className="flex flex-wrap gap-3 items-center justify-between">
|
|
|
|
|
<div className="flex flex-wrap gap-3 items-center">
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<SearchOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm" />
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
|
|
|
|
|
placeholder="Search users by name or location..."
|
|
|
|
|
value={globalFilter}
|
|
|
|
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
2025-12-10 08:41:30 +07:00
|
|
|
onKeyPress={handleSearchKeyPress}
|
2025-12-05 11:55:28 +07:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="relative">
|
2025-12-10 08:41:30 +07:00
|
|
|
<select
|
|
|
|
|
className="border border-neutral-200 rounded-lg px-4 py-2.5 text-sm w-28 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
|
|
|
|
value={perPage}
|
|
|
|
|
onChange={(e) =>
|
|
|
|
|
handlePerPageChange(parseInt(e.target.value, 10))
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<option value={10}>10 / page</option>
|
|
|
|
|
<option value={20}>20 / page</option>
|
|
|
|
|
<option value={50}>50 / page</option>
|
|
|
|
|
<option value={100}>100 / page</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-03-31 01:44:17 +07:00
|
|
|
{}
|
|
|
|
|
{
|
|
|
|
|
}
|
2025-12-05 11:55:28 +07:00
|
|
|
|
2026-03-31 01:44:17 +07:00
|
|
|
{}
|
|
|
|
|
{
|
|
|
|
|
}
|
2025-12-05 11:55:28 +07:00
|
|
|
|
2026-03-31 01:44:17 +07:00
|
|
|
{}
|
|
|
|
|
{
|
|
|
|
|
}
|
2025-12-05 11:55:28 +07:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-3">
|
|
|
|
|
<Button
|
|
|
|
|
variant="primary"
|
|
|
|
|
size="md"
|
|
|
|
|
className="flex items-center gap-2 px-4 py-2"
|
|
|
|
|
onClick={handleShowNewUserModal}
|
|
|
|
|
>
|
|
|
|
|
<PlusOutlined className="text-sm" />
|
|
|
|
|
Add User
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{(skillsFilter.length > 0 ||
|
|
|
|
|
statusFilter !== 'all' ||
|
2025-12-10 08:41:30 +07:00
|
|
|
cityFilter !== 'all') && (
|
2025-12-05 11:55:28 +07:00
|
|
|
<div className="flex flex-wrap gap-2 items-center">
|
|
|
|
|
<span className="text-sm text-neutral-600">Active filters:</span>
|
|
|
|
|
|
|
|
|
|
{statusFilter !== 'all' && (
|
|
|
|
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-info-100 text-info-800 rounded-2xl text-sm">
|
|
|
|
|
Status: {statusFilter}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() => setStatusFilter('all')}
|
|
|
|
|
className="text-info-600 hover:text-info-800 cursor-pointer"
|
|
|
|
|
>
|
|
|
|
|
✕
|
|
|
|
|
</button>
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-12-10 08:41:30 +07:00
|
|
|
{cityFilter !== 'all' && (
|
2025-12-05 11:55:28 +07:00
|
|
|
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-800 rounded-2xl text-sm">
|
2025-12-10 08:41:30 +07:00
|
|
|
City: {cityFilter}
|
2025-12-05 11:55:28 +07:00
|
|
|
<button
|
2025-12-10 08:41:30 +07:00
|
|
|
onClick={() => setCityFilter('all')}
|
2025-12-05 11:55:28 +07:00
|
|
|
className="text-green-600 hover:text-green-800 cursor-pointer"
|
|
|
|
|
>
|
|
|
|
|
✕
|
|
|
|
|
</button>
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{skillsFilter.map((skill) => (
|
|
|
|
|
<span
|
|
|
|
|
key={skill}
|
|
|
|
|
className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm"
|
|
|
|
|
>
|
|
|
|
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
|
|
|
|
<button
|
|
|
|
|
onClick={() =>
|
|
|
|
|
setSkillsFilter((prev) => prev.filter((s) => s !== skill))
|
|
|
|
|
}
|
|
|
|
|
className="text-purple-600 hover:text-purple-800 cursor-pointer"
|
|
|
|
|
>
|
|
|
|
|
✕
|
|
|
|
|
</button>
|
|
|
|
|
</span>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="secondary"
|
|
|
|
|
size="sm"
|
|
|
|
|
onClick={() => {
|
2026-04-11 17:04:14 +07:00
|
|
|
setStatusFilter('all')
|
|
|
|
|
setCityFilter('all')
|
|
|
|
|
setSkillsFilter([])
|
|
|
|
|
setGlobalFilter('')
|
2025-12-05 11:55:28 +07:00
|
|
|
}}
|
|
|
|
|
className="text-sm text-neutral-600"
|
|
|
|
|
>
|
|
|
|
|
Clear All
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-12-10 08:41:30 +07:00
|
|
|
{isLoading ? (
|
|
|
|
|
<div className="flex items-center justify-center py-12">
|
|
|
|
|
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
|
|
|
|
<span className="ml-3 text-neutral-600">Loading users...</span>
|
|
|
|
|
</div>
|
|
|
|
|
) : filteredData.length > 0 ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="text-sm text-neutral-600">
|
|
|
|
|
Showing {filteredData.length} of {totalData} users (Page{' '}
|
|
|
|
|
{currentPage} of {totalPages})
|
|
|
|
|
{isFetching && (
|
|
|
|
|
<span className="ml-2 text-primary-500">(Updating...)</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<DataTable
|
|
|
|
|
data={filteredData}
|
|
|
|
|
columns={columns}
|
|
|
|
|
pageSize={perPage}
|
|
|
|
|
manualPagination={true}
|
|
|
|
|
pageCount={totalPages}
|
|
|
|
|
currentPage={currentPage}
|
|
|
|
|
onPageChange={handlePageChange}
|
|
|
|
|
/>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="text-center py-12 text-neutral-500">
|
|
|
|
|
No users found. Try adjusting your filters.
|
2025-12-05 11:55:28 +07:00
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<ModalUserDetail
|
|
|
|
|
isOpen={showDetailModal}
|
|
|
|
|
onClose={handleCloseDetailModal}
|
|
|
|
|
user={selectedUser}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<ModalUserDetail
|
|
|
|
|
isOpen={showNewUserModal}
|
|
|
|
|
onClose={handleCloseNewUserModal}
|
2026-03-31 01:44:17 +07:00
|
|
|
user={null}
|
2025-12-05 11:55:28 +07:00
|
|
|
/>
|
|
|
|
|
</BackofficeWrapper>
|
2026-04-11 17:04:14 +07:00
|
|
|
)
|
|
|
|
|
}
|