diff --git a/worklenz-frontend/src/components/task-management/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx index 7df7af83..3f96b37d 100644 --- a/worklenz-frontend/src/components/task-management/task-group.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -308,7 +308,6 @@ const TaskGroup: React.FC = React.memo(({ No tasks in this group
+ + {/* Dropdown Menu */} + {isOpen && createPortal( +
+ {/* Phase Options */} +
+ {/* No Phase Option */} + + + {/* Phase Options */} + {phaseList.map((phase, index) => { + const isSelected = phase.name === task.phase; + + return ( + + ); + })} +
+
, + document.body + )} + + {/* CSS Animations */} + {isOpen && createPortal( + , + document.head + )} + + ); +}; + +export default TaskPhaseDropdown; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/task-priority-dropdown.tsx b/worklenz-frontend/src/components/task-management/task-priority-dropdown.tsx new file mode 100644 index 00000000..db157568 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/task-priority-dropdown.tsx @@ -0,0 +1,257 @@ +import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { Task } from '@/types/task-management.types'; +import { MinusOutlined, PauseOutlined, DoubleRightOutlined } from '@ant-design/icons'; + +interface TaskPriorityDropdownProps { + task: Task; + projectId: string; + isDarkMode?: boolean; +} + +const TaskPriorityDropdown: React.FC = ({ + task, + projectId, + isDarkMode = false +}) => { + const { socket, connected } = useSocket(); + const [isOpen, setIsOpen] = useState(false); + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }); + const buttonRef = useRef(null); + const dropdownRef = useRef(null); + + const priorityList = useAppSelector(state => state.priorityReducer.priorities); + + // Find current priority details + const currentPriority = useMemo(() => { + return priorityList.find(priority => + priority.name?.toLowerCase() === task.priority?.toLowerCase() || + priority.id === task.priority + ); + }, [priorityList, task.priority]); + + // Handle priority change + const handlePriorityChange = useCallback((priorityId: string, priorityName: string) => { + if (!task.id || !priorityId || !connected) return; + + console.log('🎯 Priority change initiated:', { taskId: task.id, priorityId, priorityName }); + + socket?.emit( + SocketEvents.TASK_PRIORITY_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + priority_id: priorityId, + team_id: projectId, // Using projectId as teamId + }) + ); + setIsOpen(false); + }, [task.id, connected, socket, projectId]); + + // Calculate dropdown position and handle outside clicks + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (buttonRef.current && buttonRef.current.contains(event.target as Node)) { + return; // Don't close if clicking the button + } + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + if (isOpen && buttonRef.current) { + // Calculate position + const rect = buttonRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + window.scrollY + 4, + left: rect.left + window.scrollX, + }); + + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [isOpen]); + + // Get priority color + const getPriorityColor = useCallback((priority: any) => { + if (isDarkMode) { + return priority?.color_code_dark || priority?.color_code || '#4b5563'; + } + return priority?.color_code || '#6b7280'; + }, [isDarkMode]); + + // Get priority icon + const getPriorityIcon = useCallback((priorityName: string) => { + const name = priorityName?.toLowerCase(); + switch (name) { + case 'low': + return ; + case 'medium': + return ; + case 'high': + return ; + default: + return ; + } + }, []); + + // Format priority name for display + const formatPriorityName = useCallback((name: string) => { + if (!name) return name; + return name.charAt(0).toUpperCase() + name.slice(1).toLowerCase(); + }, []); + + if (!task.priority) return null; + + return ( + <> + {/* Priority Button - Simple text display like status */} + + + {/* Dropdown Menu */} + {isOpen && createPortal( +
+ {/* Priority Options */} +
+ {priorityList.map((priority, index) => { + const isSelected = priority.name?.toLowerCase() === task.priority?.toLowerCase() || priority.id === task.priority; + + return ( + + ); + })} +
+
, + document.body + )} + + {/* CSS Animations */} + {isOpen && createPortal( + , + document.head + )} + + ); +}; + +export default TaskPriorityDropdown; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/task-row.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx index 32b80309..de8731b1 100644 --- a/worklenz-frontend/src/components/task-management/task-row.tsx +++ b/worklenz-frontend/src/components/task-management/task-row.tsx @@ -22,6 +22,8 @@ import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLa import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; import TaskStatusDropdown from './task-status-dropdown'; +import TaskPriorityDropdown from './task-priority-dropdown'; +import TaskPhaseDropdown from './task-phase-dropdown'; import { formatDate as utilFormatDate, formatDateTime as utilFormatDateTime, @@ -419,6 +421,24 @@ const TaskRow: React.FC = React.memo(({ ); + case 'priority': + return ( +
+
+ {task.priority || 'Medium'} +
+
+ ); + + case 'phase': + return ( +
+
+ {task.phase || 'No Phase'} +
+
+ ); + default: // For non-essential columns, show placeholder during initial load return ( @@ -580,10 +600,14 @@ const TaskRow: React.FC = React.memo(({ case 'phase': return ( -
- - {task.phase || 'No Phase'} - +
+
+ +
); @@ -602,8 +626,14 @@ const TaskRow: React.FC = React.memo(({ case 'priority': return ( -
- +
+
+ +
); diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index d28a1b31..899dc00e 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -226,6 +226,37 @@ const taskManagementSlice = createSlice({ tasksAdapter.addOne(state, action.payload); }, + + addTaskToGroup: (state, action: PayloadAction<{ task: Task; groupId?: string }>) => { + const { task, groupId } = action.payload; + + // Add to entity adapter + tasksAdapter.addOne(state, task); + + // Add to groups array for V3 API compatibility + if (state.groups && state.groups.length > 0) { + // Find the target group using the provided UUID + const targetGroup = state.groups.find(group => { + // If a specific groupId (UUID) is provided, use it directly + if (groupId && group.id === groupId) { + return true; + } + + return false; + }); + + if (targetGroup) { + // Add task ID to the end of the group's taskIds array (newest last) + targetGroup.taskIds.push(task.id); + + // Also add to the tasks array if it exists (for backward compatibility) + if ((targetGroup as any).tasks) { + (targetGroup as any).tasks.push(task); + } + } + } + }, + updateTask: (state, action: PayloadAction<{ id: string; changes: Partial }>) => { tasksAdapter.updateOne(state, { id: action.payload.id, @@ -290,6 +321,60 @@ const taskManagementSlice = createSlice({ tasksAdapter.updateOne(state, { id: taskId, changes }); }, + + // New action to move task between groups with proper group management + moveTaskBetweenGroups: (state, action: PayloadAction<{ + taskId: string; + fromGroupId: string; + toGroupId: string; + taskUpdate: Partial; + }>) => { + const { taskId, fromGroupId, toGroupId, taskUpdate } = action.payload; + + console.log('🔧 moveTaskBetweenGroups action:', { + taskId, + fromGroupId, + toGroupId, + taskUpdate, + hasGroups: !!state.groups, + groupsCount: state.groups?.length || 0 + }); + + // Update the task entity with new values + tasksAdapter.updateOne(state, { + id: taskId, + changes: { + ...taskUpdate, + updatedAt: new Date().toISOString(), + }, + }); + + // Update groups if they exist + if (state.groups && state.groups.length > 0) { + // Remove task from old group + const fromGroup = state.groups.find(group => group.id === fromGroupId); + if (fromGroup) { + const beforeCount = fromGroup.taskIds.length; + fromGroup.taskIds = fromGroup.taskIds.filter(id => id !== taskId); + console.log(`🔧 Removed task from ${fromGroup.title}: ${beforeCount} -> ${fromGroup.taskIds.length}`); + } else { + console.warn('🚨 From group not found:', fromGroupId); + } + + // Add task to new group + const toGroup = state.groups.find(group => group.id === toGroupId); + if (toGroup) { + const beforeCount = toGroup.taskIds.length; + // Add to the end of the group (newest last) + toGroup.taskIds.push(taskId); + console.log(`🔧 Added task to ${toGroup.title}: ${beforeCount} -> ${toGroup.taskIds.length}`); + } else { + console.warn('🚨 To group not found:', toGroupId); + } + } else { + console.warn('🚨 No groups available for task movement'); + } + }, // Optimistic update for drag operations - reduces perceived lag optimisticTaskMove: (state, action: PayloadAction<{ taskId: string; newGroupId: string; newIndex: number }>) => { @@ -392,12 +477,14 @@ const taskManagementSlice = createSlice({ export const { setTasks, addTask, + addTaskToGroup, updateTask, deleteTask, bulkUpdateTasks, bulkDeleteTasks, reorderTasks, moveTaskToGroup, + moveTaskBetweenGroups, optimisticTaskMove, setLoading, setError, diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index d040b86f..160fba41 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -33,10 +33,12 @@ import { updateSubTasks, updateTaskProgress, } from '@/features/tasks/tasks.slice'; -import { +import { addTask, + addTaskToGroup, updateTask, moveTaskToGroup, + moveTaskBetweenGroups, selectCurrentGroupingV3, fetchTasksV3 } from '@/features/task-management/task-management.slice'; @@ -136,14 +138,66 @@ export const useTaskSocketHandlers = () => { dispatch(updateTaskStatus(response)); dispatch(deselectAll()); - // For the task management slice, let's use a simpler approach: - // Just refetch the tasks to ensure consistency - if (response.id && projectId) { - console.log('🔄 Refetching tasks after status change to ensure consistency...'); - dispatch(fetchTasksV3(projectId)); + // For the task management slice, move task between groups without resetting + const state = store.getState(); + const groups = state.taskManagement.groups; + const currentTask = state.taskManagement.entities[response.id]; + + console.log('🔍 Status change debug:', { + hasGroups: !!groups, + groupsLength: groups?.length || 0, + hasCurrentTask: !!currentTask, + statusId: response.status_id, + currentGrouping: state.taskManagement.grouping + }); + + if (groups && groups.length > 0 && currentTask && response.status_id) { + // Find current group containing the task + const currentGroup = groups.find(group => group.taskIds.includes(response.id)); + + // Find target group based on new status ID + // The status_id from response is the UUID of the new status + const targetGroup = groups.find(group => group.id === response.status_id); + + if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) { + console.log('🔄 Moving task between groups:', { + taskId: response.id, + fromGroup: currentGroup.title, + toGroup: targetGroup.title + }); + + // Determine the new status value based on status category + let newStatusValue: 'todo' | 'doing' | 'done' = 'todo'; + if (response.statusCategory) { + if (response.statusCategory.is_done) { + newStatusValue = 'done'; + } else if (response.statusCategory.is_doing) { + newStatusValue = 'doing'; + } else { + newStatusValue = 'todo'; + } + } + + // Use the new action to move task between groups + dispatch(moveTaskBetweenGroups({ + taskId: response.id, + fromGroupId: currentGroup.id, + toGroupId: targetGroup.id, + taskUpdate: { + status: newStatusValue, + progress: response.complete_ratio || currentTask.progress, + } + })); + } else if (!currentGroup || !targetGroup) { + // Fallback to refetch if groups not found (shouldn't happen normally) + console.log('🔄 Groups not found, refetching tasks...'); + if (projectId) { + dispatch(fetchTasksV3(projectId)); + } + } } }, - [dispatch, currentGroupingV3] + [dispatch, currentGroupingV3, projectId] ); const handleTaskProgress = useCallback( @@ -186,15 +240,82 @@ export const useTaskSocketHandlers = () => { (response: ITaskListPriorityChangeResponse) => { if (!response) return; + console.log('🎯 Priority change received:', response); + // Update the old task slice (for backward compatibility) dispatch(updateTaskPriority(response)); dispatch(setTaskPriority(response)); dispatch(deselectAll()); - // For the task management slice, refetch tasks to ensure consistency - if (response.id && projectId) { - console.log('🔄 Refetching tasks after priority change...'); - dispatch(fetchTasksV3(projectId)); + // For the task management slice, always update the task entity first + const state = store.getState(); + const currentTask = state.taskManagement.entities[response.id]; + + if (currentTask) { + // Get priority list to map priority_id to priority name + const priorityList = state.priorityReducer?.priorities || []; + const priority = priorityList.find(p => p.id === response.priority_id); + + let newPriorityValue: 'critical' | 'high' | 'medium' | 'low' = 'medium'; + if (priority?.name) { + const priorityName = priority.name.toLowerCase(); + if (['critical', 'high', 'medium', 'low'].includes(priorityName)) { + newPriorityValue = priorityName as 'critical' | 'high' | 'medium' | 'low'; + } + } + + console.log('🔧 Updating task priority:', { + taskId: response.id, + oldPriority: currentTask.priority, + newPriority: newPriorityValue, + priorityId: response.priority_id, + currentGrouping: state.taskManagement.grouping + }); + + // Update the task entity + dispatch(updateTask({ + id: response.id, + changes: { + priority: newPriorityValue, + updatedAt: new Date().toISOString(), + } + })); + + // Handle group movement ONLY if grouping by priority + const groups = state.taskManagement.groups; + const currentGrouping = state.taskManagement.grouping; + + if (groups && groups.length > 0 && currentGrouping === 'priority') { + // Find current group containing the task + const currentGroup = groups.find(group => group.taskIds.includes(response.id)); + + // Find target group based on new priority value + const targetGroup = groups.find(group => + group.groupValue.toLowerCase() === newPriorityValue.toLowerCase() + ); + + if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) { + console.log('🔄 Moving task between priority groups:', { + taskId: response.id, + fromGroup: currentGroup.title, + toGroup: targetGroup.title, + newPriority: newPriorityValue + }); + + dispatch(moveTaskBetweenGroups({ + taskId: response.id, + fromGroupId: currentGroup.id, + toGroupId: targetGroup.id, + taskUpdate: { + priority: newPriorityValue, + } + })); + } else { + console.log('🔧 No group movement needed for priority change'); + } + } else { + console.log('🔧 Not grouped by priority, skipping group movement'); + } } }, [dispatch, currentGroupingV3] @@ -244,17 +365,99 @@ export const useTaskSocketHandlers = () => { (data: ITaskPhaseChangeResponse) => { if (!data) return; + console.log('🎯 Phase change received:', data); + // Update the old task slice (for backward compatibility) dispatch(updateTaskPhase(data)); dispatch(deselectAll()); - // For the task management slice, refetch tasks to ensure consistency - if (data.task_id && projectId) { - console.log('🔄 Refetching tasks after phase change...'); - dispatch(fetchTasksV3(projectId)); + // For the task management slice, always update the task entity first + const state = store.getState(); + const taskId = data.task_id; + + if (taskId) { + const currentTask = state.taskManagement.entities[taskId]; + + if (currentTask) { + // Get phase list to map phase_id to phase name + const phaseList = state.phaseReducer?.phaseList || []; + let newPhaseValue = ''; + + if (data.id) { + // data.id is the phase_id + const phase = phaseList.find(p => p.id === data.id); + newPhaseValue = phase?.name || ''; + } else { + // No phase selected (cleared) + newPhaseValue = ''; + } + + console.log('🔧 Updating task phase:', { + taskId: taskId, + oldPhase: currentTask.phase, + newPhase: newPhaseValue, + phaseId: data.id, + currentGrouping: state.taskManagement.grouping + }); + + // Update the task entity + dispatch(updateTask({ + id: taskId, + changes: { + phase: newPhaseValue, + updatedAt: new Date().toISOString(), + } + })); + + // Handle group movement ONLY if grouping by phase + const groups = state.taskManagement.groups; + const currentGrouping = state.taskManagement.grouping; + + if (groups && groups.length > 0 && currentGrouping === 'phase') { + // Find current group containing the task + const currentGroup = groups.find(group => group.taskIds.includes(taskId)); + + // Find target group based on new phase value + let targetGroup: any = null; + + if (newPhaseValue) { + // Find group by phase name + targetGroup = groups.find(group => + group.groupValue === newPhaseValue || group.title === newPhaseValue + ); + } else { + // Find "No Phase" or similar group + targetGroup = groups.find(group => + group.groupValue === '' || group.title.toLowerCase().includes('no phase') || group.title.toLowerCase().includes('unassigned') + ); + } + + if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) { + console.log('🔄 Moving task between phase groups:', { + taskId: taskId, + fromGroup: currentGroup.title, + toGroup: targetGroup.title, + newPhase: newPhaseValue || 'No Phase' + }); + + dispatch(moveTaskBetweenGroups({ + taskId: taskId, + fromGroupId: currentGroup.id, + toGroupId: targetGroup.id, + taskUpdate: { + phase: newPhaseValue, + } + })); + } else { + console.log('🔧 No group movement needed for phase change'); + } + } else { + console.log('🔧 Not grouped by phase, skipping group movement'); + } + } } }, - [dispatch, currentGroupingV3] + [dispatch, currentGroupingV3, projectId] ); const handleStartDateChange = useCallback( @@ -355,7 +558,26 @@ export const useTaskSocketHandlers = () => { order: data.sort_order || 0, }; - dispatch(addTask(task)); + // Extract the group UUID from the backend response based on current grouping + let groupId: string | undefined; + + // Select the correct UUID based on current grouping + // If currentGroupingV3 is null, default to 'status' since that's the most common grouping + const grouping = currentGroupingV3 || 'status'; + + if (grouping === 'status') { + // For status grouping, use status field (which contains the status UUID) + groupId = data.status; + } else if (grouping === 'priority') { + // For priority grouping, use priority field (which contains the priority UUID) + groupId = data.priority; + } else if (grouping === 'phase') { + // For phase grouping, use phase_id + groupId = data.phase_id; + } + + // Use addTaskToGroup with the actual group UUID + dispatch(addTaskToGroup({ task, groupId })); } }, [dispatch]