-
-
-
-
+
+
+ {/* Search bar */}
+
+
+ setGlobalFilter(e.target.value)}
+ onKeyPress={handleSearchKeyPress}
+ />
+
+
+ {/* Per Page Dropdown */}
+
+
+
+
+ {/* Status Filter */}
+ {/*
+
+
+
*/}
+
- {/* Table */}
-
+ {/* Active filters */}
+ {/* {statusFilter !== 'all' && (
+
+ Active filters:
+
+ Status: {statusFilter}
+
+
+
+
+ )} */}
+
+ {/* Loading & results */}
+ {isLoading ? (
+
+
+
+ Loading submissions...
+
+
+ ) : filteredData.length > 0 ? (
+ <>
+
+ Showing {filteredData.length} of {totalData} submissions (Page{' '}
+ {currentPage} of {totalPages})
+ {isFetching && (
+ (Updating...)
+ )}
+
+
+ >
+ ) : (
+
+ No submissions found. Try adjusting your filters.
+
+ )}
- {/* Modals extracted into shared backoffice components */}
+ {/* Submission Modal */}
+ {selectedSubmission && (
+
+ )}
);
};
-export default HackathonUsersPage;
+export default HackathonSubmissionsPage;
diff --git a/apps/backoffice/src/app/(protected)/hackathon-teams/_components/modal-team-detail-new.tsx b/apps/backoffice/src/app/(protected)/hackathon-teams/_components/modal-team-detail-new.tsx
new file mode 100644
index 0000000..b2f7201
--- /dev/null
+++ b/apps/backoffice/src/app/(protected)/hackathon-teams/_components/modal-team-detail-new.tsx
@@ -0,0 +1,514 @@
+import { FC, useState, useEffect, useMemo, useRef } from 'react';
+import { Button } from '@imphnen-frontend-service/ui/atoms';
+import { CityFilterSelect } from '../../../../components/city-filter-select';
+import TeamBannerPlaceholder from './team-banner-placeholder';
+import { cn } from '@imphnen-frontend-service/utils';
+import { TAdminTeamItem } from '@imphnen-frontend-service/service';
+import {
+ TeamOutlined,
+ CloseOutlined,
+ DeleteOutlined,
+ SaveOutlined,
+ CalendarOutlined,
+ CrownOutlined,
+ ExclamationOutlined,
+ UploadOutlined,
+ CameraOutlined,
+ EyeOutlined,
+ EyeInvisibleOutlined,
+} from '@ant-design/icons';
+
+type TeamType = TAdminTeamItem;
+
+interface ModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ team: TeamType | null;
+}
+
+const ModalTeamDetail: FC
= ({ isOpen, onClose, team }) => {
+ const [formData, setFormData] = useState(null);
+ const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
+ const [showLogoMenu, setShowLogoMenu] = useState(false);
+ const logoInputRef = useRef(null);
+ const bannerInputRef = useRef(null);
+
+ // Initialize form data when modal opens
+ useEffect(() => {
+ if (isOpen) {
+ if (team) {
+ setFormData({ ...team });
+ } else {
+ setFormData({
+ id: '',
+ name: '',
+ description: '',
+ city: '',
+ banner: null,
+ logo: null,
+ visibility: 'public',
+ created_at: new Date().toISOString(),
+ updated_at: new Date().toISOString(),
+ leader_id: '',
+ });
+ }
+ }
+ }, [isOpen, team]);
+
+ // Check if form has changes
+ const hasChanges = useMemo(() => {
+ if (!formData || !team) return !!formData;
+ return (
+ formData.name !== team.name ||
+ formData.description !== team.description ||
+ formData.city !== team.city ||
+ formData.visibility !== team.visibility ||
+ formData.logo !== team.logo ||
+ formData.banner !== team.banner
+ );
+ }, [formData, team]);
+
+ // Check if required fields are filled
+ const isFormValid = useMemo(() => {
+ if (!formData) return false;
+ return (
+ formData.name.trim() !== '' &&
+ formData.city.trim() !== '' &&
+ formData.description.trim() !== ''
+ );
+ }, [formData]);
+
+ const canSave = hasChanges && isFormValid;
+
+ if (!isOpen || !formData) return null;
+
+ const handleInputChange = (field: keyof TeamType, value: string | null) => {
+ setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
+ };
+
+ const handleSave = () => {
+ console.log('Saving team:', formData);
+ onClose();
+ };
+
+ const handleDelete = () => {
+ if (!team) return;
+ console.log('Deleting team:', team.id);
+ setShowDeleteConfirm(false);
+ onClose();
+ };
+
+ const handleLogoUpload = (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+
+ if (!file.type.startsWith('image/')) {
+ alert('Please select an image file');
+ return;
+ }
+ if (file.size > 5 * 1024 * 1024) {
+ alert('Image size must be less than 5MB');
+ return;
+ }
+
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ const logoUrl = e.target?.result as string;
+ handleInputChange('logo', logoUrl);
+ setShowLogoMenu(false);
+ };
+ reader.readAsDataURL(file);
+ };
+
+ const handleBannerUpload = (event: React.ChangeEvent) => {
+ const file = event.target.files?.[0];
+ if (!file) return;
+
+ if (!file.type.startsWith('image/')) {
+ alert('Please select an image file');
+ return;
+ }
+ if (file.size > 5 * 1024 * 1024) {
+ alert('Image size must be less than 5MB');
+ return;
+ }
+
+ const reader = new FileReader();
+ reader.onload = (e) => {
+ const bannerUrl = e.target?.result as string;
+ handleInputChange('banner', bannerUrl);
+ };
+ reader.readAsDataURL(file);
+ };
+
+ return (
+
+
+ {/* Header */}
+
+
+
+
+
+
+
+ {team ? 'Team Details' : 'Create New Team'}
+
+
+ {team
+ ? 'View and manage team information'
+ : 'Add a new team to the hackathon'}
+
+
+
+
+
+
+ {/* Content */}
+
setShowLogoMenu(false)}>
+
+
+
+ {/* Banner Section */}
+
+
+
+
+
+
+ {formData.banner && (
+
+ )}
+
+
+
+
+ {/* Logo & Name */}
+
+
+
+
+
+ {formData.logo ? (
+

+ ) : (
+
+ )}
+
+
+ {showLogoMenu && (
+
+
+ {formData.logo && (
+
+ )}
+
+ )}
+
+
+
+
+
+ handleInputChange('name', e.target.value)}
+ />
+
+
+
+ {/* Description */}
+
+
+
+
+ {/* City */}
+
+
+
+ handleInputChange('city', city === 'all' ? '' : city)
+ }
+ className="w-full"
+ placeholder="Search cities..."
+ allOptionLabel="Select a city"
+ filterIcon={false}
+ />
+
+
+ {/* Visibility */}
+
+
+ {/* Team Details */}
+ {team && (
+
+
+
+
+
+
+ {team.leader_id}
+
+
+
+
+
+
+
+
+
+
+ {new Date(team.created_at).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+
+
+
+
+
+
+
+ {new Date(team.updated_at).toLocaleDateString('en-US', {
+ year: 'numeric',
+ month: 'long',
+ day: 'numeric',
+ hour: '2-digit',
+ minute: '2-digit',
+ })}
+
+
+
+
+
+ )}
+
+
+ {/* Footer */}
+
+
+ {team && (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {/* Delete Confirmation Modal */}
+ {showDeleteConfirm && (
+
+
+
+
+
+
+
+
+ Delete Team
+
+
+ This action cannot be undone.
+
+
+
+
+
+ Are you sure you want to delete "{team?.name}"? This will
+ permanently remove the team and all associated data.
+
+
+
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default ModalTeamDetail;
diff --git a/apps/backoffice/src/app/(protected)/hackathon-teams/_components/team-banner-placeholder.tsx b/apps/backoffice/src/app/(protected)/hackathon-teams/_components/team-banner-placeholder.tsx
new file mode 100644
index 0000000..cbd9cb6
--- /dev/null
+++ b/apps/backoffice/src/app/(protected)/hackathon-teams/_components/team-banner-placeholder.tsx
@@ -0,0 +1,82 @@
+import { FC } from 'react';
+import { TeamOutlined } from '@ant-design/icons';
+import { cn } from '@imphnen-frontend-service/utils';
+
+interface TeamBannerPlaceholderProps {
+ banner?: string;
+ teamName: string;
+ className?: string;
+ showPlaceholder?: boolean;
+}
+
+const TeamBannerPlaceholder: FC = ({
+ banner,
+ teamName,
+ className = '',
+ showPlaceholder = true,
+}) => {
+ const aspectRatioClass = 'aspect-[3/1]'; // 3:1 aspect ratio
+
+ if (!banner && !showPlaceholder) {
+ return null;
+ }
+
+ if (banner) {
+ return (
+
+

{
+ // Fallback to placeholder if image fails to load
+ const target = e.target as HTMLImageElement;
+ target.style.display = 'none';
+ const placeholder = target.nextElementSibling as HTMLElement;
+ if (placeholder) {
+ placeholder.style.display = 'flex';
+ }
+ }}
+ />
+ {/* Fallback placeholder (hidden by default, shown on image error) */}
+
+
+
+
{teamName}
+
Team Banner
+
+
+
+ );
+ }
+
+ // No banner - show placeholder
+ return (
+
+
+
+
{teamName}
+
No Banner
+
+
+ );
+};
+
+export default TeamBannerPlaceholder;
diff --git a/apps/backoffice/src/app/(protected)/hackathon-teams/page.tsx b/apps/backoffice/src/app/(protected)/hackathon-teams/page.tsx
index eb52968..9e29cb2 100644
--- a/apps/backoffice/src/app/(protected)/hackathon-teams/page.tsx
+++ b/apps/backoffice/src/app/(protected)/hackathon-teams/page.tsx
@@ -1,179 +1,275 @@
-import { FC, ReactElement, useState } from 'react';
+import {
+ FC,
+ ReactElement,
+ useState,
+ useEffect,
+ useMemo,
+ useCallback,
+} from 'react';
+import ModalTeamDetail from './_components/modal-team-detail-new';
+import { CityFilterSelect } from '../../../components/city-filter-select';
import {
BackofficeWrapper,
DataTable,
} from '@imphnen-frontend-service/ui/organisms';
-import {
- ColumnDef,
- getCoreRowModel,
- getPaginationRowModel,
- PaginationState,
- RowSelectionState,
- useReactTable,
-} from '@tanstack/react-table';
+import { ColumnDef } from '@tanstack/react-table';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
-import { useTeams } from '@imphnen-frontend-service/service';
-import { EditOutlined } from '@ant-design/icons';
+import {
+ EditOutlined,
+ TeamOutlined,
+ SearchOutlined,
+ FilterOutlined,
+ PlusOutlined,
+ LoadingOutlined,
+} from '@ant-design/icons';
+import { useQuery } from '@tanstack/react-query';
+import {
+ getAdminTeams,
+ TAdminTeamItem,
+} from '@imphnen-frontend-service/service';
+import { useSearchParams } from 'react-router-dom';
+
+type TeamType = TAdminTeamItem;
export const HackathonTeamsPage: FC = (): ReactElement => {
- const { data: teamsData } = useTeams();
+ 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 [showNewTeamModal, setShowNewTeamModal] = useState(false);
+ const [selectedTeam, setSelectedTeam] = useState(null);
+ useState(null);
+ const [globalFilter, setGlobalFilter] = useState(searchQuery);
- const [rowSelection, setRowSelection] = useState({});
- const [pagination, setPagination] = useState({
- pageIndex: 0,
- pageSize: 9,
+ // Advanced filtering states
+ const [visibilityFilter, setVisibilityFilter] = useState('all');
+ const [cityFilter, setCityFilter] = useState('all');
+
+ // Fetch teams from API
+ const {
+ data: teamsResponse,
+ isLoading,
+ isFetching,
+ } = useQuery({
+ queryKey: [
+ 'admin-teams',
+ currentPage,
+ perPage,
+ cityFilter,
+ visibilityFilter,
+ searchQuery,
+ ],
+ queryFn: () =>
+ getAdminTeams({
+ page: currentPage,
+ per_page: perPage,
+ search: searchQuery || undefined,
+ }),
+ staleTime: 30000, // 30 seconds cache
+ gcTime: 5 * 60 * 1000, // 5 minutes
});
- const mockData: TeamType[] = Array.from({ length: 90 }, (_, i) => ({
- id: `team-${i + 1}`,
- name: `Team ${i + 1} - ${i % 3 === 0 ? 'Innovators' : 'Hackers'}`,
- city: i % 2 === 0 ? 'Jakarta' : 'Bandung',
- visibility: i % 4 === 0 ? 'private' : 'public',
- member_count: Math.floor(Math.random() * 4) + 1,
- has_submission: i % 3 !== 0,
- created_at: new Date().toISOString(),
- updated_at: new Date().toISOString(),
- leader: {
- user: {
- fullname: `Leader User ${i}`,
- email: `leader${i}@example.com`,
- },
- },
- }));
+ const totalData = teamsResponse?.meta?.total_data || 0;
+ const totalPages = teamsResponse?.meta?.total_page || 1;
- interface TeamType {
- id: string;
- name: string;
- city: string;
- visibility: 'public' | 'private';
- member_count: number;
- has_submission: boolean;
- created_at: string;
- updated_at: string;
- leader?: {
- user: {
- fullname: string;
- email: string;
- };
- };
- }
+ // 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]
+ );
- const columns: ColumnDef[] = [
- {
- accessorKey: 'id',
- header: 'ID',
+ // 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 teams
+ 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();
+ }
},
- {
- accessorKey: 'name',
- header: 'Team Name',
+ [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);
},
- {
- accessorKey: 'city',
- header: 'City',
- },
- {
- accessorKey: 'visibility',
- header: 'Visibility',
- cell: ({ row }) => {
- const isPublic = row.original.visibility === 'public';
- return (
-
- {isPublic ? 'Public' : 'Private'}
-
- );
- },
- },
- {
- accessorKey: 'member_count',
- header: 'Members',
- },
- {
- id: 'leader',
- header: 'Leader',
- cell: ({ row }) => {
- const leader = row.original.leader?.user;
- return leader ? (
-
-
- {leader.fullname}
+ [setSearchParams, searchQuery]
+ );
+
+ // Memoize the callback to prevent recreation
+ const handleShowDetailModal = useCallback((team: TeamType) => {
+ setSelectedTeam(team);
+ setShowDetailModal(true);
+ }, []);
+
+ const handleCloseDetailModal = useCallback(() => {
+ setShowDetailModal(false);
+ setSelectedTeam(null);
+ }, []);
+
+ const handleShowNewTeamModal = useCallback(() => {
+ setShowNewTeamModal(true);
+ }, []);
+
+ const handleCloseNewTeamModal = useCallback(() => {
+ setShowNewTeamModal(false);
+ }, []);
+
+ // Get teams data from API response
+ const filteredData = useMemo(() => {
+ return teamsResponse?.data || [];
+ }, [teamsResponse]);
+
+ // Memoize columns to prevent recreation on every render
+ const columns: ColumnDef
[] = useMemo(
+ () => [
+ {
+ accessorKey: 'name',
+ header: 'Team',
+ cell: ({ row }) => {
+ const team = row.original;
+ return (
+
+ {/* Team Logo */}
+
+ {team.logo ? (
+

+ ) : (
+
+ )}
+
+ {/* Team Name */}
+
- {leader.email}
+ );
+ },
+ enableSorting: true,
+ },
+ {
+ accessorKey: 'city',
+ header: 'City',
+ cell: ({ row }) => (
+ {row.original.city}
+ ),
+ enableSorting: true,
+ },
+ {
+ accessorKey: 'visibility',
+ header: 'Visibility',
+ cell: ({ row }) => {
+ const isPublic = row.original.visibility === 'public';
+ return (
+
+ {isPublic ? 'Public' : 'Private'}
+
+ );
+ },
+ enableSorting: true,
+ },
+ {
+ id: 'leader',
+ header: 'Leader ID',
+ cell: ({ row }) => (
+
+ {row.original.leader_id}
- ) : (
- -
- );
+ ),
+ enableSorting: false,
},
- },
- {
- accessorKey: 'has_submission',
- header: 'Submitted',
- cell: ({ row }) => {
- const hasSubmission = row.original.has_submission;
- return (
-
- {hasSubmission ? 'Yes' : 'No'}
+ {
+ accessorKey: 'created_at',
+ header: 'Created',
+ cell: ({ row }) => (
+
+ {new Date(row.original.created_at).toLocaleDateString('en-UK', {
+ year: 'numeric',
+ month: 'short',
+ day: 'numeric',
+ })}
- );
+ ),
+ enableSorting: true,
+ sortingFn: 'datetime',
},
- },
- {
- accessorKey: 'updated_at',
- header: 'Last Updated',
- cell: ({ row }) => {
- return new Date(row.original.updated_at).toLocaleDateString();
+ {
+ id: 'actions',
+ header: 'Actions',
+ meta: { cellClassName: cn('w-48') },
+ cell: ({ row }) => (
+
+
+
+ ),
+ enableSorting: false,
},
- },
- {
- id: 'actions',
- header: 'Action',
- meta: { cellClassName: cn('w-48') },
- cell: ({ row }) => (
-
-
-
- ),
- },
- ];
-
- const table = useReactTable({
- data: mockData,
- columns,
- state: {
- pagination,
- rowSelection,
- },
- enableRowSelection: true,
- onRowSelectionChange: setRowSelection,
- getCoreRowModel: getCoreRowModel(),
- getPaginationRowModel: getPaginationRowModel(),
- onPaginationChange: setPagination,
- pageCount: Math.ceil(mockData.length / pagination.pageSize),
- manualPagination: false,
- });
+ ],
+ [handleShowDetailModal]
+ );
return (
@@ -182,27 +278,168 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
{/* Filters and actions */}
-
-
-
-
+
+ {/* Left side - Search & filters */}
+
+ {/* Search bar */}
+
+
+ setGlobalFilter(e.target.value)}
+ onKeyPress={handleSearchKeyPress}
+ />
+
+
+ {/* Per Page Dropdown */}
+
+
+
+
+ {/* Visibility Filter */}
+ {/*
+
+
+
*/}
+
+ {/* City Filter */}
+ {/*
*/}
+
+
+ {/* Right side - Add Team Button */}
+
- {/* Table */}
-
+
+ {/* Active filters display */}
+ {(visibilityFilter !== 'all' || cityFilter !== 'all') && (
+
+ Active filters:
+
+ {/* Visibility filter badge */}
+ {visibilityFilter !== 'all' && (
+
+ Visibility: {visibilityFilter}
+
+
+ )}
+
+ {/* City filter badge */}
+ {cityFilter !== 'all' && (
+
+ City: {cityFilter}
+
+
+ )}
+
+ {/* Clear all filters */}
+
+
+ )}
+
+ {/* Loading & results display */}
+ {isLoading ? (
+
+
+ Loading teams...
+
+ ) : filteredData.length > 0 ? (
+ <>
+
+ Showing {filteredData.length} of {totalData} teams (Page{' '}
+ {currentPage} of {totalPages})
+ {isFetching && (
+ (Updating...)
+ )}
+
+
+ >
+ ) : (
+
+ No teams found. Try adjusting your filters.
+
+ )}
- {/* Modals extracted into shared backoffice components */}
+
+ {/* Modals component */}
+
+
+ {/* New Team Modal */}
+
);
};
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 500f9eb..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';
-// Removed unused SearchOutlined icon after schema revision
+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 [locationFilter, setLocationFilter] = 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;
}
- // Location filter
- if (locationFilter !== 'all' && user.location !== locationFilter) {
- 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, locationFilter, 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 */}
+ {/*
-
+ */}
- {/* Location Filter */}
-
-
-
-
+ {/* City Filter */}
+ {/*
+ {cityFilter !== 'all' && (
+
+ Location: {cityFilter}
+
+
+ )} */}
{/* Skills Filter with Icon */}
- */}
{/* Right side - Add User Button */}
@@ -378,7 +456,7 @@ export const HackathonUsersPage: FC = (): ReactElement => {
{/* Active filters display */}
{(skillsFilter.length > 0 ||
statusFilter !== 'all' ||
- locationFilter !== 'all') && (
+ cityFilter !== 'all') && (
Active filters:
@@ -396,11 +474,11 @@ export const HackathonUsersPage: FC = (): ReactElement => {
)}
{/* Location filter badge */}
- {locationFilter !== 'all' && (
+ {cityFilter !== 'all' && (
- Location: {locationFilter}
+ City: {cityFilter}
)}
- {/* 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/apps/backoffice/src/app/(protected)/layout.tsx b/apps/backoffice/src/app/(protected)/layout.tsx
index a1fb13e..957a288 100644
--- a/apps/backoffice/src/app/(protected)/layout.tsx
+++ b/apps/backoffice/src/app/(protected)/layout.tsx
@@ -23,7 +23,7 @@ export const AppLayout: FC = (): ReactElement => {
{/* Mobile menu button (shown on small screens) */}
-
+ {manualPagination && onPageChange && pageCount ? (
+ // Server-side pagination controls with numbered pages
+
+
onPageChange(currentPage - 1)}
+ disabled={currentPage === 1}
+ aria-label="Previous page"
+ >
+
+
+
+
+ {pageCount <= 8 ? (
+ // Show all pages if 8 or fewer
+ Array.from({ length: pageCount }, (_, index) => (
+ onPageChange(index + 1)}
+ >
+ {index + 1}
+
+ ))
+ ) : (
+ // Show ellipsis for many pages
+ <>
+ 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
+
+ {currentPage > 3 && ...}
+ {Array.from(
+ { length: 5 },
+ (_, index) => currentPage - 2 + index
+ )
+ .filter((page) => page > 1 && page < pageCount)
+ .map((page) => (
+ 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}
+
+ ))}
+ {currentPage < pageCount - 2 && ...}
+ 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}
+
+ >
+ )}
+
+
+
onPageChange(currentPage + 1)}
+ disabled={currentPage === pageCount}
+ aria-label="Next page"
+ >
+
+
+
+ ) : (
+ // Client-side pagination (default)
+
+ )}
);
};