Merge branch 'develop' into hackathon/fix-ui

This commit is contained in:
Hafid Nur
2025-11-26 13:51:51 +07:00
11 changed files with 624 additions and 137 deletions
+169 -67
View File
@@ -1,7 +1,15 @@
import { FC, ReactElement, useState, useEffect } from 'react';
import { Button } from '@imphnen-frontend-service/ui/atoms';
import { Link, useParams, useNavigate } from 'react-router';
import { useTeamById, useTeamMembers, useInviteMember, useTeamJoinRequests, useRespondToJoinRequest, ETeamMemberRole, useAuthStore } from '@imphnen-frontend-service/service';
import {
useTeamById,
useTeamMembers,
useInviteMember,
useTeamJoinRequests,
useRespondToJoinRequest,
ETeamMemberRole,
useAuthStore,
} from '@imphnen-frontend-service/service';
import { toast } from 'sonner';
const MAX_TEAM_MEMBERS = 5;
@@ -18,12 +26,16 @@ const ImageWithLoader: FC<{
return (
<div className="relative">
{!isLoaded && (
<div className={`absolute inset-0 bg-gray-200 animate-pulse ${className}`} />
<div
className={`absolute inset-0 bg-gray-200 animate-pulse ${className}`}
/>
)}
<img
src={src}
alt={alt}
className={`${className} ${isLoaded ? 'opacity-100' : 'opacity-0'} transition-opacity duration-300`}
className={`${className} ${
isLoaded ? 'opacity-100' : 'opacity-0'
} transition-opacity duration-300`}
onLoad={() => {
setIsLoaded(true);
onLoad?.();
@@ -43,23 +55,36 @@ const TeamDashboardPage: FC = (): ReactElement => {
const [imagesLoaded, setImagesLoaded] = useState(false);
const [imageLoadCount, setImageLoadCount] = useState(0);
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(teamId || '');
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(teamId || '');
const { data: joinRequestsData } = useTeamJoinRequests(teamId || '', !!teamId);
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(teamId || '');
const { mutateAsync: respondToJoinRequest, isPending: isResponding } = useRespondToJoinRequest(teamId || '');
const { data: teamData, isLoading: isLoadingTeam } = useTeamById(
teamId || ''
);
const { data: membersData, isLoading: isLoadingMembers } = useTeamMembers(
teamId || ''
);
const { data: joinRequestsData } = useTeamJoinRequests(
teamId || '',
!!teamId
);
const { mutateAsync: inviteMember, isPending: isInviting } = useInviteMember(
teamId || ''
);
const { mutateAsync: respondToJoinRequest, isPending: isResponding } =
useRespondToJoinRequest(teamId || '');
const team = teamData?.data;
const members = membersData?.data || [];
const joinRequests = joinRequestsData?.data || [];
const pendingJoinRequests = joinRequests.filter((req: any) => req.status === 'pending');
const pendingJoinRequests = joinRequests.filter(
(req: any) => req.status === 'pending'
);
const currentUserId = session?.user?.id;
const isLeader = currentUserId === team?.leader_id;
const canInvite = isLeader && members.length < MAX_TEAM_MEMBERS;
// Calculate total images to load
const totalImagesToLoad = (team?.banner ? 1 : 0) +
const totalImagesToLoad =
(team?.banner ? 1 : 0) +
(team?.logo ? 1 : 0) +
members.filter((m: any) => m.user?.avatar).length;
@@ -72,7 +97,13 @@ const TeamDashboardPage: FC = (): ReactElement => {
setImagesLoaded(true);
}
}
}, [isLoadingTeam, isLoadingMembers, team, imageLoadCount, totalImagesToLoad]);
}, [
isLoadingTeam,
isLoadingMembers,
team,
imageLoadCount,
totalImagesToLoad,
]);
// Fallback timeout - if images take too long, show content anyway
useEffect(() => {
@@ -93,7 +124,11 @@ const TeamDashboardPage: FC = (): ReactElement => {
setImageLoadCount((prev) => prev + 1);
};
console.log('Leader check:', { currentUserId, leaderId: team?.leader_id, isLeader });
console.log('Leader check:', {
currentUserId,
leaderId: team?.leader_id,
isLeader,
});
const handleInviteMember = async (e: React.FormEvent) => {
e.preventDefault();
@@ -110,10 +145,15 @@ const TeamDashboardPage: FC = (): ReactElement => {
}
};
const handleRespondToJoinRequest = async (requestId: string, action: 'approve' | 'reject') => {
const handleRespondToJoinRequest = async (
requestId: string,
action: 'approve' | 'reject'
) => {
try {
await respondToJoinRequest({ requestId, action });
toast.success(action === 'approve' ? 'Request approved!' : 'Request rejected');
toast.success(
action === 'approve' ? 'Request approved!' : 'Request rejected'
);
} catch (error) {
console.error('Failed to respond to join request:', error);
toast.error('Failed to process request');
@@ -178,7 +218,9 @@ const TeamDashboardPage: FC = (): ReactElement => {
<div className="fixed inset-0 bg-white/60 flex items-center justify-center z-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600 font-medium">Loading team data...</p>
<p className="mt-4 text-gray-600 font-medium">
Loading team data...
</p>
</div>
</div>
</div>
@@ -188,8 +230,12 @@ const TeamDashboardPage: FC = (): ReactElement => {
if (!team) {
return (
<div className="flex flex-col items-center justify-center min-h-screen">
<h2 className="text-2xl font-bold text-gray-900 mb-4">Team not found</h2>
<Button onClick={() => navigate('/dashboard')}>Back to Dashboard</Button>
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Team not found
</h2>
<Button onClick={() => navigate('/dashboard')}>
Back to Dashboard
</Button>
</div>
);
}
@@ -202,7 +248,9 @@ const TeamDashboardPage: FC = (): ReactElement => {
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent"></div>
<p className="mt-4 text-gray-600 font-medium">Loading images...</p>
<p className="text-sm text-gray-400 mt-1">{imageLoadCount} / {totalImagesToLoad}</p>
<p className="text-sm text-gray-400 mt-1">
{imageLoadCount} / {totalImagesToLoad}
</p>
</div>
</div>
)}
@@ -210,20 +258,26 @@ const TeamDashboardPage: FC = (): ReactElement => {
{/* Header with Banner */}
<div className="bg-white border-b">
{team.banner && (
<div className="w-full h-48 overflow-hidden relative">
<div className={`absolute inset-0 bg-gray-200 animate-pulse ${imagesLoaded ? 'hidden' : ''}`} />
<div className="w-full h-32 md:h-48 overflow-hidden relative">
<div
className={`absolute inset-0 bg-gray-200 animate-pulse ${
imagesLoaded ? 'hidden' : ''
}`}
/>
<img
src={team.banner}
alt={team.name}
className={`w-full h-full object-cover transition-opacity duration-300 ${imagesLoaded ? 'opacity-100' : 'opacity-0'}`}
className={`w-full h-full object-cover transition-opacity duration-300 ${
imagesLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
/>
</div>
)}
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-6">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-3 md:space-x-4">
{team.logo && (
<div className="relative">
{!imagesLoaded && (
@@ -232,51 +286,64 @@ const TeamDashboardPage: FC = (): ReactElement => {
<img
src={team.logo}
alt={team.name}
className={`w-20 h-20 rounded-full object-cover border-4 border-white shadow-lg -mt-10 transition-opacity duration-300 ${imagesLoaded ? 'opacity-100' : 'opacity-0'}`}
className={`w-16 h-16 md:w-20 md:h-20 rounded-full object-cover border-4 border-white shadow-lg -mt-8 md:-mt-10 transition-opacity duration-300 ${
imagesLoaded ? 'opacity-100' : 'opacity-0'
}`}
onLoad={handleImageLoad}
onError={handleImageError}
/>
</div>
)}
<div>
<h1 className="text-3xl font-bold text-gray-900">{team.name}</h1>
<p className="text-gray-600 mt-1">📍 {team.city}</p>
<div className="flex items-center space-x-4 mt-2">
<h1 className="text-xl md:text-3xl font-bold text-gray-900 line-clamp-2">
{team.name}
</h1>
<p className="text-sm md:text-base text-gray-600 mt-1 line-clamp-1">
📍 {team.city}
</p>
<div className="flex flex-wrap items-center gap-2 md:space-x-4 mt-2">
<span className="text-sm text-gray-500">
{members.length} {members.length === 1 ? 'Member' : 'Members'}
{members.length}{' '}
{members.length === 1 ? 'Member' : 'Members'}
</span>
<span className="text-sm text-gray-500">
{team.visibility === 'public' ? '🌐 Public' : '🔒 Private'}
</span>
{isLeader && (
<span className="px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-semibold shadow-sm">
<span className="hidden md:inline-flex px-3 py-1 bg-blue-600 text-white rounded-md text-sm font-semibold shadow-sm">
👑 Team Leader
</span>
)}
</div>
</div>
</div>
<Link to="/dashboard">
<Link to="/dashboard" className="hidden md:block">
<Button variant="secondary">Back to Dashboard</Button>
</Link>
</div>
</div>
</div>
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="grid gap-6 lg:grid-cols-3">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4 md:py-8">
<div className="grid gap-4 md:gap-6 lg:grid-cols-3">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6">
<div className="lg:col-span-2 space-y-4 md:space-y-6">
{/* Team Description */}
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">About Team</h2>
<p className="text-gray-700 whitespace-pre-wrap">{team.description}</p>
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 mb-3 md:mb-4">
About Team
</h2>
<p className="text-gray-700 whitespace-pre-wrap">
{team.description}
</p>
</div>
{/* Team Actions - Only for Leader */}
{isLeader && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">Team Management</h2>
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 mb-3 md:mb-4">
Team Management
</h2>
<div className="grid gap-3 sm:grid-cols-2">
<Button
className="w-full"
@@ -284,7 +351,8 @@ const TeamDashboardPage: FC = (): ReactElement => {
onClick={() => setShowInviteModal(true)}
disabled={!canInvite}
>
Invite Member {!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
Invite Member{' '}
{!canInvite && `(${members.length}/${MAX_TEAM_MEMBERS})`}
</Button>
<Button
className="w-full relative"
@@ -314,9 +382,7 @@ const TeamDashboardPage: FC = (): ReactElement => {
</Button>
</Link>
<Link to={`/teams/${teamId}/submit`}>
<Button className="w-full">
🚀 Submit Project
</Button>
<Button className="w-full">🚀 Submit Project</Button>
</Link>
</div>
{!canInvite && members.length >= MAX_TEAM_MEMBERS && (
@@ -329,8 +395,10 @@ const TeamDashboardPage: FC = (): ReactElement => {
{/* Quick Actions for Members */}
{!isLeader && (
<div className="bg-white rounded-lg shadow-md p-6">
<h2 className="text-xl font-bold text-gray-900 mb-4">Quick Actions</h2>
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
<h2 className="text-lg md:text-xl font-bold text-gray-900 mb-3 md:mb-4">
Quick Actions
</h2>
<div className="grid gap-3 sm:grid-cols-2">
<Link to={`/teams/${teamId}/chat`}>
<Button className="w-full" variant="secondary">
@@ -350,11 +418,13 @@ const TeamDashboardPage: FC = (): ReactElement => {
{/* Submission Status */}
{team.has_submission && (
<div className="bg-green-50 border border-green-200 rounded-lg p-6">
<div className="bg-green-50 border border-green-200 rounded-lg p-4 md:p-6">
<div className="flex items-center space-x-3">
<span className="text-3xl"></span>
<div>
<h3 className="font-bold text-green-900">Project Submitted</h3>
<h3 className="font-bold text-green-900">
Project Submitted
</h3>
<p className="text-green-700 text-sm">
Your team has successfully submitted a project
</p>
@@ -370,12 +440,17 @@ const TeamDashboardPage: FC = (): ReactElement => {
</div>
{/* Sidebar */}
<div className="space-y-6">
<div className="space-y-4 md:space-y-6">
{/* Team Leader */}
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="font-bold text-gray-900 mb-4">Team Leader</h3>
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
<h3 className="text-base md:text-lg font-bold text-gray-900 mb-3 md:mb-4">
Team Leader
</h3>
{team.leader && (
<div className="flex items-center space-x-3">
<button
onClick={() => navigate(`/users/${team.leader.id}`)}
className="w-full flex items-center space-x-3 hover:bg-gray-100 rounded-lg p-2 transition-colors text-left cursor-pointer"
>
{team.leader.avatar ? (
<img
src={team.leader.avatar}
@@ -388,21 +463,27 @@ const TeamDashboardPage: FC = (): ReactElement => {
</div>
)}
<div>
<p className="font-medium text-gray-900">{team.leader.fullname}</p>
<p className="font-medium text-gray-900">
{team.leader.fullname}
</p>
<p className="text-sm text-gray-600">{team.leader.email}</p>
</div>
</div>
</button>
)}
</div>
{/* Team Members */}
<div className="bg-white rounded-lg shadow-md p-6">
<h3 className="font-bold text-gray-900 mb-4">
<div className="bg-white rounded-lg shadow-md p-4 md:p-6">
<h3 className="text-base md:text-lg font-bold text-gray-900 mb-3 md:mb-4">
Members ({members.length})
</h3>
<div className="space-y-3">
{members.map((member: any) => (
<div key={member.id} className="flex items-center space-x-3">
<button
key={member.id}
onClick={() => navigate(`/users/${member.user.id}`)}
className="w-full flex items-center space-x-3 hover:bg-gray-100 rounded-lg p-2 transition-colors text-left cursor-pointer"
>
{member.user?.avatar ? (
<img
src={member.user.avatar}
@@ -421,10 +502,12 @@ const TeamDashboardPage: FC = (): ReactElement => {
{member.user?.fullname || 'Unknown'}
</p>
<p className="text-xs text-gray-500">
{member.role === ETeamMemberRole.LEADER ? 'Leader' : 'Member'}
{member.role === ETeamMemberRole.LEADER
? 'Leader'
: 'Member'}
</p>
</div>
</div>
</button>
))}
</div>
</div>
@@ -440,16 +523,21 @@ const TeamDashboardPage: FC = (): ReactElement => {
Invite Team Member
</h2>
<p className="text-gray-600 mb-4">
Send an invitation to join your team. The invited member will see the invitation on their dashboard after logging in.
Send an invitation to join your team. The invited member will see
the invitation on their dashboard after logging in.
</p>
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-3 mb-6">
<p className="text-sm text-yellow-800">
<strong>Important:</strong> The email you enter must match the GitHub email address the member uses to sign in.
<strong>Important:</strong> The email you enter must match the
GitHub email address the member uses to sign in.
</p>
</div>
<form onSubmit={handleInviteMember} className="space-y-4">
<div>
<label htmlFor="invite-email" className="block text-sm font-medium text-gray-700 mb-2">
<label
htmlFor="invite-email"
className="block text-sm font-medium text-gray-700 mb-2"
>
Email Address
</label>
<input
@@ -508,7 +596,9 @@ const TeamDashboardPage: FC = (): ReactElement => {
{pendingJoinRequests.length === 0 ? (
<div className="text-center py-12">
<p className="text-gray-600 text-lg">No pending join requests</p>
<p className="text-gray-600 text-lg">
No pending join requests
</p>
<p className="text-gray-500 text-sm mt-2">
When users request to join your team, they'll appear here
</p>
@@ -516,7 +606,10 @@ const TeamDashboardPage: FC = (): ReactElement => {
) : (
<div className="space-y-4">
{pendingJoinRequests.map((request: any) => (
<div key={request.id} className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow">
<div
key={request.id}
className="border border-gray-200 rounded-lg p-4 hover:shadow-md transition-shadow"
>
<div className="flex items-start justify-between">
<div className="flex items-start space-x-3 flex-1">
{request.user?.avatar ? (
@@ -545,20 +638,27 @@ const TeamDashboardPage: FC = (): ReactElement => {
</div>
)}
<p className="text-xs text-gray-500 mt-2">
Requested {new Date(request.created_at).toLocaleDateString()}
Requested{' '}
{new Date(request.created_at).toLocaleDateString()}
</p>
</div>
</div>
<div className="flex space-x-2 ml-4">
<Button
onClick={() => handleRespondToJoinRequest(request.id, 'approve')}
disabled={isResponding || members.length >= MAX_TEAM_MEMBERS}
onClick={() =>
handleRespondToJoinRequest(request.id, 'approve')
}
disabled={
isResponding || members.length >= MAX_TEAM_MEMBERS
}
className="px-4 py-2 text-sm"
>
✓ Accept
</Button>
<Button
onClick={() => handleRespondToJoinRequest(request.id, 'reject')}
onClick={() =>
handleRespondToJoinRequest(request.id, 'reject')
}
disabled={isResponding}
variant="secondary"
className="px-4 py-2 text-sm"
@@ -570,7 +670,9 @@ const TeamDashboardPage: FC = (): ReactElement => {
{members.length >= MAX_TEAM_MEMBERS && (
<div className="mt-3 bg-yellow-50 border border-yellow-200 rounded-lg p-2">
<p className="text-xs text-yellow-800">
Team is full ({MAX_TEAM_MEMBERS}/{MAX_TEAM_MEMBERS} members). Remove a member before accepting new requests.
Team is full ({MAX_TEAM_MEMBERS}/{MAX_TEAM_MEMBERS}{' '}
members). Remove a member before accepting new
requests.
</p>
</div>
)}