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:
Hafid Nur
2025-12-09 21:29:53 +07:00
parent 254a2f154d
commit 7fc43d0f09
5 changed files with 355 additions and 124 deletions
@@ -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">
@@ -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 -7
View File
@@ -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;
}) => { }) => {
+132 -2
View File
@@ -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>
); );
}; };