diff --git a/apps/backoffice/src/app/(protected)/hackathon-dashboard/page.tsx b/apps/backoffice/src/app/(protected)/hackathon-dashboard/page.tsx index 9fc79f3..48a8d6d 100644 --- a/apps/backoffice/src/app/(protected)/hackathon-dashboard/page.tsx +++ b/apps/backoffice/src/app/(protected)/hackathon-dashboard/page.tsx @@ -11,24 +11,24 @@ export const HackathonDashboardPage: FC = (): ReactElement => { // Fetch total participants const { data: usersData } = useQuery({ queryKey: ['admin-users-count'], - queryFn: () => getAdminUsers({ page: 1, limit: 1 }), + queryFn: () => getAdminUsers({ page: 1, per_page: 1 }), }); // Fetch total teams const { data: teamsData } = useQuery({ queryKey: ['admin-teams-count'], - queryFn: () => getAdminTeams({ page: 1, limit: 1 }), + queryFn: () => getAdminTeams({ page: 1, per_page: 1 }), }); // Fetch total submissions const { data: submissionsData } = useQuery({ 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 totalTeams = teamsData?.meta?.total_data ?? 0; - const totalSubmissions = submissionsData?.meta?.total_data ?? 0; + const totalParticipants = usersData?.meta?.total_data ?? '??'; + const totalTeams = teamsData?.meta?.total_data ?? '??'; + const totalSubmissions = submissionsData?.meta?.total_data ?? '??'; return ( diff --git a/apps/backoffice/src/app/(protected)/hackathon-users/_components/modal-user-detail.tsx b/apps/backoffice/src/app/(protected)/hackathon-users/_components/modal-user-detail.tsx index 8182c5f..519044a 100644 --- a/apps/backoffice/src/app/(protected)/hackathon-users/_components/modal-user-detail.tsx +++ b/apps/backoffice/src/app/(protected)/hackathon-users/_components/modal-user-detail.tsx @@ -15,10 +15,10 @@ import { interface UserType { id: string; - avatar?: string; + avatar?: string | null; fullname: string; bio?: string; - location: string; + location: string | null; is_active: boolean; skills: string[]; created_at: string; @@ -77,7 +77,7 @@ const ModalUserDetail: FC = ({ isOpen, onClose, user }) => { // Check if required fields are filled const isFormValid = useMemo(() => { if (!formData) return false; - return formData.fullname.trim() !== '' && formData.location.trim() !== ''; + return formData.fullname?.trim() !== '' && formData.location?.trim() !== ''; }, [formData]); const canSave = hasChanges && isFormValid; @@ -259,13 +259,13 @@ const ModalUserDetail: FC = ({ isOpen, onClose, user }) => { @@ -294,13 +294,15 @@ const ModalUserDetail: FC = ({ isOpen, onClose, user }) => { } className={cn( '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-neutral-300' )} placeholder="Enter full name" /> - {formData.fullname.trim() === '' && ( + {(!formData.fullname || + formData.fullname.trim() === '') && (

Full name is required

@@ -316,13 +318,14 @@ const ModalUserDetail: FC = ({ isOpen, onClose, user }) => { Location * - {formData.location.trim() === '' && ( + {(!formData.location || + formData.location.trim() === '') && (

Location is required

diff --git a/apps/backoffice/src/app/(protected)/hackathon-users/page.tsx b/apps/backoffice/src/app/(protected)/hackathon-users/page.tsx index 31257bc..5903fc5 100644 --- a/apps/backoffice/src/app/(protected)/hackathon-users/page.tsx +++ b/apps/backoffice/src/app/(protected)/hackathon-users/page.tsx @@ -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 { BackofficeWrapper, @@ -13,23 +20,19 @@ import { SearchOutlined, FilterOutlined, PlusOutlined, + LoadingOutlined, } from '@ant-design/icons'; 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 -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; -} +type UserType = TAdminUserItem; -// Move mock data outside component to prevent recreation +// Skills options for filter const skillsOptions = [ 'Frontend Developer', 'Backend Developer', @@ -41,59 +44,108 @@ const skillsOptions = [ '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 => { + 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 [showNewUserModal, setShowNewUserModal] = useState(false); const [selectedUser, setSelectedUser] = useState(null); - const [globalFilter, setGlobalFilter] = useState(''); + const [globalFilter, setGlobalFilter] = useState(searchQuery); // Advanced filtering states const [statusFilter, setStatusFilter] = useState('all'); const [cityFilter, setCityFilter] = useState('all'); const [skillsFilter, setSkillsFilter] = useState([]); - // Constants - const pageSize = 10; + // Fetch users from API + 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) => { + 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 const handleShowDetailModal = useCallback((user: UserType) => { @@ -116,29 +168,26 @@ export const HackathonUsersPage: FC = (): ReactElement => { // Filter data based on current filter states const filteredData = useMemo(() => { - return mockData.filter((user) => { + const usersData = usersResponse?.data || []; + return usersData.filter((user: UserType) => { // Status filter if (statusFilter !== 'all') { const isActive = statusFilter === 'active'; if (user.is_active !== isActive) return false; } - // City filter - if (cityFilter !== 'all' && user.location !== cityFilter) { - return false; - } - // Skills filter if (skillsFilter.length > 0) { + const userSkills = user.skills || []; const hasMatchingSkill = skillsFilter.some((skill) => - user.skills.includes(skill) + userSkills.includes(skill) ); if (!hasMatchingSkill) return false; } return true; }); - }, [statusFilter, cityFilter, skillsFilter]); + }, [usersResponse, statusFilter, skillsFilter]); // Memoize columns to prevent recreation on every render const columns: ColumnDef[] = useMemo( @@ -173,23 +222,32 @@ export const HackathonUsersPage: FC = (): ReactElement => { { accessorKey: 'skills', header: 'Skills', - cell: ({ row }) => ( -
- {row.original.skills.slice(0, 2).map((skill, index) => ( - - {skill.replace(' Developer', '').replace(' Engineer', '')} - - ))} - {row.original.skills.length > 2 && ( - - +{row.original.skills.length - 2} - - )} -
- ), + cell: ({ row }) => { + const skills = row.original.skills || []; + return ( +
+ {skills.length > 0 ? ( + <> + {skills.slice(0, 2).map((skill, index) => ( + + {skill.replace(' Developer', '').replace(' Engineer', '')} + + ))} + {skills.length > 2 && ( + + +{skills.length - 2} + + )} + + ) : ( + - + )} +
+ ); + }, enableSorting: false, }, { @@ -260,7 +318,7 @@ export const HackathonUsersPage: FC = (): ReactElement => { Manage - + */} ), enableSorting: false, @@ -298,11 +356,28 @@ export const HackathonUsersPage: FC = (): ReactElement => { placeholder="Search users by name or location..." value={globalFilter} onChange={(e) => setGlobalFilter(e.target.value)} + onKeyPress={handleSearchKeyPress} /> - {/* Status Filter */} + {/* Per Page Dropdown */}
+ +
+ + {/* Status Filter */} + {/*
-
+ */} {/* City Filter */} - { ✕ - )} + )} */} {/* Skills Filter with Icon */} -
+ {/*
-
+
*/} {/* Right side - Add User Button */} @@ -446,17 +521,36 @@ export const HackathonUsersPage: FC = (): ReactElement => { )} - {/* Pagination-aware results display */} - {filteredData.length > 0 && ( -
- Showing {Math.min(pageSize, filteredData.length)} of{' '} - {filteredData.length} users - {filteredData.length > pageSize} + {/* Loading & results display */} + {isLoading ? ( +
+ + Loading users... +
+ ) : filteredData.length > 0 ? ( + <> +
+ Showing {filteredData.length} of {totalData} users (Page{' '} + {currentPage} of {totalPages}) + {isFetching && ( + (Updating...) + )} +
+ + + ) : ( +
+ No users found. Try adjusting your filters.
)} - - {/* Table */} - {/* Modals component */} diff --git a/libs/service/src/api/admin/index.ts b/libs/service/src/api/admin/index.ts index 23b331a..ada6b81 100644 --- a/libs/service/src/api/admin/index.ts +++ b/libs/service/src/api/admin/index.ts @@ -10,13 +10,18 @@ const ADMIN_BASE_URL = '/admin'; // Admin Users export const getAdminUsers = async (params?: { page?: number; - limit?: number; + per_page?: number; search?: string; - city?: string; + is_admin?: boolean; }) => { const response = await api.get( `${ADMIN_BASE_URL}/users`, - { params } + { + params: { + ...params, + is_admin: params?.is_admin ?? false, + }, + } ); return response.data; }; @@ -24,10 +29,8 @@ export const getAdminUsers = async (params?: { // Admin Teams export const getAdminTeams = async (params?: { page?: number; - limit?: number; + per_page?: number; search?: string; - city?: string; - visibility?: string; }) => { const response = await api.get( `${ADMIN_BASE_URL}/teams`, @@ -39,7 +42,7 @@ export const getAdminTeams = async (params?: { // Admin Submissions export const getAdminSubmissions = async (params?: { page?: number; - limit?: number; + per_page?: number; search?: string; status?: string; }) => { diff --git a/libs/ui/src/organisms/datatable/datatable.tsx b/libs/ui/src/organisms/datatable/datatable.tsx index 0338398..bbc5866 100644 --- a/libs/ui/src/organisms/datatable/datatable.tsx +++ b/libs/ui/src/organisms/datatable/datatable.tsx @@ -23,6 +23,11 @@ interface DataTableProps { columns?: ColumnDef[]; pageSize?: number; className?: string; + // server-side pagination props + manualPagination?: boolean; + pageCount?: number; + currentPage?: number; + onPageChange?: (page: number) => void; } export const DataTable = ({ @@ -31,6 +36,10 @@ export const DataTable = ({ columns = [], pageSize = 9, className, + manualPagination = false, + pageCount, + currentPage = 1, + onPageChange, }: DataTableProps) => { const [pagination, setPagination] = React.useState({ pageIndex: 0, @@ -75,10 +84,20 @@ export const DataTable = ({ getPaginationRowModel: getPaginationRowModel(), getSortedRowModel: getSortedRowModel(), getFilteredRowModel: getFilteredRowModel(), + // server-side pagination config + manualPagination, + pageCount: manualPagination ? pageCount : undefined, }; return config; - }, [memoizedData, memoizedColumns, pagination, sorting]); + }, [ + memoizedData, + memoizedColumns, + pagination, + sorting, + manualPagination, + pageCount, + ]); // Prefer external table instance if provided; otherwise create an internal one const internalTable = useReactTable(tableConfig); @@ -166,7 +185,118 @@ export const DataTable = ({
- + {manualPagination && onPageChange && pageCount ? ( + // Server-side pagination controls with numbered pages +
+ + +
+ {pageCount <= 8 ? ( + // Show all pages if 8 or fewer + Array.from({ length: pageCount }, (_, index) => ( + + )) + ) : ( + // Show ellipsis for many pages + <> + + {currentPage > 3 && ...} + {Array.from( + { length: 5 }, + (_, index) => currentPage - 2 + index + ) + .filter((page) => page > 1 && page < pageCount) + .map((page) => ( + + ))} + {currentPage < pageCount - 2 && ...} + + + )} +
+ + +
+ ) : ( + // Client-side pagination (default) + + )} ); };