diff --git a/worklenz-frontend/src/components/AssigneeSelector.tsx b/worklenz-frontend/src/components/AssigneeSelector.tsx index 261588b9..9c780bd7 100644 --- a/worklenz-frontend/src/components/AssigneeSelector.tsx +++ b/worklenz-frontend/src/components/AssigneeSelector.tsx @@ -5,6 +5,7 @@ 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'; @@ -12,6 +13,7 @@ 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'; interface AssigneeSelectorProps { task: IProjectTask; @@ -28,6 +30,8 @@ const AssigneeSelector: React.FC = ({ 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); @@ -134,6 +138,34 @@ const AssigneeSelector: React.FC = ({ 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, @@ -143,13 +175,26 @@ const AssigneeSelector: React.FC = ({ 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)); + + // 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; - const assignees = task?.assignees?.map(assignee => assignee.team_member_id); - return assignees?.includes(memberId) || 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 = () => { @@ -233,13 +278,28 @@ const AssigneeSelector: React.FC = ({ handleMemberToggle(member.id || '', !isSelected); } }} + style={{ + // Add visual feedback for immediate response + transition: 'all 0.15s ease-in-out', + }} > - handleMemberToggle(member.id || '', checked)} - disabled={member.pending_invitation} - isDarkMode={isDarkMode} - /> +
+ handleMemberToggle(member.id || '', checked)} + disabled={member.pending_invitation || pendingChanges.has(member.id || '')} + isDarkMode={isDarkMode} + /> + {pendingChanges.has(member.id || '') && ( +
+
+
+ )} +
= React.memo(({ // PERFORMANCE OPTIMIZATION: Simplified column rendering for initial load const renderColumnSimple = useCallback((col: { key: string; width: number }, isFixed: boolean, index: number, totalColumns: number) => { - const isLast = index === totalColumns - 1; - const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`; + // Fix border logic: if this is a fixed column, only consider it "last" if there are no scrollable columns + // If this is a scrollable column, use the normal logic + const isActuallyLast = isFixed + ? (index === totalColumns - 1 && (!scrollableColumns || scrollableColumns.length === 0)) + : (index === totalColumns - 1); + const borderClasses = `${isActuallyLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`; // Only render essential columns during initial load switch (col.key) { @@ -527,8 +531,12 @@ const TaskRow: React.FC = React.memo(({ } // Full rendering logic (existing code) - const isLast = index === totalColumns - 1; - const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`; + // Fix border logic: if this is a fixed column, only consider it "last" if there are no scrollable columns + // If this is a scrollable column, use the normal logic + const isActuallyLast = isFixed + ? (index === totalColumns - 1 && (!scrollableColumns || scrollableColumns.length === 0)) + : (index === totalColumns - 1); + const borderClasses = `${isActuallyLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`; switch (col.key) { case 'drag': @@ -558,7 +566,14 @@ const TaskRow: React.FC = React.memo(({ case 'task': const cellStyle = editTaskName - ? { width: col.width, border: '1px solid #1890ff', background: isDarkMode ? '#232b3a' : '#f0f7ff', transition: 'border 0.2s' } + ? { + width: col.width, + borderTop: '1px solid #1890ff', + borderBottom: '1px solid #1890ff', + borderLeft: '1px solid #1890ff', + background: isDarkMode ? '#232b3a' : '#f0f7ff', + transition: 'border 0.2s' + } : { width: col.width }; return ( @@ -954,8 +969,9 @@ const TaskRow: React.FC = React.memo(({ }} > {fixedColumns.map((col, index) => { - const isLast = index === fixedColumns.length - 1; - const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`; + // Fix border logic for add subtask row: fixed columns should have right border if scrollable columns exist + const isActuallyLast = index === fixedColumns.length - 1 && (!scrollableColumns || scrollableColumns.length === 0); + const borderClasses = `${isActuallyLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`; if (col.key === 'task') { return (