feat(backoffice): teams page integration

- Get teams data from API
- Hide filter that doesn't exists in back-end
- Simplify modal according to the back-end
This commit is contained in:
Hafid Nur
2025-12-09 22:20:41 +07:00
parent 7fc43d0f09
commit 93df14169c
2 changed files with 314 additions and 841 deletions
@@ -1,63 +1,24 @@
import { FC, useState, useEffect, useMemo, useRef } from 'react'; import { FC, useState, useEffect, useMemo, useRef } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms'; import { Button } from '@imphnen-frontend-service/ui/atoms';
import { cn } from '@imphnen-frontend-service/utils';
import TeamBannerPlaceholder from './team-banner-placeholder';
import { CityFilterSelect } from '../../../../components/city-filter-select'; 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 { import {
TeamOutlined, TeamOutlined,
CalendarOutlined,
SaveOutlined,
CloseOutlined, CloseOutlined,
ExclamationOutlined,
UserOutlined,
DeleteOutlined, DeleteOutlined,
SaveOutlined,
CalendarOutlined,
CrownOutlined,
ExclamationOutlined,
UploadOutlined,
CameraOutlined,
EyeOutlined, EyeOutlined,
EyeInvisibleOutlined, EyeInvisibleOutlined,
CrownOutlined,
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
CameraOutlined,
UploadOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
interface TeamMember { type TeamType = TAdminTeamItem;
id: string;
joined_at: string;
role: 'leader' | 'member';
status: 'pending' | 'accepted' | 'rejected';
team_id: string;
user: {
avatar?: string;
bio?: string;
created_at: string;
email: string;
fullname: string;
id: string;
is_active: boolean;
location: string;
phone_number?: string;
skills: string[];
updated_at: string;
};
user_id: string;
}
interface TeamType {
id: string;
name: string;
description?: string;
city: string;
banner?: string;
logo?: string;
visibility: 'public' | 'private';
member_count: number;
has_submission: boolean;
created_at: string;
updated_at: string;
leader_id: string;
members: TeamMember[];
}
interface ModalProps { interface ModalProps {
isOpen: boolean; isOpen: boolean;
@@ -68,7 +29,6 @@ interface ModalProps {
const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => { const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
const [formData, setFormData] = useState<TeamType | null>(null); const [formData, setFormData] = useState<TeamType | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [activeTab, setActiveTab] = useState<'details' | 'members'>('details');
const [showLogoMenu, setShowLogoMenu] = useState(false); const [showLogoMenu, setShowLogoMenu] = useState(false);
const logoInputRef = useRef<HTMLInputElement>(null); const logoInputRef = useRef<HTMLInputElement>(null);
const bannerInputRef = useRef<HTMLInputElement>(null); const bannerInputRef = useRef<HTMLInputElement>(null);
@@ -77,24 +37,19 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
if (team) { if (team) {
// Edit existing team
setFormData({ ...team }); setFormData({ ...team });
} else { } else {
// Create new team
setFormData({ setFormData({
id: '', // Will be generated by backend id: '',
name: '', name: '',
description: '', description: '',
city: '', city: '',
banner: undefined, banner: null,
logo: undefined, logo: null,
visibility: 'public', visibility: 'public',
member_count: 1,
has_submission: false,
created_at: new Date().toISOString(), created_at: new Date().toISOString(),
updated_at: new Date().toISOString(), updated_at: new Date().toISOString(),
leader_id: '', leader_id: '',
members: [],
}); });
} }
} }
@@ -102,8 +57,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
// Check if form has changes // Check if form has changes
const hasChanges = useMemo(() => { const hasChanges = useMemo(() => {
if (!formData) return false; if (!formData || !team) return !!formData;
if (!team) return true; // New team always has changes
return ( return (
formData.name !== team.name || formData.name !== team.name ||
formData.description !== team.description || formData.description !== team.description ||
@@ -117,37 +71,28 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
// 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.name.trim() !== '' && formData.city.trim() !== ''; return (
formData.name.trim() !== '' &&
formData.city.trim() !== '' &&
formData.description.trim() !== ''
);
}, [formData]); }, [formData]);
const canSave = hasChanges && isFormValid; const canSave = hasChanges && isFormValid;
// Get leader and other members
const leader = team?.members.find((m) => m.role === 'leader');
const acceptedMembers =
team?.members.filter((m) => m.status === 'accepted') || [];
const pendingMembers =
team?.members.filter((m) => m.status === 'pending') || [];
if (!isOpen || !formData) return null; if (!isOpen || !formData) return null;
const handleInputChange = ( const handleInputChange = (field: keyof TeamType, value: string | null) => {
field: keyof TeamType,
value: string | boolean | 'public' | 'private' | undefined
) => {
setFormData((prev) => (prev ? { ...prev, [field]: value } : null)); setFormData((prev) => (prev ? { ...prev, [field]: value } : null));
}; };
const handleSave = () => { const handleSave = () => {
if (!formData) return;
console.log('Saving team:', formData); console.log('Saving team:', formData);
onClose(); onClose();
}; };
const handleDelete = () => { const handleDelete = () => {
if (!team) return; if (!team) return;
console.log('Deleting team:', team.id); console.log('Deleting team:', team.id);
setShowDeleteConfirm(false); setShowDeleteConfirm(false);
onClose(); onClose();
@@ -155,56 +100,45 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => { const handleLogoUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Please select an image file'); if (!file.type.startsWith('image/')) {
return; 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);
} }
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<HTMLInputElement>) => { const handleBannerUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0]; const file = event.target.files?.[0];
if (file) { if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Please select an image file'); if (!file.type.startsWith('image/')) {
return; alert('Please select an image file');
} return;
if (file.size > 5 * 1024 * 1024) { }
alert('Image size must be less than 5MB'); if (file.size > 5 * 1024 * 1024) {
return; 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);
} }
};
const handleRemoveLogo = () => { const reader = new FileReader();
handleInputChange('logo', undefined); reader.onload = (e) => {
setShowLogoMenu(false); const bannerUrl = e.target?.result as string;
}; handleInputChange('banner', bannerUrl);
};
const handleRemoveBanner = () => { reader.readAsDataURL(file);
handleInputChange('banner', undefined);
};
const triggerLogoUpload = () => {
logoInputRef.current?.click();
}; };
return ( return (
@@ -240,7 +174,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
{/* Content */} {/* Content */}
<div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}> <div className="p-6 space-y-6" onClick={() => setShowLogoMenu(false)}>
{/* Hidden File Inputs */}
<input <input
type="file" type="file"
ref={logoInputRef} ref={logoInputRef}
@@ -256,21 +189,20 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
className="hidden" className="hidden"
/> />
{/* Interactive Banner Section */} {/* Banner Section */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">
Team Banner Team Banner{' '}
<span className="text-xs text-neutral-500 ml-2"> <span className="text-xs text-neutral-500">
(3:1 aspect ratio recommended) (3:1 aspect ratio recommended)
</span> </span>
</label> </label>
<div className="relative group"> <div className="relative group">
<TeamBannerPlaceholder <TeamBannerPlaceholder
banner={formData.banner} banner={formData.banner || undefined}
teamName={formData.name || 'Team Name'} teamName={formData.name || 'Team Name'}
className="rounded-lg border border-neutral-200 transition-all group-hover:border-primary-300" className="rounded-lg border border-neutral-200 transition-all group-hover:border-primary-300"
/> />
{/* Banner Action Buttons */}
<div className="absolute inset-0 bg-black/40 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3"> <div className="absolute inset-0 bg-black/40 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-3">
<Button <Button
variant="primary" variant="primary"
@@ -290,21 +222,20 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
size="sm" size="sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleRemoveBanner(); handleInputChange('banner', null);
}} }}
className="bg-white/90 hover:bg-white text-red-600 border-transparent shadow-sm hover:text-red-700 gap-2" className="bg-white/90 hover:bg-white text-red-600 border-transparent shadow-sm hover:text-red-700 gap-2"
> >
<DeleteOutlined className="text-sm" /> <DeleteOutlined className="text-sm" />
Delete Banner Delete
</Button> </Button>
)} )}
</div> </div>
</div> </div>
</div> </div>
{/* Team Logo & Name Row */} {/* Logo & Name */}
<div className="grid grid-cols-12 gap-4 items-start"> <div className="grid grid-cols-12 gap-4 items-start">
{/* Interactive Team Logo */}
<div className="col-span-2"> <div className="col-span-2">
<label className="block text-sm font-medium text-neutral-700 mb-2"> <label className="block text-sm font-medium text-neutral-700 mb-2">
Logo Logo
@@ -321,7 +252,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
<TeamOutlined className="text-neutral-400 text-xl" /> <TeamOutlined className="text-neutral-400 text-xl" />
)} )}
</div> </div>
{/* Logo Hover Overlay - Full circle */}
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -331,14 +261,12 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
> >
<CameraOutlined className="text-white text-lg" /> <CameraOutlined className="text-white text-lg" />
</button> </button>
{/* Logo Menu Dropdown */}
{showLogoMenu && ( {showLogoMenu && (
<div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10"> <div className="absolute top-full left-0 mt-2 bg-white rounded-lg shadow-lg border border-neutral-200 py-2 min-w-[140px] z-10">
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
triggerLogoUpload(); logoInputRef.current?.click();
}} }}
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer" className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-2 cursor-pointer"
> >
@@ -349,7 +277,8 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleRemoveLogo(); handleInputChange('logo', null);
setShowLogoMenu(false);
}} }}
className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer" className="w-full px-4 py-2 text-left text-sm text-red-600 hover:bg-red-50 flex items-center gap-2 cursor-pointer"
> >
@@ -362,7 +291,6 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
</div> </div>
</div> </div>
{/* Team Name */}
<div className="col-span-10 space-y-2"> <div className="col-span-10 space-y-2">
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">
Team Name <span className="text-danger-500">*</span> Team Name <span className="text-danger-500">*</span>
@@ -374,22 +302,19 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
value={formData.name} value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)} onChange={(e) => handleInputChange('name', e.target.value)}
/> />
<p className="text-xs text-neutral-500">
Click logo to upload or change team logo (circular format)
</p>
</div> </div>
</div> </div>
{/* Team Description */} {/* Description */}
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">
Description Description <span className="text-danger-500">*</span>
</label> </label>
<textarea <textarea
className="w-full border border-neutral-200 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none" className="w-full border border-neutral-200 rounded-lg px-3 py-2 text-sm focus:border-primary-500 focus:outline-none resize-none"
placeholder="Enter team description (optional)" placeholder="Enter team description"
rows={3} rows={3}
value={formData.description || ''} value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)} onChange={(e) => handleInputChange('description', e.target.value)}
/> />
</div> </div>
@@ -424,7 +349,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
value="public" value="public"
checked={formData.visibility === 'public'} checked={formData.visibility === 'public'}
onChange={(e) => onChange={(e) =>
handleInputChange('visibility', e.target.value as 'public') handleInputChange('visibility', e.target.value)
} }
className="text-primary-600" className="text-primary-600"
/> />
@@ -438,7 +363,7 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
value="private" value="private"
checked={formData.visibility === 'private'} checked={formData.visibility === 'private'}
onChange={(e) => onChange={(e) =>
handleInputChange('visibility', e.target.value as 'private') handleInputChange('visibility', e.target.value)
} }
className="text-primary-600" className="text-primary-600"
/> />
@@ -448,288 +373,57 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
</div> </div>
</div> </div>
{/* Team Information (Read-only for existing teams) */} {/* Team Details */}
{team && ( {team && (
<div className="grid grid-cols-2 gap-4"> <div className="space-y-4 border-t border-neutral-200 pt-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700"> <label className="block text-sm font-medium text-neutral-700">
Members Team Leader ID
</label> </label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg"> <div className="p-3 bg-neutral-50 rounded-lg flex items-center gap-3">
<UserOutlined className="text-neutral-500" /> <CrownOutlined className="text-yellow-600 text-lg" />
<span className="text-sm text-neutral-700"> <span className="text-sm text-neutral-700 font-mono">
{team.member_count} member {team.leader_id}
{team.member_count !== 1 ? 's' : ''}
</span> </span>
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="grid grid-cols-2 gap-4">
<label className="block text-sm font-medium text-neutral-700"> <div className="space-y-2">
Submission Status <label className="block text-sm font-medium text-neutral-700">
</label> Created
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg"> </label>
<div <div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
className={cn( <CalendarOutlined className="text-neutral-500" />
'w-2 h-2 rounded-full', <span className="text-sm text-neutral-700">
team.has_submission ? 'bg-success-500' : 'bg-danger-500' {new Date(team.created_at).toLocaleDateString('en-US', {
)} year: 'numeric',
/> month: 'long',
<span day: 'numeric',
className={cn( hour: '2-digit',
'text-sm font-medium', minute: '2-digit',
team.has_submission })}
? 'text-success-700' </span>
: 'text-danger-700' </div>
)}
>
{team.has_submission ? 'Submitted' : 'Not Submitted'}
</span>
</div> </div>
</div>
</div>
)}
{/* Tabs for Details and Members */} <div className="space-y-2">
{team && ( <label className="block text-sm font-medium text-neutral-700">
<div> Last Updated
<div className="flex border-b border-neutral-200"> </label>
<button <div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
className={cn( <CalendarOutlined className="text-neutral-500" />
'px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer', <span className="text-sm text-neutral-700">
activeTab === 'details' {new Date(team.updated_at).toLocaleDateString('en-US', {
? 'border-primary-500 text-primary-600' year: 'numeric',
: 'border-transparent text-neutral-500 hover:text-neutral-700' month: 'long',
)} day: 'numeric',
onClick={() => setActiveTab('details')} hour: '2-digit',
> minute: '2-digit',
Team Details })}
</button> </span>
<button
className={cn(
'px-4 py-2 text-sm font-medium border-b-2 transition-colors cursor-pointer',
activeTab === 'members'
? 'border-primary-500 text-primary-600'
: 'border-transparent text-neutral-500 hover:text-neutral-700'
)}
onClick={() => setActiveTab('members')}
>
Members ({team.member_count})
</button>
</div>
<div className="pt-4">
{activeTab === 'details' && (
<div className="space-y-4">
{/* Leader Information */}
{leader && (
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Team Leader
</label>
<div className="p-3 bg-neutral-50 rounded-lg">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden">
{leader.user.avatar ? (
<img
src={leader.user.avatar}
alt={leader.user.fullname}
className="w-full h-full object-cover"
/>
) : (
<CrownOutlined className="text-yellow-600 text-sm" />
)}
</div>
<div>
<p className="text-sm font-medium text-neutral-900">
{leader.user.fullname}
</p>
<p className="text-xs text-neutral-500">
{leader.user.email}
</p>
</div>
</div>
</div>
</div>
)}
{/* Timestamps */}
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Created
</label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
<CalendarOutlined className="text-neutral-500" />
<span className="text-sm text-neutral-700">
{new Date(team.created_at).toLocaleDateString(
'en-US',
{
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}
)}
</span>
</div>
</div>
<div className="space-y-2">
<label className="block text-sm font-medium text-neutral-700">
Last Updated
</label>
<div className="flex items-center gap-2 p-3 bg-neutral-50 rounded-lg">
<CalendarOutlined className="text-neutral-500" />
<span className="text-sm text-neutral-700">
{new Date(team.updated_at).toLocaleDateString(
'en-US',
{
year: 'numeric',
month: 'long',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}
)}
</span>
</div>
</div>
</div>
</div> </div>
)} </div>
{activeTab === 'members' && (
<div className="space-y-4">
{/* Accepted Members */}
<div>
<h4 className="text-sm font-medium text-neutral-700 mb-3">
Active Members ({acceptedMembers.length})
</h4>
<div className="space-y-2">
{acceptedMembers.map((member) => (
<div
key={member.id}
className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg"
>
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden">
{member.user.avatar ? (
<img
src={member.user.avatar}
alt={member.user.fullname}
className="w-full h-full object-cover"
/>
) : (
<UserOutlined className="text-neutral-500" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-neutral-900">
{member.user.fullname}
</p>
{member.role === 'leader' && (
<CrownOutlined className="text-yellow-600 text-xs" />
)}
</div>
<p className="text-xs text-neutral-500">
{member.user.email}
</p>
{member.user.skills.length > 0 && (
<div className="flex flex-wrap gap-1 mt-1">
{member.user.skills
.slice(0, 2)
.map((skill, idx) => (
<span
key={idx}
className="px-1 py-0.5 bg-primary-100 text-primary-700 text-xs rounded"
>
{skill}
</span>
))}
{member.user.skills.length > 2 && (
<span className="text-xs text-neutral-400">
+{member.user.skills.length - 2}
</span>
)}
</div>
)}
</div>
<div className="text-xs text-neutral-500">
Joined{' '}
{new Date(member.joined_at).toLocaleDateString()}
</div>
</div>
))}
</div>
</div>
{/* Pending Members */}
{pendingMembers.length > 0 && (
<div>
<h4 className="text-sm font-medium text-neutral-700 mb-3">
Pending Members ({pendingMembers.length})
</h4>
<div className="space-y-2">
{pendingMembers.map((member) => (
<div
key={member.id}
className="flex items-center gap-3 p-3 bg-orange-50 border border-orange-200 rounded-lg"
>
<div className="w-10 h-10 rounded-full bg-neutral-200 flex items-center justify-center overflow-hidden">
{member.user.avatar ? (
<img
src={member.user.avatar}
alt={member.user.fullname}
className="w-full h-full object-cover"
/>
) : (
<UserOutlined className="text-neutral-500" />
)}
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-neutral-900">
{member.user.fullname}
</p>
<ClockCircleOutlined className="text-orange-600 text-xs" />
</div>
<p className="text-xs text-neutral-500">
{member.user.email}
</p>
</div>
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
className="flex items-center gap-1"
onClick={() =>
console.log('Accept member:', member.id)
}
>
<CheckCircleOutlined className="text-xs" />
Accept
</Button>
<Button
variant="secondary"
size="sm"
className="flex items-center gap-1"
onClick={() =>
console.log('Reject member:', member.id)
}
>
<CloseCircleOutlined className="text-xs" />
Reject
</Button>
</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div> </div>
</div> </div>
)} )}
@@ -772,46 +466,44 @@ const ModalTeamDetail: FC<ModalProps> = ({ isOpen, onClose, team }) => {
{/* Delete Confirmation Modal */} {/* Delete Confirmation Modal */}
{showDeleteConfirm && ( {showDeleteConfirm && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-60 p-4">
<div className="bg-white rounded-lg shadow-xl w-full max-w-md"> <div className="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
<div className="p-6"> <div className="flex items-center gap-4 mb-4">
<div className="flex items-center gap-4 mb-4"> <div className="w-12 h-12 rounded-full bg-danger-100 flex items-center justify-center">
<div className="w-12 h-12 rounded-full bg-danger-100 flex items-center justify-center"> <ExclamationOutlined className="text-danger-600 text-xl" />
<ExclamationOutlined className="text-danger-600 text-xl" />
</div>
<div>
<h3 className="text-lg font-semibold text-neutral-900">
Delete Team
</h3>
<p className="text-sm text-neutral-500">
This action cannot be undone.
</p>
</div>
</div> </div>
<div>
<p className="text-sm text-neutral-700 mb-6"> <h3 className="text-lg font-semibold text-neutral-900">
Are you sure you want to delete "{team?.name}"? This will
permanently remove the team and all associated data.
</p>
<div className="flex items-center gap-3 justify-end">
<Button
variant="secondary"
size="md"
onClick={() => setShowDeleteConfirm(false)}
>
Cancel
</Button>
<Button
variant="danger"
size="md"
onClick={handleDelete}
className="flex items-center gap-2"
>
<DeleteOutlined />
Delete Team Delete Team
</Button> </h3>
<p className="text-sm text-neutral-500">
This action cannot be undone.
</p>
</div> </div>
</div> </div>
<p className="text-sm text-neutral-700 mb-6">
Are you sure you want to delete "{team?.name}"? This will
permanently remove the team and all associated data.
</p>
<div className="flex items-center gap-3 justify-end">
<Button
variant="secondary"
size="md"
onClick={() => setShowDeleteConfirm(false)}
>
Cancel
</Button>
<Button
variant="danger"
size="md"
onClick={handleDelete}
className="flex items-center gap-2"
>
<DeleteOutlined />
Delete Team
</Button>
</div>
</div> </div>
</div> </div>
)} )}
@@ -1,8 +1,14 @@
import { FC, ReactElement, useState, useMemo, useCallback } from 'react'; import {
FC,
ReactElement,
useState,
useEffect,
useMemo,
useCallback,
} from 'react';
import ModalTeamDetail from './_components/modal-team-detail-new'; import ModalTeamDetail from './_components/modal-team-detail-new';
import SubmissionModal from './_components/submission-modal'; import SubmissionModal from './_components/submission-modal';
import { CityFilterSelect } from '../../../components/city-filter-select'; import { CityFilterSelect } from '../../../components/city-filter-select';
import INDONESIAN_CITIES from '../../../constants/cities';
import { import {
BackofficeWrapper, BackofficeWrapper,
DataTable, DataTable,
@@ -16,205 +22,121 @@ import {
SearchOutlined, SearchOutlined,
FilterOutlined, FilterOutlined,
PlusOutlined, PlusOutlined,
UserOutlined, LoadingOutlined,
ArrowRightOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { useQuery } from '@tanstack/react-query';
import {
getAdminTeams,
TAdminTeamItem,
} from '@imphnen-frontend-service/service';
import { useSearchParams } from 'react-router-dom';
// Define interface outside component type TeamType = TAdminTeamItem;
interface TeamMember {
id: string;
joined_at: string;
role: 'leader' | 'member';
status: 'pending' | 'accepted' | 'rejected';
team_id: string;
user: {
avatar?: string;
bio?: string;
created_at: string;
email: string;
fullname: string;
id: string;
is_active: boolean;
location: string;
phone_number?: string;
skills: string[];
updated_at: string;
};
user_id: string;
}
interface TeamType {
id: string;
name: string;
description?: string;
city: string;
banner?: string;
logo?: string;
visibility: 'public' | 'private';
member_count: number;
has_submission: boolean;
created_at: string;
updated_at: string;
leader_id: string;
members: TeamMember[];
}
// Move mock data outside component to prevent recreation
// Sample data for popular cities from the INDONESIAN_CITIES constant
const cities = INDONESIAN_CITIES.slice(0, 20); // Use first 20 cities for variety
const teamNames = [
'Innovators',
'Hackers',
'Builders',
'Creators',
'Pioneers',
'Developers',
'Engineers',
'Coders',
'Tech Stars',
'Digital Wizards',
];
const descriptions = [
'Building innovative solutions for modern problems with cutting-edge technology',
'Passionate developers creating the next generation of web applications',
'Focused on sustainable tech solutions that make a positive impact',
'Experienced team working on scalable fintech innovations',
'Creative minds developing user-centric mobile applications',
'Full-stack developers building comprehensive business solutions',
'AI enthusiasts creating intelligent automation tools',
'Open source advocates building community-driven platforms',
];
const skills = [
'Frontend Developer',
'Backend Developer',
'Full Stack Developer',
'DevOps Engineer',
'UI/UX Designer',
'Product Manager',
'Data Scientist',
'Mobile Developer',
];
const generateMembers = (
count: number,
teamId: string,
leaderId: string
): TeamMember[] => {
return Array.from({ length: count }, (_, i) => {
const isLeader = i === 0;
const memberId = isLeader ? leaderId : `user-${teamId}-${i}`;
return {
id: `member-${teamId}-${i}`,
joined_at: new Date(
Date.now() - (count - i) * 86400000 * Math.random() * 5
).toISOString(),
role: isLeader ? 'leader' : 'member',
status: Math.random() > 0.8 ? 'pending' : 'accepted',
team_id: teamId,
user: {
id: memberId,
avatar:
Math.random() > 0.6
? `https://ui-avatars.com/api/?name=${encodeURIComponent(
`User ${i}`
)}`
: undefined,
bio:
Math.random() > 0.5
? `Passionate ${skills[
Math.floor(Math.random() * skills.length)
].toLowerCase()} with ${
Math.floor(Math.random() * 8) + 1
}+ years experience`
: undefined,
created_at: new Date(
Date.now() - Math.random() * 365 * 86400000
).toISOString(),
email: `user${i}.team${teamId}@example.com`,
fullname: `${
['Ahmad', 'Sofia', 'Budi', 'Sari', 'Rizki', 'Maya', 'Andi', 'Dina'][
Math.floor(Math.random() * 8)
]
} ${
[
'Wijuana',
'Santoso',
'Pratama',
'Dewi',
'Nugroho',
'Sari',
'Putra',
'Lestari',
][Math.floor(Math.random() * 8)]
}`,
is_active: true,
location: cities[Math.floor(Math.random() * cities.length)],
phone_number:
Math.random() > 0.7
? `+62${Math.floor(Math.random() * 9000000000) + 1000000000}`
: undefined,
skills: skills.slice(0, Math.floor(Math.random() * 3) + 1),
updated_at: new Date().toISOString(),
},
user_id: memberId,
};
});
};
const mockData: TeamType[] = Array.from({ length: 50 }, (_, i) => {
const teamId = `team-${String(i + 1).padStart(3, '0')}`;
const memberCount = Math.floor(Math.random() * 5) + 1; // 1-5 members
const leaderId = `leader-${teamId}`;
const members = generateMembers(memberCount, teamId, leaderId);
return {
id: teamId,
name: `Team ${teamNames[i % teamNames.length]} ${
Math.floor(i / teamNames.length) + 1
}`,
description:
i % 4 === 0 ? undefined : descriptions[i % descriptions.length],
city: cities[i % cities.length],
banner:
i % 3 === 0 ? undefined : `https://picsum.photos/600/200?random=${i}`, // 3:1 aspect ratio
logo:
i % 4 === 0
? undefined
: `https://ui-avatars.com/api/?name=${encodeURIComponent(
teamNames[i % teamNames.length]
)}&background=random&size=120`,
visibility: i % 4 === 0 ? 'private' : 'public',
member_count: memberCount,
has_submission: i % 3 !== 0,
created_at: new Date(
Date.now() - i * 86400000 * (Math.random() * 15 + 1)
).toISOString(),
updated_at: new Date().toISOString(),
leader_id: leaderId,
members: members,
};
});
export const HackathonTeamsPage: FC = (): ReactElement => { export const HackathonTeamsPage: 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 [showNewTeamModal, setShowNewTeamModal] = useState(false); const [showNewTeamModal, setShowNewTeamModal] = useState(false);
const [showSubmissionModal, setShowSubmissionModal] = useState(false); const [showSubmissionModal, setShowSubmissionModal] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<TeamType | null>(null); const [selectedTeam, setSelectedTeam] = useState<TeamType | null>(null);
const [selectedSubmissionTeam, setSelectedSubmissionTeam] = const [selectedSubmissionTeam, setSelectedSubmissionTeam] =
useState<TeamType | null>(null); useState<TeamType | null>(null);
const [globalFilter, setGlobalFilter] = useState(''); const [globalFilter, setGlobalFilter] = useState(searchQuery);
// Advanced filtering states // Advanced filtering states
const [visibilityFilter, setVisibilityFilter] = useState('all'); const [visibilityFilter, setVisibilityFilter] = useState('all');
const [cityFilter, setCityFilter] = useState('all'); const [cityFilter, setCityFilter] = useState('all');
const [submissionFilter, setSubmissionFilter] = useState('all');
const [memberCountFilter, setMemberCountFilter] = useState('all');
// Constants // Fetch teams from API
const pageSize = 10; 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 totalData = teamsResponse?.meta?.total_data || 0;
const totalPages = teamsResponse?.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 submission
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((team: TeamType) => { const handleShowDetailModal = useCallback((team: TeamType) => {
@@ -235,67 +157,15 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
setShowNewTeamModal(false); setShowNewTeamModal(false);
}, []); }, []);
const handleShowSubmissionModal = useCallback((team: TeamType) => {
setSelectedSubmissionTeam(team);
setShowSubmissionModal(true);
}, []);
const handleCloseSubmissionModal = useCallback(() => { const handleCloseSubmissionModal = useCallback(() => {
setShowSubmissionModal(false); setShowSubmissionModal(false);
setSelectedSubmissionTeam(null); setSelectedSubmissionTeam(null);
}, []); }, []);
// Filter data based on current filter states // Get teams data from API response
const filteredData = useMemo(() => { const filteredData = useMemo(() => {
return mockData.filter((team) => { return teamsResponse?.data || [];
// Global search filter }, [teamsResponse]);
if (globalFilter) {
const searchTerm = globalFilter.toLowerCase();
const leaderName =
team.members.find((m) => m.role === 'leader')?.user.fullname || '';
const memberNames = team.members.map((m) => m.user.fullname).join(' ');
if (
!team.name.toLowerCase().includes(searchTerm) &&
!team.city.toLowerCase().includes(searchTerm) &&
!leaderName.toLowerCase().includes(searchTerm) &&
!memberNames.toLowerCase().includes(searchTerm)
) {
return false;
}
}
// Visibility filter
if (visibilityFilter !== 'all' && team.visibility !== visibilityFilter) {
return false;
}
// City filter
if (cityFilter !== 'all' && team.city !== cityFilter) {
return false;
}
// Submission filter
if (submissionFilter !== 'all') {
const hasSubmission = submissionFilter === 'submitted';
if (team.has_submission !== hasSubmission) return false;
}
// Member count filter
if (memberCountFilter !== 'all') {
const count = parseInt(memberCountFilter);
if (team.member_count !== count) return false;
}
return true;
});
}, [
globalFilter,
visibilityFilter,
cityFilter,
submissionFilter,
memberCountFilter,
]);
// Memoize columns to prevent recreation on every render // Memoize columns to prevent recreation on every render
const columns: ColumnDef<TeamType>[] = useMemo( const columns: ColumnDef<TeamType>[] = useMemo(
@@ -319,9 +189,12 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
<TeamOutlined className="text-neutral-400 text-lg" /> <TeamOutlined className="text-neutral-400 text-lg" />
)} )}
</div> </div>
{/* Team Name & Description */} {/* Team Name */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<p className="font-medium text-neutral-900 truncate"> <p
className="font-medium text-neutral-900 truncate max-w-sm"
title={team.name}
>
{team.name} {team.name}
</p> </p>
</div> </div>
@@ -359,81 +232,15 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
enableSorting: true, enableSorting: true,
}, },
{ {
accessorKey: 'member_count', id: 'leader',
header: 'Members', header: 'Leader ID',
cell: ({ row }) => ( cell: ({ row }) => (
<div className="flex items-center gap-2"> <div className="text-sm text-neutral-700 font-mono">
<UserOutlined className="text-neutral-400 text-sm" /> {row.original.leader_id}
<span className="text-sm text-neutral-700">
{row.original.member_count}
</span>
</div> </div>
), ),
enableSorting: true,
},
{
id: 'leader',
header: 'Leader',
cell: ({ row }) => {
const leader = row.original.members.find(
(m) => m.role === 'leader'
)?.user;
return leader ? (
<div>
<div className="text-sm font-medium text-neutral-900">
{leader.fullname}
</div>
<div className="text-xs text-neutral-500">{leader.email}</div>
</div>
) : (
<span className="text-neutral-400 italic">No leader</span>
);
},
enableSorting: false, enableSorting: false,
}, },
{
accessorKey: 'has_submission',
header: 'Submission',
cell: ({ row }) => {
const hasSubmission = row.original.has_submission;
return (
<div className="flex items-center gap-2">
<div
className={cn(
'w-2 h-2 rounded-full',
hasSubmission ? 'bg-success-500' : 'bg-danger-500'
)}
/>
<div className="flex flex-col">
<span
className={cn(
'text-sm font-medium',
hasSubmission ? 'text-success-700' : 'text-danger-700'
)}
>
{hasSubmission ? 'Submitted' : 'Not Submitted'}
</span>
{hasSubmission && (
<button
className="text-xs text-primary-600 hover:text-primary-800 text-left cursor-pointer"
onClick={() => handleShowSubmissionModal(row.original)}
>
View Submission <ArrowRightOutlined />
</button>
)}
</div>
</div>
);
},
enableSorting: true,
sortingFn: (rowA, rowB) => {
const aSubmission = rowA.original.has_submission;
const bSubmission = rowB.original.has_submission;
if (aSubmission && !bSubmission) return -1;
if (!aSubmission && bSubmission) return 1;
return 0;
},
},
{ {
accessorKey: 'created_at', accessorKey: 'created_at',
header: 'Created', header: 'Created',
@@ -469,7 +276,7 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
enableSorting: false, enableSorting: false,
}, },
], ],
[handleShowDetailModal, handleShowSubmissionModal] [handleShowDetailModal]
); );
return ( return (
@@ -488,14 +295,31 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
<input <input
type="text" type="text"
className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none" className="border border-neutral-200 rounded-lg pl-10 pr-4 py-2.5 text-sm w-full sm:w-80 focus:border-primary-500 focus:outline-none"
placeholder="Search teams by name, city, or leader..." placeholder="Search teams by name or city..."
value={globalFilter} value={globalFilter}
onChange={(e) => setGlobalFilter(e.target.value)} onChange={(e) => setGlobalFilter(e.target.value)}
onKeyPress={handleSearchKeyPress}
/> />
</div> </div>
{/* Visibility 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>
{/* Visibility 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-40 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-40 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
@@ -506,47 +330,16 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
<option value="public">Public</option> <option value="public">Public</option>
<option value="private">Private</option> <option value="private">Private</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"
placeholder="Search cities..." placeholder="Search cities..."
allOptionLabel="All Cities" allOptionLabel="All Cities"
/> /> */}
{/* Member Count 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" />
<select
className="border border-neutral-200 rounded-lg pl-10 pr-10 py-2.5 text-sm w-full sm:w-55 focus:border-primary-500 focus:outline-none appearance-none bg-white cursor-pointer"
value={memberCountFilter}
onChange={(e) => setMemberCountFilter(e.target.value)}
>
<option value="all">All Member Count</option>
<option value="1">1 Member</option>
<option value="2">2 Members</option>
<option value="3">3 Members</option>
<option value="4">4 Members</option>
<option value="5">5 Members</option>
</select>
</div>
{/* Submission 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" />
<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"
value={submissionFilter}
onChange={(e) => setSubmissionFilter(e.target.value)}
>
<option value="all">All Submissions</option>
<option value="submitted">Submitted</option>
<option value="not_submitted">Not Submitted</option>
</select>
</div>
</div> </div>
{/* Right side - Add Team Button */} {/* Right side - Add Team Button */}
@@ -564,10 +357,7 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
</div> </div>
{/* Active filters display */} {/* Active filters display */}
{(visibilityFilter !== 'all' || {(visibilityFilter !== 'all' || cityFilter !== 'all') && (
cityFilter !== 'all' ||
submissionFilter !== 'all' ||
memberCountFilter !== 'all') && (
<div className="flex flex-wrap gap-2 items-center"> <div className="flex flex-wrap gap-2 items-center">
<span className="text-sm text-neutral-600">Active filters:</span> <span className="text-sm text-neutral-600">Active filters:</span>
@@ -597,32 +387,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
</span> </span>
)} )}
{/* Member count filter badge */}
{memberCountFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-blue-100 text-blue-800 rounded-2xl text-sm">
Members: {memberCountFilter}
<button
onClick={() => setMemberCountFilter('all')}
className="text-blue-600 hover:text-blue-800 cursor-pointer"
>
</button>
</span>
)}
{/* Submission filter badge */}
{submissionFilter !== 'all' && (
<span className="inline-flex items-center gap-1 px-2 py-1 bg-purple-100 text-purple-800 rounded-2xl text-sm">
Submission: {submissionFilter}
<button
onClick={() => setSubmissionFilter('all')}
className="text-purple-600 hover:text-purple-800 cursor-pointer"
>
</button>
</span>
)}
{/* Clear all filters */} {/* Clear all filters */}
<Button <Button
variant="secondary" variant="secondary"
@@ -630,8 +394,6 @@ export const HackathonTeamsPage: FC = (): ReactElement => {
onClick={() => { onClick={() => {
setVisibilityFilter('all'); setVisibilityFilter('all');
setCityFilter('all'); setCityFilter('all');
setSubmissionFilter('all');
setMemberCountFilter('all');
setGlobalFilter(''); setGlobalFilter('');
}} }}
className="text-sm text-neutral-600" className="text-sm text-neutral-600"
@@ -641,17 +403,36 @@ export const HackathonTeamsPage: 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} teams <span className="ml-3 text-neutral-600">Loading teams...</span>
{filteredData.length > pageSize} </div>
) : filteredData.length > 0 ? (
<>
<div className="text-sm text-neutral-600">
Showing {filteredData.length} of {totalData} teams (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 teams found. Try adjusting your filters.
</div> </div>
)} )}
{/* Table */}
<DataTable data={filteredData} columns={columns} pageSize={10} />
</section> </section>
{/* Modals component */} {/* Modals component */}