import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; import { createPortal } from 'react-dom'; import { useSelector } from 'react-redux'; import { PlusOutlined, UserAddOutlined } from '@ant-design/icons'; import { RootState } from '@/app/store'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types'; import { InlineMember } from '@/types/teamMembers/inlineMember.types'; import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; import { useAuthService } from '@/hooks/useAuth'; import { Avatar, Button, Checkbox } from '@/components'; import { sortTeamMembers } from '@/utils/sort-team-members'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice'; import { updateTask } from '@/features/task-management/task-management.slice'; import { updateEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice'; interface AssigneeSelectorProps { task: IProjectTask; groupId?: string | null; isDarkMode?: boolean; } const AssigneeSelector: React.FC = ({ task, groupId = null, isDarkMode = false, }) => { const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [teamMembers, setTeamMembers] = useState({ data: [], total: 0 }); const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }); const [optimisticAssignees, setOptimisticAssignees] = useState([]); // For optimistic updates const [pendingChanges, setPendingChanges] = useState>(new Set()); // Track pending member changes const dropdownRef = useRef(null); const buttonRef = useRef(null); const searchInputRef = useRef(null); const { projectId } = useSelector((state: RootState) => state.projectReducer); const members = useSelector((state: RootState) => state.teamMembersReducer.teamMembers); const currentSession = useAuthService().getCurrentSession(); const { socket } = useSocket(); const dispatch = useAppDispatch(); const filteredMembers = useMemo(() => { return teamMembers?.data?.filter(member => member.name?.toLowerCase().includes(searchQuery.toLowerCase()) ); }, [teamMembers, searchQuery]); // Update dropdown position const updateDropdownPosition = useCallback(() => { if (buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect(); setDropdownPosition({ top: rect.bottom + window.scrollY + 2, left: rect.left + window.scrollX, }); } }, []); // Close dropdown when clicking outside and handle scroll useEffect(() => { const handleClickOutside = (event: MouseEvent) => { if ( dropdownRef.current && !dropdownRef.current.contains(event.target as Node) && buttonRef.current && !buttonRef.current.contains(event.target as Node) ) { setIsOpen(false); } }; const handleScroll = () => { if (isOpen) { // Check if the button is still visible in the viewport if (buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect(); const isVisible = rect.top >= 0 && rect.left >= 0 && rect.bottom <= window.innerHeight && rect.right <= window.innerWidth; if (isVisible) { updateDropdownPosition(); } else { // Hide dropdown if button is not visible setIsOpen(false); } } } }; const handleResize = () => { if (isOpen) { updateDropdownPosition(); } }; if (isOpen) { document.addEventListener('mousedown', handleClickOutside); window.addEventListener('scroll', handleScroll, true); window.addEventListener('resize', handleResize); return () => { document.removeEventListener('mousedown', handleClickOutside); window.removeEventListener('scroll', handleScroll, true); window.removeEventListener('resize', handleResize); }; } else { document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); } }, [isOpen, updateDropdownPosition]); const handleDropdownToggle = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); if (!isOpen) { updateDropdownPosition(); // Prepare team members data when opening const assignees = task?.assignees?.map(assignee => assignee.team_member_id); const membersData = (members?.data || []).map(member => ({ ...member, selected: assignees?.includes(member.id), })); const sortedMembers = sortTeamMembers(membersData); setTeamMembers({ data: sortedMembers }); setIsOpen(true); // Focus search input after opening setTimeout(() => { searchInputRef.current?.focus(); }, 0); } else { setIsOpen(false); } }; const handleMemberToggle = (memberId: string, checked: boolean) => { if (!memberId || !projectId || !task?.id || !currentSession?.id) return; // Add to pending changes for visual feedback setPendingChanges(prev => new Set(prev).add(memberId)); // OPTIMISTIC UPDATE: Update local state immediately for instant UI feedback const currentAssignees = task?.assignees?.map(a => a.team_member_id) || []; let newAssigneeIds: string[]; if (checked) { // Adding assignee newAssigneeIds = [...currentAssignees, memberId]; } else { // Removing assignee newAssigneeIds = currentAssignees.filter(id => id !== memberId); } // Update optimistic state for immediate UI feedback in dropdown setOptimisticAssignees(newAssigneeIds); // Update local team members state for dropdown UI setTeamMembers(prev => ({ ...prev, data: (prev.data || []).map(member => member.id === memberId ? { ...member, selected: checked } : member ), })); const body = { team_member_id: memberId, project_id: projectId, task_id: task.id, reporter_id: currentSession.id, mode: checked ? 0 : 1, parent_task: task.parent_task_id, }; // Emit socket event - the socket handler will update Redux with proper types socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body)); socket?.once(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), (data: any) => { dispatch(updateEnhancedKanbanTaskAssignees(data)); }); // Remove from pending changes after a short delay (optimistic) setTimeout(() => { setPendingChanges(prev => { const newSet = new Set(prev); newSet.delete(memberId); return newSet; }); }, 500); // Remove pending state after 500ms }; const checkMemberSelected = (memberId: string) => { if (!memberId) return false; // Use optimistic assignees if available, otherwise fall back to task assignees const assignees = optimisticAssignees.length > 0 ? optimisticAssignees : task?.assignees?.map(assignee => assignee.team_member_id) || []; return assignees.includes(memberId); }; const handleInviteProjectMemberDrawer = () => { setIsOpen(false); // Close the assignee dropdown first dispatch(toggleProjectMemberDrawer()); // Then open the invite drawer }; return ( <> {isOpen && createPortal(
e.stopPropagation()} className={` fixed z-9999 w-72 rounded-md shadow-lg border ${isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200'} `} style={{ top: dropdownPosition.top, left: dropdownPosition.left, }} > {/* Header */}
setSearchQuery(e.target.value)} placeholder="Search members..." className={` w-full px-2 py-1 text-xs rounded border ${ isDarkMode ? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500' : 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500' } focus:outline-none focus:ring-1 focus:ring-blue-500 `} />
{/* Members List */}
{filteredMembers && filteredMembers.length > 0 ? ( filteredMembers.map(member => (
{ if (!member.pending_invitation) { const isSelected = checkMemberSelected(member.id || ''); handleMemberToggle(member.id || '', !isSelected); } }} style={{ // Add visual feedback for immediate response transition: 'all 0.15s ease-in-out', }} >
e.stopPropagation()}> handleMemberToggle(member.id || '', checked)} disabled={ member.pending_invitation || pendingChanges.has(member.id || '') } isDarkMode={isDarkMode} /> {pendingChanges.has(member.id || '') && (
)}
{member.name}
{member.email} {member.pending_invitation && ( (Pending) )}
)) ) : (
No members found
)}
{/* Footer */}
, document.body )} ); }; export default AssigneeSelector;