feat(backoffice): users page integration
- Get users data from API - Set up server-side pagination and match URL params - Hide filter that doesn't exist in back-end
This commit is contained in:
@@ -11,24 +11,24 @@ export const HackathonDashboardPage: FC = (): ReactElement => {
|
|||||||
// Fetch total participants
|
// Fetch total participants
|
||||||
const { data: usersData } = useQuery({
|
const { data: usersData } = useQuery({
|
||||||
queryKey: ['admin-users-count'],
|
queryKey: ['admin-users-count'],
|
||||||
queryFn: () => getAdminUsers({ page: 1, limit: 1 }),
|
queryFn: () => getAdminUsers({ page: 1, per_page: 1 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch total teams
|
// Fetch total teams
|
||||||
const { data: teamsData } = useQuery({
|
const { data: teamsData } = useQuery({
|
||||||
queryKey: ['admin-teams-count'],
|
queryKey: ['admin-teams-count'],
|
||||||
queryFn: () => getAdminTeams({ page: 1, limit: 1 }),
|
queryFn: () => getAdminTeams({ page: 1, per_page: 1 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fetch total submissions
|
// Fetch total submissions
|
||||||
const { data: submissionsData } = useQuery({
|
const { data: submissionsData } = useQuery({
|
||||||
queryKey: ['admin-submissions-count'],
|
queryKey: ['admin-submissions-count'],
|
||||||
queryFn: () => getAdminSubmissions({ page: 1, limit: 1 }),
|
queryFn: () => getAdminSubmissions({ page: 1, per_page: 1 }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const totalParticipants = usersData?.meta?.total_data ?? 0;
|
const totalParticipants = usersData?.meta?.total_data ?? '??';
|
||||||
const totalTeams = teamsData?.meta?.total_data ?? 0;
|
const totalTeams = teamsData?.meta?.total_data ?? '??';
|
||||||
const totalSubmissions = submissionsData?.meta?.total_data ?? 0;
|
const totalSubmissions = submissionsData?.meta?.total_data ?? '??';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
<BackofficeWrapper title="IMPHNEN x Kolosal.ai Hackathon 2025">
|
||||||
|
|||||||
+14
-10
@@ -15,10 +15,10 @@ import {
|
|||||||
|
|
||||||
interface UserType {
|
interface UserType {
|
||||||
id: string;
|
id: string;
|
||||||
avatar?: string;
|
avatar?: string | null;
|
||||||
fullname: string;
|
fullname: string;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
location: string;
|
location: string | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
skills: string[];
|
skills: string[];
|
||||||
created_at: string;
|
created_at: string;
|
||||||
@@ -77,7 +77,7 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
// Check if required fields are filled
|
// Check if required fields are filled
|
||||||
const isFormValid = useMemo(() => {
|
const isFormValid = useMemo(() => {
|
||||||
if (!formData) return false;
|
if (!formData) return false;
|
||||||
return formData.fullname.trim() !== '' && formData.location.trim() !== '';
|
return formData.fullname?.trim() !== '' && formData.location?.trim() !== '';
|
||||||
}, [formData]);
|
}, [formData]);
|
||||||
|
|
||||||
const canSave = hasChanges && isFormValid;
|
const canSave = hasChanges && isFormValid;
|
||||||
@@ -259,13 +259,13 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowAvatarMenu(false);
|
setShowAvatarMenu(false);
|
||||||
handleCancel();
|
handleCancel();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<CloseOutlined className="text-neutral-400 text-lg cursor-pointer" />
|
<CloseOutlined className="text-neutral-400 text-lg" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -294,13 +294,15 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none',
|
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none',
|
||||||
formData.fullname.trim() === ''
|
!formData.fullname ||
|
||||||
|
formData.fullname.trim() === ''
|
||||||
? 'border-red-300 bg-red-50'
|
? 'border-red-300 bg-red-50'
|
||||||
: 'border-neutral-300'
|
: 'border-neutral-300'
|
||||||
)}
|
)}
|
||||||
placeholder="Enter full name"
|
placeholder="Enter full name"
|
||||||
/>
|
/>
|
||||||
{formData.fullname.trim() === '' && (
|
{(!formData.fullname ||
|
||||||
|
formData.fullname.trim() === '') && (
|
||||||
<p className="text-red-500 text-xs mt-1">
|
<p className="text-red-500 text-xs mt-1">
|
||||||
Full name is required
|
Full name is required
|
||||||
</p>
|
</p>
|
||||||
@@ -316,13 +318,14 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
Location <span className="text-red-500">*</span>
|
Location <span className="text-red-500">*</span>
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.location}
|
value={formData.location || ''}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
handleInputChange('location', e.target.value)
|
handleInputChange('location', e.target.value)
|
||||||
}
|
}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white',
|
'w-full border rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none bg-white',
|
||||||
formData.location.trim() === ''
|
!formData.location ||
|
||||||
|
formData.location.trim() === ''
|
||||||
? 'border-red-300 bg-red-50'
|
? 'border-red-300 bg-red-50'
|
||||||
: 'border-neutral-300'
|
: 'border-neutral-300'
|
||||||
)}
|
)}
|
||||||
@@ -334,7 +337,8 @@ const ModalUserDetail: FC<ModalProps> = ({ isOpen, onClose, user }) => {
|
|||||||
<option value="Medan">Medan</option>
|
<option value="Medan">Medan</option>
|
||||||
<option value="Yogyakarta">Yogyakarta</option>
|
<option value="Yogyakarta">Yogyakarta</option>
|
||||||
</select>
|
</select>
|
||||||
{formData.location.trim() === '' && (
|
{(!formData.location ||
|
||||||
|
formData.location.trim() === '') && (
|
||||||
<p className="text-red-500 text-xs mt-1">
|
<p className="text-red-500 text-xs mt-1">
|
||||||
Location is required
|
Location is required
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { FC, ReactElement, useState, useMemo, useCallback } from 'react';
|
import {
|
||||||
|
FC,
|
||||||
|
ReactElement,
|
||||||
|
useState,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
} from 'react';
|
||||||
import ModalUserDetail from './_components/modal-user-detail';
|
import ModalUserDetail from './_components/modal-user-detail';
|
||||||
import {
|
import {
|
||||||
BackofficeWrapper,
|
BackofficeWrapper,
|
||||||
@@ -13,23 +20,19 @@ import {
|
|||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
FilterOutlined,
|
FilterOutlined,
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
|
LoadingOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { CityFilterSelect } from '../../../components/city-filter-select';
|
import { CityFilterSelect } from '../../../components/city-filter-select';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
getAdminUsers,
|
||||||
|
TAdminUserItem,
|
||||||
|
} from '@imphnen-frontend-service/service';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
// Define interface outside component
|
type UserType = TAdminUserItem;
|
||||||
interface UserType {
|
|
||||||
id: string; // UUID
|
|
||||||
avatar?: string;
|
|
||||||
fullname: string;
|
|
||||||
bio?: string;
|
|
||||||
location: string;
|
|
||||||
is_active: boolean; // admin can deactivate
|
|
||||||
skills: string[]; // Frontend Developer, Backend Developer, etc.
|
|
||||||
created_at: string;
|
|
||||||
updated_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move mock data outside component to prevent recreation
|
// Skills options for filter
|
||||||
const skillsOptions = [
|
const skillsOptions = [
|
||||||
'Frontend Developer',
|
'Frontend Developer',
|
||||||
'Backend Developer',
|
'Backend Developer',
|
||||||
@@ -41,59 +44,108 @@ const skillsOptions = [
|
|||||||
'Mobile Developer',
|
'Mobile Developer',
|
||||||
];
|
];
|
||||||
|
|
||||||
const locations = ['Jakarta', 'Bandung', 'Surabaya', 'Medan', 'Yogyakarta'];
|
|
||||||
const bios = [
|
|
||||||
'Passionate developer with 5+ years experience',
|
|
||||||
'Tech enthusiast and problem solver',
|
|
||||||
'Building scalable solutions for modern problems',
|
|
||||||
'Creative designer with technical background',
|
|
||||||
'Data-driven decision maker',
|
|
||||||
];
|
|
||||||
|
|
||||||
const mockData: UserType[] = Array.from({ length: 50 }, (_, i) => {
|
|
||||||
const randomSkillsCount = Math.floor(Math.random() * 3) + 1; // 1-3 skills
|
|
||||||
const randomSkills = skillsOptions
|
|
||||||
.sort(() => 0.5 - Math.random())
|
|
||||||
.slice(0, randomSkillsCount);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: `24db9e4d-ca4c-46aa-ac36-8ef04bbe01${String(i).padStart(2, '0')}`,
|
|
||||||
avatar:
|
|
||||||
i % 4 === 0
|
|
||||||
? `https://ui-avatars.com/api/?name=${encodeURIComponent(
|
|
||||||
i % 3 === 0 ? 'Ahmad Wijuana' : 'Sofia Wijuana'
|
|
||||||
)}&background=random`
|
|
||||||
: undefined,
|
|
||||||
fullname:
|
|
||||||
i % 3 === 0
|
|
||||||
? 'Ahmad Wijuana'
|
|
||||||
: i % 3 === 1
|
|
||||||
? 'Sofia Wijuana'
|
|
||||||
: 'Budi Santoso',
|
|
||||||
bio: i % 4 === 0 ? bios[i % bios.length] : undefined,
|
|
||||||
location: locations[i % locations.length],
|
|
||||||
is_active: i % 7 !== 0, // More realistic distribution
|
|
||||||
skills: randomSkills,
|
|
||||||
created_at: new Date(
|
|
||||||
Date.now() - i * 86400000 * (Math.random() * 30 + 1)
|
|
||||||
).toISOString(), // Random within last 30-60 days
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
export const HackathonUsersPage: FC = (): ReactElement => {
|
export const HackathonUsersPage: FC = (): ReactElement => {
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
const currentPage = Math.max(
|
||||||
|
1,
|
||||||
|
parseInt(searchParams.get('page') || '1', 10)
|
||||||
|
);
|
||||||
|
const searchQuery = searchParams.get('search') || '';
|
||||||
|
const perPage = parseInt(searchParams.get('per_page') || '10', 10);
|
||||||
const [showDetailModal, setShowDetailModal] = useState(false);
|
const [showDetailModal, setShowDetailModal] = useState(false);
|
||||||
const [showNewUserModal, setShowNewUserModal] = useState(false);
|
const [showNewUserModal, setShowNewUserModal] = useState(false);
|
||||||
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
|
const [selectedUser, setSelectedUser] = useState<UserType | null>(null);
|
||||||
const [globalFilter, setGlobalFilter] = useState('');
|
const [globalFilter, setGlobalFilter] = useState(searchQuery);
|
||||||
|
|
||||||
// Advanced filtering states
|
// Advanced filtering states
|
||||||
const [statusFilter, setStatusFilter] = useState('all');
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
const [cityFilter, setCityFilter] = useState('all');
|
const [cityFilter, setCityFilter] = useState('all');
|
||||||
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
|
const [skillsFilter, setSkillsFilter] = useState<string[]>([]);
|
||||||
|
|
||||||
// Constants
|
// Fetch users from API
|
||||||
const pageSize = 10;
|
const {
|
||||||
|
data: usersResponse,
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: [
|
||||||
|
'admin-users',
|
||||||
|
currentPage,
|
||||||
|
perPage,
|
||||||
|
cityFilter,
|
||||||
|
statusFilter,
|
||||||
|
searchQuery,
|
||||||
|
],
|
||||||
|
queryFn: () =>
|
||||||
|
getAdminUsers({
|
||||||
|
page: currentPage,
|
||||||
|
per_page: perPage,
|
||||||
|
search: searchQuery || undefined,
|
||||||
|
}),
|
||||||
|
staleTime: 30000, // 30 seconds cache
|
||||||
|
gcTime: 5 * 60 * 1000, // 5 minutes
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalData = usersResponse?.meta?.total_data || 0;
|
||||||
|
const totalPages = usersResponse?.meta?.total_page || 1;
|
||||||
|
|
||||||
|
// Handle page change - update URL query params
|
||||||
|
const handlePageChange = useCallback(
|
||||||
|
(newPage: number) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', newPage.toString());
|
||||||
|
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||||
|
if (searchQuery) params.set('search', searchQuery);
|
||||||
|
setSearchParams(params);
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||||
|
},
|
||||||
|
[setSearchParams, perPage, searchQuery]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Validate page number doesn't exceed total pages
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoading && totalPages > 0 && currentPage > totalPages) {
|
||||||
|
setSearchParams({ page: totalPages.toString() });
|
||||||
|
}
|
||||||
|
}, [currentPage, totalPages, setSearchParams, isLoading]);
|
||||||
|
|
||||||
|
// Sync globalFilter with URL search param on mount
|
||||||
|
useEffect(() => {
|
||||||
|
setGlobalFilter(searchQuery);
|
||||||
|
}, [searchQuery]);
|
||||||
|
|
||||||
|
// Handle search users
|
||||||
|
const handleSearch = useCallback(() => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', '1');
|
||||||
|
if (perPage !== 10) params.set('per_page', perPage.toString());
|
||||||
|
if (globalFilter.trim()) {
|
||||||
|
params.set('search', globalFilter.trim());
|
||||||
|
}
|
||||||
|
setSearchParams(params);
|
||||||
|
}, [globalFilter, setSearchParams, perPage]);
|
||||||
|
|
||||||
|
// Handle Enter key press in search input
|
||||||
|
const handleSearchKeyPress = useCallback(
|
||||||
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleSearch();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[handleSearch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Handle per page change
|
||||||
|
const handlePerPageChange = useCallback(
|
||||||
|
(newPerPage: number) => {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set('page', '1');
|
||||||
|
params.set('per_page', newPerPage.toString());
|
||||||
|
if (searchQuery) params.set('search', searchQuery);
|
||||||
|
setSearchParams(params);
|
||||||
|
},
|
||||||
|
[setSearchParams, searchQuery]
|
||||||
|
);
|
||||||
|
|
||||||
// Memoize the callback to prevent recreation
|
// Memoize the callback to prevent recreation
|
||||||
const handleShowDetailModal = useCallback((user: UserType) => {
|
const handleShowDetailModal = useCallback((user: UserType) => {
|
||||||
@@ -116,29 +168,26 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
|
|
||||||
// Filter data based on current filter states
|
// Filter data based on current filter states
|
||||||
const filteredData = useMemo(() => {
|
const filteredData = useMemo(() => {
|
||||||
return mockData.filter((user) => {
|
const usersData = usersResponse?.data || [];
|
||||||
|
return usersData.filter((user: UserType) => {
|
||||||
// Status filter
|
// Status filter
|
||||||
if (statusFilter !== 'all') {
|
if (statusFilter !== 'all') {
|
||||||
const isActive = statusFilter === 'active';
|
const isActive = statusFilter === 'active';
|
||||||
if (user.is_active !== isActive) return false;
|
if (user.is_active !== isActive) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
// City filter
|
|
||||||
if (cityFilter !== 'all' && user.location !== cityFilter) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skills filter
|
// Skills filter
|
||||||
if (skillsFilter.length > 0) {
|
if (skillsFilter.length > 0) {
|
||||||
|
const userSkills = user.skills || [];
|
||||||
const hasMatchingSkill = skillsFilter.some((skill) =>
|
const hasMatchingSkill = skillsFilter.some((skill) =>
|
||||||
user.skills.includes(skill)
|
userSkills.includes(skill)
|
||||||
);
|
);
|
||||||
if (!hasMatchingSkill) return false;
|
if (!hasMatchingSkill) return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}, [statusFilter, cityFilter, skillsFilter]);
|
}, [usersResponse, statusFilter, skillsFilter]);
|
||||||
|
|
||||||
// Memoize columns to prevent recreation on every render
|
// Memoize columns to prevent recreation on every render
|
||||||
const columns: ColumnDef<UserType>[] = useMemo(
|
const columns: ColumnDef<UserType>[] = useMemo(
|
||||||
@@ -173,23 +222,32 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
{
|
{
|
||||||
accessorKey: 'skills',
|
accessorKey: 'skills',
|
||||||
header: 'Skills',
|
header: 'Skills',
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => {
|
||||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
const skills = row.original.skills || [];
|
||||||
{row.original.skills.slice(0, 2).map((skill, index) => (
|
return (
|
||||||
<span
|
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||||
key={index}
|
{skills.length > 0 ? (
|
||||||
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
|
<>
|
||||||
>
|
{skills.slice(0, 2).map((skill, index) => (
|
||||||
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
<span
|
||||||
</span>
|
key={index}
|
||||||
))}
|
className="inline-flex items-center px-2 py-1 rounded-2xl text-xs font-medium bg-success-100 text-success-800"
|
||||||
{row.original.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">
|
{skill.replace(' Developer', '').replace(' Engineer', '')}
|
||||||
+{row.original.skills.length - 2}
|
</span>
|
||||||
</span>
|
))}
|
||||||
)}
|
{skills.length > 2 && (
|
||||||
</div>
|
<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>
|
||||||
|
);
|
||||||
|
},
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -260,7 +318,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
<EditOutlined className="text-sm" />
|
<EditOutlined className="text-sm" />
|
||||||
Manage
|
Manage
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
{/* <Button
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
size="sm"
|
size="sm"
|
||||||
className="text-sm px-4 py-2"
|
className="text-sm px-4 py-2"
|
||||||
@@ -270,7 +328,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{row.original.is_active ? 'Deactivate' : 'Activate'}
|
{row.original.is_active ? 'Deactivate' : 'Activate'}
|
||||||
</Button>
|
</Button> */}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
@@ -298,11 +356,28 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
placeholder="Search users by name or location..."
|
placeholder="Search users by name or location..."
|
||||||
value={globalFilter}
|
value={globalFilter}
|
||||||
onChange={(e) => setGlobalFilter(e.target.value)}
|
onChange={(e) => setGlobalFilter(e.target.value)}
|
||||||
|
onKeyPress={handleSearchKeyPress}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Status Filter */}
|
{/* Per Page Dropdown */}
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
|
<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>
|
||||||
|
|
||||||
|
{/* Status Filter */}
|
||||||
|
{/* <div className="relative">
|
||||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
<select
|
<select
|
||||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-36 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
@@ -313,10 +388,10 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
<option value="active">Active</option>
|
<option value="active">Active</option>
|
||||||
<option value="inactive">Inactive</option>
|
<option value="inactive">Inactive</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div> */}
|
||||||
|
|
||||||
{/* City Filter */}
|
{/* City Filter */}
|
||||||
<CityFilterSelect
|
{/* <CityFilterSelect
|
||||||
value={cityFilter}
|
value={cityFilter}
|
||||||
onChange={setCityFilter}
|
onChange={setCityFilter}
|
||||||
className="w-full sm:w-44"
|
className="w-full sm:w-44"
|
||||||
@@ -333,10 +408,10 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)} */}
|
||||||
|
|
||||||
{/* Skills Filter with Icon */}
|
{/* Skills Filter with Icon */}
|
||||||
<div className="relative">
|
{/* <div className="relative">
|
||||||
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
<FilterOutlined className="absolute left-3 top-1/2 transform -translate-y-1/2 text-neutral-400 text-sm pointer-events-none z-10" />
|
||||||
<select
|
<select
|
||||||
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-44 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
|
||||||
@@ -361,7 +436,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Add User Button */}
|
{/* Right side - Add User Button */}
|
||||||
@@ -446,17 +521,36 @@ export const HackathonUsersPage: FC = (): ReactElement => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Pagination-aware results display */}
|
{/* Loading & results display */}
|
||||||
{filteredData.length > 0 && (
|
{isLoading ? (
|
||||||
<div className="text-sm text-neutral-600">
|
<div className="flex items-center justify-center py-12">
|
||||||
Showing {Math.min(pageSize, filteredData.length)} of{' '}
|
<LoadingOutlined className="text-3xl text-primary-500 animate-spin" />
|
||||||
{filteredData.length} users
|
<span className="ml-3 text-neutral-600">Loading users...</span>
|
||||||
{filteredData.length > pageSize}
|
</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.
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<DataTable data={filteredData} columns={columns} pageSize={10} />
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Modals component */}
|
{/* Modals component */}
|
||||||
|
|||||||
@@ -10,13 +10,18 @@ const ADMIN_BASE_URL = '/admin';
|
|||||||
// Admin Users
|
// Admin Users
|
||||||
export const getAdminUsers = async (params?: {
|
export const getAdminUsers = async (params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
per_page?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
city?: string;
|
is_admin?: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const response = await api.get<TAdminUsersResponse>(
|
const response = await api.get<TAdminUsersResponse>(
|
||||||
`${ADMIN_BASE_URL}/users`,
|
`${ADMIN_BASE_URL}/users`,
|
||||||
{ params }
|
{
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
is_admin: params?.is_admin ?? false,
|
||||||
|
},
|
||||||
|
}
|
||||||
);
|
);
|
||||||
return response.data;
|
return response.data;
|
||||||
};
|
};
|
||||||
@@ -24,10 +29,8 @@ export const getAdminUsers = async (params?: {
|
|||||||
// Admin Teams
|
// Admin Teams
|
||||||
export const getAdminTeams = async (params?: {
|
export const getAdminTeams = async (params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
per_page?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
city?: string;
|
|
||||||
visibility?: string;
|
|
||||||
}) => {
|
}) => {
|
||||||
const response = await api.get<TAdminTeamsResponse>(
|
const response = await api.get<TAdminTeamsResponse>(
|
||||||
`${ADMIN_BASE_URL}/teams`,
|
`${ADMIN_BASE_URL}/teams`,
|
||||||
@@ -39,7 +42,7 @@ export const getAdminTeams = async (params?: {
|
|||||||
// Admin Submissions
|
// Admin Submissions
|
||||||
export const getAdminSubmissions = async (params?: {
|
export const getAdminSubmissions = async (params?: {
|
||||||
page?: number;
|
page?: number;
|
||||||
limit?: number;
|
per_page?: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
status?: string;
|
status?: string;
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -23,6 +23,11 @@ interface DataTableProps<T extends RowData> {
|
|||||||
columns?: ColumnDef<T, unknown>[];
|
columns?: ColumnDef<T, unknown>[];
|
||||||
pageSize?: number;
|
pageSize?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
|
// server-side pagination props
|
||||||
|
manualPagination?: boolean;
|
||||||
|
pageCount?: number;
|
||||||
|
currentPage?: number;
|
||||||
|
onPageChange?: (page: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DataTable = <T extends RowData>({
|
export const DataTable = <T extends RowData>({
|
||||||
@@ -31,6 +36,10 @@ export const DataTable = <T extends RowData>({
|
|||||||
columns = [],
|
columns = [],
|
||||||
pageSize = 9,
|
pageSize = 9,
|
||||||
className,
|
className,
|
||||||
|
manualPagination = false,
|
||||||
|
pageCount,
|
||||||
|
currentPage = 1,
|
||||||
|
onPageChange,
|
||||||
}: DataTableProps<T>) => {
|
}: DataTableProps<T>) => {
|
||||||
const [pagination, setPagination] = React.useState<PaginationState>({
|
const [pagination, setPagination] = React.useState<PaginationState>({
|
||||||
pageIndex: 0,
|
pageIndex: 0,
|
||||||
@@ -75,10 +84,20 @@ export const DataTable = <T extends RowData>({
|
|||||||
getPaginationRowModel: getPaginationRowModel(),
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
getSortedRowModel: getSortedRowModel(),
|
getSortedRowModel: getSortedRowModel(),
|
||||||
getFilteredRowModel: getFilteredRowModel(),
|
getFilteredRowModel: getFilteredRowModel(),
|
||||||
|
// server-side pagination config
|
||||||
|
manualPagination,
|
||||||
|
pageCount: manualPagination ? pageCount : undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
}, [memoizedData, memoizedColumns, pagination, sorting]);
|
}, [
|
||||||
|
memoizedData,
|
||||||
|
memoizedColumns,
|
||||||
|
pagination,
|
||||||
|
sorting,
|
||||||
|
manualPagination,
|
||||||
|
pageCount,
|
||||||
|
]);
|
||||||
|
|
||||||
// Prefer external table instance if provided; otherwise create an internal one
|
// Prefer external table instance if provided; otherwise create an internal one
|
||||||
const internalTable = useReactTable(tableConfig);
|
const internalTable = useReactTable(tableConfig);
|
||||||
@@ -166,7 +185,118 @@ export const DataTable = <T extends RowData>({
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
<Pagination table={t} />
|
{manualPagination && onPageChange && pageCount ? (
|
||||||
|
// Server-side pagination controls with numbered pages
|
||||||
|
<div className="flex items-center justify-center gap-10">
|
||||||
|
<button
|
||||||
|
className="disabled:opacity-50 cursor-pointer"
|
||||||
|
onClick={() => onPageChange(currentPage - 1)}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-neutral-800"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M15 19l-7-7 7-7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex gap-4 items-baseline">
|
||||||
|
{pageCount <= 8 ? (
|
||||||
|
// Show all pages if 8 or fewer
|
||||||
|
Array.from({ length: pageCount }, (_, index) => (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
|
||||||
|
currentPage === index + 1
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-primary-100 hover:bg-primary-200'
|
||||||
|
}`}
|
||||||
|
onClick={() => onPageChange(index + 1)}
|
||||||
|
>
|
||||||
|
{index + 1}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
// Show ellipsis for many pages
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(1)}
|
||||||
|
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
|
||||||
|
currentPage === 1
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-primary-100 hover:bg-primary-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
1
|
||||||
|
</button>
|
||||||
|
{currentPage > 3 && <span>...</span>}
|
||||||
|
{Array.from(
|
||||||
|
{ length: 5 },
|
||||||
|
(_, index) => currentPage - 2 + index
|
||||||
|
)
|
||||||
|
.filter((page) => page > 1 && page < pageCount)
|
||||||
|
.map((page) => (
|
||||||
|
<button
|
||||||
|
key={page}
|
||||||
|
onClick={() => onPageChange(page)}
|
||||||
|
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
|
||||||
|
currentPage === page
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-primary-100 hover:bg-primary-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{page}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{currentPage < pageCount - 2 && <span>...</span>}
|
||||||
|
<button
|
||||||
|
onClick={() => onPageChange(pageCount)}
|
||||||
|
className={`size-[30px] py-2 flex items-center justify-center rounded-md cursor-pointer ${
|
||||||
|
currentPage === pageCount
|
||||||
|
? 'bg-primary-500 text-white'
|
||||||
|
: 'bg-primary-100 hover:bg-primary-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{pageCount}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="disabled:opacity-50 cursor-pointer"
|
||||||
|
onClick={() => onPageChange(currentPage + 1)}
|
||||||
|
disabled={currentPage === pageCount}
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className="w-4 h-4 text-neutral-800"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M9 5l7 7-7 7"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
// Client-side pagination (default)
|
||||||
|
<Pagination table={t} />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user