From e4dfae9f1d324c8b8c5c2fab3b451ef4ca6fa5be Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 11 Jul 2025 14:46:07 +0530 Subject: [PATCH 01/11] feat(database): optimize task sorting functions and introduce bulk update capability - Added new SQL migration to fix window function errors in task sorting functions, replacing CTEs with direct updates for better performance. - Introduced a bulk update function for task sort orders, allowing multiple updates in a single call to improve efficiency. - Updated socket command to support bulk updates, enhancing the task sorting experience in the frontend. - Simplified task update handling in the frontend to utilize the new bulk update feature, improving overall performance and user experience. --- ...250128000000-fix-window-function-error.sql | 143 +++++++++++++++++ worklenz-backend/database/sql/4_functions.sql | 149 +++++++++++------- .../commands/on-task-sort-order-change.ts | 106 +++++++++---- .../task-management/ManageStatusModal.tsx | 6 - .../task-group-wrapper/task-group-wrapper.tsx | 64 ++++++++ .../task-list-table-wrapper.tsx | 24 +-- 6 files changed, 385 insertions(+), 107 deletions(-) create mode 100644 worklenz-backend/database/migrations/20250128000000-fix-window-function-error.sql diff --git a/worklenz-backend/database/migrations/20250128000000-fix-window-function-error.sql b/worklenz-backend/database/migrations/20250128000000-fix-window-function-error.sql new file mode 100644 index 00000000..9a20e173 --- /dev/null +++ b/worklenz-backend/database/migrations/20250128000000-fix-window-function-error.sql @@ -0,0 +1,143 @@ +-- Fix window function error in task sort optimized functions +-- Error: window functions are not allowed in UPDATE + +-- Replace the optimized sort functions to avoid CTE usage in UPDATE statements +CREATE OR REPLACE FUNCTION handle_task_list_sort_between_groups_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _offset INT := 0; + _affected_rows INT; +BEGIN + -- PERFORMANCE OPTIMIZATION: Use direct updates without CTE in UPDATE + IF (_to_index = -1) + THEN + _to_index = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0); + END IF; + + -- PERFORMANCE OPTIMIZATION: Batch updates for large datasets + IF _to_index > _from_index + THEN + LOOP + UPDATE tasks + SET sort_order = sort_order - 1 + WHERE project_id = _project_id + AND sort_order > _from_index + AND sort_order < _to_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size; + + GET DIAGNOSTICS _affected_rows = ROW_COUNT; + EXIT WHEN _affected_rows = 0; + _offset := _offset + _batch_size; + END LOOP; + + UPDATE tasks SET sort_order = _to_index - 1 WHERE id = _task_id AND project_id = _project_id; + END IF; + + IF _to_index < _from_index + THEN + _offset := 0; + LOOP + UPDATE tasks + SET sort_order = sort_order + 1 + WHERE project_id = _project_id + AND sort_order > _to_index + AND sort_order < _from_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size; + + GET DIAGNOSTICS _affected_rows = ROW_COUNT; + EXIT WHEN _affected_rows = 0; + _offset := _offset + _batch_size; + END LOOP; + + UPDATE tasks SET sort_order = _to_index + 1 WHERE id = _task_id AND project_id = _project_id; + END IF; +END +$$; + +-- Replace the second optimized sort function +CREATE OR REPLACE FUNCTION handle_task_list_sort_inside_group_optimized(_from_index integer, _to_index integer, _task_id uuid, _project_id uuid, _batch_size integer DEFAULT 100) RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _offset INT := 0; + _affected_rows INT; +BEGIN + -- PERFORMANCE OPTIMIZATION: Batch updates for large datasets without CTE in UPDATE + IF _to_index > _from_index + THEN + LOOP + UPDATE tasks + SET sort_order = sort_order - 1 + WHERE project_id = _project_id + AND sort_order > _from_index + AND sort_order <= _to_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size; + + GET DIAGNOSTICS _affected_rows = ROW_COUNT; + EXIT WHEN _affected_rows = 0; + _offset := _offset + _batch_size; + END LOOP; + END IF; + + IF _to_index < _from_index + THEN + _offset := 0; + LOOP + UPDATE tasks + SET sort_order = sort_order + 1 + WHERE project_id = _project_id + AND sort_order >= _to_index + AND sort_order < _from_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size; + + GET DIAGNOSTICS _affected_rows = ROW_COUNT; + EXIT WHEN _affected_rows = 0; + _offset := _offset + _batch_size; + END LOOP; + END IF; + + UPDATE tasks SET sort_order = _to_index WHERE id = _task_id AND project_id = _project_id; +END +$$; + +-- Add simple bulk update function as alternative +CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json) RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _update_record RECORD; +BEGIN + -- Simple approach: update each task's sort_order from the provided array + FOR _update_record IN + SELECT + (item->>'task_id')::uuid as task_id, + (item->>'sort_order')::int as sort_order, + (item->>'status_id')::uuid as status_id, + (item->>'priority_id')::uuid as priority_id, + (item->>'phase_id')::uuid as phase_id + FROM json_array_elements(_updates) as item + LOOP + UPDATE tasks + SET + sort_order = _update_record.sort_order, + status_id = COALESCE(_update_record.status_id, status_id), + priority_id = COALESCE(_update_record.priority_id, priority_id) + WHERE id = _update_record.task_id; + + -- Handle phase updates separately since it's in a different table + IF _update_record.phase_id IS NOT NULL THEN + INSERT INTO task_phase (task_id, phase_id) + VALUES (_update_record.task_id, _update_record.phase_id) + ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id; + END IF; + END LOOP; +END +$$; \ No newline at end of file diff --git a/worklenz-backend/database/sql/4_functions.sql b/worklenz-backend/database/sql/4_functions.sql index 441b08e8..2c57d3c4 100644 --- a/worklenz-backend/database/sql/4_functions.sql +++ b/worklenz-backend/database/sql/4_functions.sql @@ -5498,6 +5498,7 @@ DECLARE _iterator NUMERIC := 0; _status_id TEXT; _project_id UUID; + _base_sort_order NUMERIC; BEGIN -- Get the project_id from the first status to ensure we update all statuses in the same project SELECT project_id INTO _project_id @@ -5513,17 +5514,28 @@ BEGIN _iterator := _iterator + 1; END LOOP; - -- Ensure any remaining statuses in the project (not in the provided list) get sequential sort_order - -- This handles edge cases where not all statuses are provided - UPDATE task_statuses - SET sort_order = ( - SELECT COUNT(*) - FROM task_statuses ts2 - WHERE ts2.project_id = _project_id - AND ts2.id = ANY(SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID) - ) + ROW_NUMBER() OVER (ORDER BY sort_order) - 1 - WHERE project_id = _project_id - AND id NOT IN (SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID); + -- Get the base sort order for remaining statuses (simple count approach) + SELECT COUNT(*) INTO _base_sort_order + FROM task_statuses ts2 + WHERE ts2.project_id = _project_id + AND ts2.id = ANY(SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID); + + -- Update remaining statuses with simple sequential numbering + -- Reset iterator to start from base_sort_order + _iterator := _base_sort_order; + + -- Use a cursor approach to avoid window functions + FOR _status_id IN + SELECT id::TEXT FROM task_statuses + WHERE project_id = _project_id + AND id NOT IN (SELECT (TRIM(BOTH '"' FROM JSON_ARRAY_ELEMENTS(_status_ids)::TEXT))::UUID) + ORDER BY sort_order + LOOP + UPDATE task_statuses + SET sort_order = _iterator + WHERE id = _status_id::UUID; + _iterator := _iterator + 1; + END LOOP; RETURN; END @@ -6412,7 +6424,7 @@ DECLARE _offset INT := 0; _affected_rows INT; BEGIN - -- PERFORMANCE OPTIMIZATION: Use CTE for better query planning + -- PERFORMANCE OPTIMIZATION: Use direct updates without CTE in UPDATE IF (_to_index = -1) THEN _to_index = COALESCE((SELECT MAX(sort_order) + 1 FROM tasks WHERE project_id = _project_id), 0); @@ -6422,18 +6434,15 @@ BEGIN IF _to_index > _from_index THEN LOOP - WITH batch_update AS ( - UPDATE tasks - SET sort_order = sort_order - 1 - WHERE project_id = _project_id - AND sort_order > _from_index - AND sort_order < _to_index - AND sort_order > _offset - AND sort_order <= _offset + _batch_size - RETURNING 1 - ) - SELECT COUNT(*) INTO _affected_rows FROM batch_update; + UPDATE tasks + SET sort_order = sort_order - 1 + WHERE project_id = _project_id + AND sort_order > _from_index + AND sort_order < _to_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size; + GET DIAGNOSTICS _affected_rows = ROW_COUNT; EXIT WHEN _affected_rows = 0; _offset := _offset + _batch_size; END LOOP; @@ -6445,18 +6454,15 @@ BEGIN THEN _offset := 0; LOOP - WITH batch_update AS ( - UPDATE tasks - SET sort_order = sort_order + 1 - WHERE project_id = _project_id - AND sort_order > _to_index - AND sort_order < _from_index - AND sort_order > _offset - AND sort_order <= _offset + _batch_size - RETURNING 1 - ) - SELECT COUNT(*) INTO _affected_rows FROM batch_update; + UPDATE tasks + SET sort_order = sort_order + 1 + WHERE project_id = _project_id + AND sort_order > _to_index + AND sort_order < _from_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size; + GET DIAGNOSTICS _affected_rows = ROW_COUNT; EXIT WHEN _affected_rows = 0; _offset := _offset + _batch_size; END LOOP; @@ -6475,22 +6481,19 @@ DECLARE _offset INT := 0; _affected_rows INT; BEGIN - -- PERFORMANCE OPTIMIZATION: Batch updates for large datasets + -- PERFORMANCE OPTIMIZATION: Batch updates for large datasets without CTE in UPDATE IF _to_index > _from_index THEN LOOP - WITH batch_update AS ( - UPDATE tasks - SET sort_order = sort_order - 1 - WHERE project_id = _project_id - AND sort_order > _from_index - AND sort_order <= _to_index - AND sort_order > _offset - AND sort_order <= _offset + _batch_size - RETURNING 1 - ) - SELECT COUNT(*) INTO _affected_rows FROM batch_update; + UPDATE tasks + SET sort_order = sort_order - 1 + WHERE project_id = _project_id + AND sort_order > _from_index + AND sort_order <= _to_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size; + GET DIAGNOSTICS _affected_rows = ROW_COUNT; EXIT WHEN _affected_rows = 0; _offset := _offset + _batch_size; END LOOP; @@ -6500,18 +6503,15 @@ BEGIN THEN _offset := 0; LOOP - WITH batch_update AS ( - UPDATE tasks - SET sort_order = sort_order + 1 - WHERE project_id = _project_id - AND sort_order >= _to_index - AND sort_order < _from_index - AND sort_order > _offset - AND sort_order <= _offset + _batch_size - RETURNING 1 - ) - SELECT COUNT(*) INTO _affected_rows FROM batch_update; + UPDATE tasks + SET sort_order = sort_order + 1 + WHERE project_id = _project_id + AND sort_order >= _to_index + AND sort_order < _from_index + AND sort_order > _offset + AND sort_order <= _offset + _batch_size; + GET DIAGNOSTICS _affected_rows = ROW_COUNT; EXIT WHEN _affected_rows = 0; _offset := _offset + _batch_size; END LOOP; @@ -6520,3 +6520,38 @@ BEGIN UPDATE tasks SET sort_order = _to_index WHERE id = _task_id AND project_id = _project_id; END $$; + +-- Simple function to update task sort orders in bulk +CREATE OR REPLACE FUNCTION update_task_sort_orders_bulk(_updates json) RETURNS void + LANGUAGE plpgsql +AS +$$ +DECLARE + _update_record RECORD; +BEGIN + -- Simple approach: update each task's sort_order from the provided array + FOR _update_record IN + SELECT + (item->>'task_id')::uuid as task_id, + (item->>'sort_order')::int as sort_order, + (item->>'status_id')::uuid as status_id, + (item->>'priority_id')::uuid as priority_id, + (item->>'phase_id')::uuid as phase_id + FROM json_array_elements(_updates) as item + LOOP + UPDATE tasks + SET + sort_order = _update_record.sort_order, + status_id = COALESCE(_update_record.status_id, status_id), + priority_id = COALESCE(_update_record.priority_id, priority_id) + WHERE id = _update_record.task_id; + + -- Handle phase updates separately since it's in a different table + IF _update_record.phase_id IS NOT NULL THEN + INSERT INTO task_phase (task_id, phase_id) + VALUES (_update_record.task_id, _update_record.phase_id) + ON CONFLICT (task_id) DO UPDATE SET phase_id = _update_record.phase_id; + END IF; + END LOOP; +END +$$; diff --git a/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts b/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts index 79abae7a..11ec09cd 100644 --- a/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts +++ b/worklenz-backend/src/socket.io/commands/on-task-sort-order-change.ts @@ -24,6 +24,14 @@ interface ChangeRequest { priority: string; }; team_id: string; + // New simplified approach + task_updates?: Array<{ + task_id: string; + sort_order: number; + status_id?: string; + priority_id?: string; + phase_id?: string; + }>; } interface Config { @@ -64,38 +72,72 @@ function updateUnmappedStatus(config: Config) { export async function on_task_sort_order_change(_io: Server, socket: Socket, data: ChangeRequest) { try { - const q = `SELECT handle_task_list_sort_order_change($1);`; - - const config: Config = { - from_index: data.from_index, - to_index: data.to_index, - task_id: data.task.id, - from_group: data.from_group, - to_group: data.to_group, - project_id: data.project_id, - group_by: data.group_by, - to_last_index: Boolean(data.to_last_index) - }; - - if ((config.group_by === GroupBy.STATUS) && config.to_group) { - const canContinue = await TasksControllerV2.checkForCompletedDependencies(config.task_id, config?.to_group); - if (!canContinue) { - return socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { - completed_deps: canContinue - }); + // New simplified approach - use bulk updates if provided + if (data.task_updates && data.task_updates.length > 0) { + // Check dependencies for status changes + if (data.group_by === GroupBy.STATUS && data.to_group) { + const canContinue = await TasksControllerV2.checkForCompletedDependencies(data.task.id, data.to_group); + if (!canContinue) { + return socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { + completed_deps: canContinue + }); + } } - notifyStatusChange(socket, config); + // Use the simple bulk update function + const q = `SELECT update_task_sort_orders_bulk($1);`; + await db.query(q, [JSON.stringify(data.task_updates)]); + await emitSortOrderChange(data, socket); + + // Handle notifications and logging + if (data.group_by === GroupBy.STATUS && data.to_group) { + notifyStatusChange(socket, { + task_id: data.task.id, + to_group: data.to_group, + from_group: data.from_group, + from_index: data.from_index, + to_index: data.to_index, + project_id: data.project_id, + group_by: data.group_by, + to_last_index: data.to_last_index + }); + } + } else { + // Fallback to old complex method + const q = `SELECT handle_task_list_sort_order_change($1);`; + + const config: Config = { + from_index: data.from_index, + to_index: data.to_index, + task_id: data.task.id, + from_group: data.from_group, + to_group: data.to_group, + project_id: data.project_id, + group_by: data.group_by, + to_last_index: Boolean(data.to_last_index) + }; + + if ((config.group_by === GroupBy.STATUS) && config.to_group) { + const canContinue = await TasksControllerV2.checkForCompletedDependencies(config.task_id, config?.to_group); + if (!canContinue) { + return socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { + completed_deps: canContinue + }); + } + + notifyStatusChange(socket, config); + } + + if (config.group_by === GroupBy.PHASE) { + updateUnmappedStatus(config); + } + + await db.query(q, [JSON.stringify(config)]); + await emitSortOrderChange(data, socket); } - if (config.group_by === GroupBy.PHASE) { - updateUnmappedStatus(config); - } - - await db.query(q, [JSON.stringify(config)]); - await emitSortOrderChange(data, socket); - - if (config.group_by === GroupBy.STATUS) { + // Common post-processing logic for both approaches + if (data.group_by === GroupBy.STATUS) { const userId = getLoggedInUserIdFromSocket(socket); const isAlreadyAssigned = await TasksControllerV2.checkUserAssignedToTask(data.task.id, userId as string, data.team_id); @@ -104,7 +146,7 @@ export async function on_task_sort_order_change(_io: Server, socket: Socket, dat } } - if (config.group_by === GroupBy.PHASE) { + if (data.group_by === GroupBy.PHASE) { void logPhaseChange({ task_id: data.task.id, socket, @@ -113,7 +155,7 @@ export async function on_task_sort_order_change(_io: Server, socket: Socket, dat }); } - if (config.group_by === GroupBy.STATUS) { + if (data.group_by === GroupBy.STATUS) { void logStatusChange({ task_id: data.task.id, socket, @@ -122,7 +164,7 @@ export async function on_task_sort_order_change(_io: Server, socket: Socket, dat }); } - if (config.group_by === GroupBy.PRIORITY) { + if (data.group_by === GroupBy.PRIORITY) { void logPriorityChange({ task_id: data.task.id, socket, @@ -131,7 +173,7 @@ export async function on_task_sort_order_change(_io: Server, socket: Socket, dat }); } - void notifyProjectUpdates(socket, config.task_id); + void notifyProjectUpdates(socket, data.task.id); return; } catch (error) { log_error(error); diff --git a/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx b/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx index 6f6d85dc..8c24c88b 100644 --- a/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx +++ b/worklenz-frontend/src/components/task-management/ManageStatusModal.tsx @@ -15,7 +15,6 @@ import { IKanbanTaskStatus } from '@/types/tasks/taskStatus.types'; import { Modal as AntModal } from 'antd'; import { fetchTasksV3 } from '@/features/task-management/task-management.slice'; import { fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice'; -import { fetchTaskGroups } from '@/features/tasks/tasks.slice'; import './ManageStatusModal.css'; const { Title, Text } = Typography; @@ -594,7 +593,6 @@ const ManageStatusModal: React.FC = ({ // Refresh from server to ensure consistency dispatch(fetchStatuses(finalProjectId)); dispatch(fetchTasksV3(finalProjectId)); - dispatch(fetchTaskGroups(finalProjectId)); dispatch(fetchEnhancedKanbanGroups(finalProjectId)); } catch (error) { console.error('Error changing status category:', error); @@ -736,7 +734,6 @@ const ManageStatusModal: React.FC = ({ statusApiService.updateStatusOrder(requestBody, finalProjectId).then(() => { // Refresh task lists after status order change dispatch(fetchTasksV3(finalProjectId)); - dispatch(fetchTaskGroups(finalProjectId)); dispatch(fetchEnhancedKanbanGroups(finalProjectId)); }).catch(error => { console.error('Error updating status order:', error); @@ -767,7 +764,6 @@ const ManageStatusModal: React.FC = ({ if (res.done) { dispatch(fetchStatuses(finalProjectId)); dispatch(fetchTasksV3(finalProjectId)); - dispatch(fetchTaskGroups(finalProjectId)); dispatch(fetchEnhancedKanbanGroups(finalProjectId)); } } catch (error) { @@ -791,7 +787,6 @@ const ManageStatusModal: React.FC = ({ await statusApiService.updateNameOfStatus(id, body, finalProjectId); dispatch(fetchStatuses(finalProjectId)); dispatch(fetchTasksV3(finalProjectId)); - dispatch(fetchTaskGroups(finalProjectId)); dispatch(fetchEnhancedKanbanGroups(finalProjectId)); } catch (error) { console.error('Error renaming status:', error); @@ -813,7 +808,6 @@ const ManageStatusModal: React.FC = ({ await statusApiService.deleteStatus(id, finalProjectId, replacingStatusId); dispatch(fetchStatuses(finalProjectId)); dispatch(fetchTasksV3(finalProjectId)); - dispatch(fetchTaskGroups(finalProjectId)); dispatch(fetchEnhancedKanbanGroups(finalProjectId)); } catch (error) { console.error('Error deleting status:', error); diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx index 84c30f59..f7a8a52c 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx @@ -524,6 +524,69 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { }); } + // NEW SIMPLIFIED APPROACH: Calculate all affected task updates and send them + const taskUpdates: Array<{ + task_id: string; + sort_order: number; + status_id?: string; + priority_id?: string; + phase_id?: string; + }> = []; + + // Add updates for all tasks in affected groups + if (activeGroupId === overGroupId) { + // Same group - just reorder + const updatedTasks = [...sourceGroup.tasks]; + updatedTasks.splice(fromIndex, 1); + updatedTasks.splice(toIndex, 0, task); + + updatedTasks.forEach((task, index) => { + taskUpdates.push({ + task_id: task.id, + sort_order: index + 1, // 1-based indexing + }); + }); + } else { + // Different groups - update both source and target + const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex); + const updatedTargetTasks = [...targetGroup.tasks]; + + if (isTargetGroupEmpty) { + updatedTargetTasks.push(task); + } else if (toIndex >= 0 && toIndex <= updatedTargetTasks.length) { + updatedTargetTasks.splice(toIndex, 0, task); + } else { + updatedTargetTasks.push(task); + } + + // Add updates for source group + updatedSourceTasks.forEach((task, index) => { + taskUpdates.push({ + task_id: task.id, + sort_order: index + 1, + }); + }); + + // Add updates for target group (including the moved task) + updatedTargetTasks.forEach((task, index) => { + const update: any = { + task_id: task.id, + sort_order: index + 1, + }; + + // Add group-specific updates + if (groupBy === IGroupBy.STATUS) { + update.status_id = targetGroup.id; + } else if (groupBy === IGroupBy.PRIORITY) { + update.priority_id = targetGroup.id; + } else if (groupBy === IGroupBy.PHASE) { + update.phase_id = targetGroup.id; + } + + taskUpdates.push(update); + }); + } + socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { project_id: projectId, from_index: sourceGroup.tasks[fromIndex].sort_order, @@ -534,6 +597,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { group_by: groupBy, task: sourceGroup.tasks[fromIndex], team_id: currentSession?.team_id, + task_updates: taskUpdates, // NEW: Send calculated updates }); setTimeout(resetTaskRowStyles, 0); diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper.tsx index bee5c22a..fb7c9fb1 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper.tsx @@ -208,6 +208,18 @@ const TaskListTableWrapper = ({ > + {groupBy !== IGroupBy.PRIORITY && + !showRenameInput && + isEditable && + name !== 'Unmapped' && ( + + - {groupBy !== IGroupBy.PRIORITY && - !showRenameInput && - isEditable && - name !== 'Unmapped' && ( - - - +
+ +
+ + - {/* Select All Checkbox Space - ultra minimal width */} -
- e.stopPropagation()} - style={{ - color: headerTextColor, - }} - /> -
+ {/* Select All Checkbox Space - ultra minimal width */} +
+ e.stopPropagation()} + style={{ + color: headerTextColor, + }} + /> +
- {/* Group indicator and name - no gap at all */} + {/* Group indicator and name - no gap at all */}
{/* Group name and count */}
- - {group.name} - + {isEditingName ? ( + setEditingName(e.target.value)} + onKeyDown={handleNameKeyDown} + onBlur={handleNameBlur} + autoFocus + size="small" + className="text-sm font-semibold" + style={{ + width: 'auto', + minWidth: '100px', + backgroundColor: 'rgba(255, 255, 255, 0.1)', + color: headerTextColor, + border: '1px solid rgba(255, 255, 255, 0.3)' + }} + onClick={(e) => e.stopPropagation()} + /> + ) : ( + + {group.name} + + )} ({group.count})
+ + {/* Three-dot menu - only show for status and phase grouping */} + {menuItems.length > 0 && (currentGrouping === 'status' || currentGrouping === 'phase') && ( +
+ + + +
+ )} + + + + {/* Progress Bar - sticky to the right edge during horizontal scroll */} + {(currentGrouping === 'priority' || currentGrouping === 'phase') && + (group.todo_progress || group.doing_progress || group.done_progress) && ( +
+ +
+ )} ); }; diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index c208882f..8d3c8452 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -452,6 +452,10 @@ const TaskListV2Section: React.FC = () => { name: group.title, count: group.actualCount, color: group.color, + todo_progress: group.todo_progress, + doing_progress: group.doing_progress, + done_progress: group.done_progress, + groupType: group.groupType, }} isCollapsed={isGroupCollapsed} onToggle={() => handleGroupCollapse(group.id)} diff --git a/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts b/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts index 8b4e1419..3ccac5d2 100644 --- a/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts +++ b/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts @@ -33,7 +33,7 @@ export const GROUP_BY_OPTIONS: IGroupByOption[] = [ { label: 'Phase', value: IGroupBy.PHASE }, ]; -const LOCALSTORAGE_GROUP_KEY = 'worklenz.enhanced-kanban.group_by'; +const LOCALSTORAGE_GROUP_KEY = 'worklenz.kanban.group_by'; export const getCurrentGroup = (): IGroupBy => { const key = localStorage.getItem(LOCALSTORAGE_GROUP_KEY); diff --git a/worklenz-frontend/src/features/task-management/grouping.slice.ts b/worklenz-frontend/src/features/task-management/grouping.slice.ts index cea2c047..49f5bb61 100644 --- a/worklenz-frontend/src/features/task-management/grouping.slice.ts +++ b/worklenz-frontend/src/features/task-management/grouping.slice.ts @@ -17,8 +17,36 @@ interface LocalGroupingState { collapsedGroups: string[]; } +// Local storage constants +const LOCALSTORAGE_GROUP_KEY = 'worklenz.tasklist.group_by'; + +// Utility functions for local storage +const loadGroupingFromLocalStorage = (): GroupingType | null => { + try { + const stored = localStorage.getItem(LOCALSTORAGE_GROUP_KEY); + if (stored && ['status', 'priority', 'phase'].includes(stored)) { + return stored as GroupingType; + } + } catch (error) { + console.warn('Failed to load grouping from localStorage:', error); + } + return 'status'; // Default to 'status' instead of null +}; + +const saveGroupingToLocalStorage = (grouping: GroupingType | null): void => { + try { + if (grouping) { + localStorage.setItem(LOCALSTORAGE_GROUP_KEY, grouping); + } else { + localStorage.removeItem(LOCALSTORAGE_GROUP_KEY); + } + } catch (error) { + console.warn('Failed to save grouping to localStorage:', error); + } +}; + const initialState: LocalGroupingState = { - currentGrouping: null, + currentGrouping: loadGroupingFromLocalStorage(), customPhases: ['Planning', 'Development', 'Testing', 'Deployment'], groupOrder: { status: ['todo', 'doing', 'done'], @@ -35,6 +63,7 @@ const groupingSlice = createSlice({ reducers: { setCurrentGrouping: (state, action: PayloadAction) => { state.currentGrouping = action.payload; + saveGroupingToLocalStorage(action.payload); }, addCustomPhase: (state, action: PayloadAction) => { From 12b430a3494cb8407ba217230a14341b6ba9b594 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 11 Jul 2025 16:41:30 +0530 Subject: [PATCH 03/11] feat(task-context-menu): implement context menu for task actions - Added TaskContextMenu component to provide a context menu for task-related actions such as assigning, archiving, deleting, and moving tasks. - Integrated context menu into TitleColumn component, allowing users to access task actions via right-click. - Enhanced user experience by providing immediate feedback for actions like assigning tasks and archiving. - Improved code organization by separating context menu logic into its own component. --- .../components/TaskContextMenu.tsx | 491 ++++++++++++++++++ .../task-list-v2/components/TitleColumn.tsx | 36 ++ .../task-list-table/task-list-table.tsx | 90 ---- 3 files changed, 527 insertions(+), 90 deletions(-) create mode 100644 worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx diff --git a/worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx b/worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx new file mode 100644 index 00000000..0982dafa --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx @@ -0,0 +1,491 @@ +import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useSocket } from '@/socket/socketContext'; +import { useAuthService } from '@/hooks/useAuth'; +import { SocketEvents } from '@/shared/socket-events'; +import logger from '@/utils/errorLogger'; +import { Task } from '@/types/task-management.types'; +import { tasksApiService } from '@/api/tasks/tasks.api.service'; +import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service'; +import { IBulkAssignRequest } from '@/types/tasks/bulk-action-bar.types'; +import { + deleteTask, + fetchTasksV3, + IGroupBy, + toggleTaskExpansion, + updateTaskAssignees, +} from '@/features/task-management/task-management.slice'; +import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; +import { setConvertToSubtaskDrawerOpen } from '@/features/task-drawer/task-drawer.slice'; +import { useTranslation } from 'react-i18next'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { + evt_project_task_list_context_menu_archive, + evt_project_task_list_context_menu_assign_me, + evt_project_task_list_context_menu_delete, +} from '@/shared/worklenz-analytics-events'; +import { + DeleteOutlined, + DoubleRightOutlined, + InboxOutlined, + RetweetOutlined, + UserAddOutlined, + LoadingOutlined, +} from '@ant-design/icons'; + +interface TaskContextMenuProps { + task: Task; + projectId: string; + position: { x: number; y: number }; + onClose: () => void; +} + +const TaskContextMenu: React.FC = ({ + task, + projectId, + position, + onClose, +}) => { + const dispatch = useAppDispatch(); + const { t } = useTranslation('task-list-table'); + const { socket, connected } = useSocket(); + const currentSession = useAuthService().getCurrentSession(); + const { trackMixpanelEvent } = useMixpanelTracking(); + + const { groups: taskGroups } = useAppSelector(state => state.taskManagement); + const statusList = useAppSelector(state => state.taskStatusReducer.status); + const priorityList = useAppSelector(state => state.priorityReducer.priorities); + const phaseList = useAppSelector(state => state.phaseReducer.phaseList); + const currentGrouping = useAppSelector(state => state.grouping.currentGrouping); + const archived = useAppSelector(state => state.taskReducer.archived); + + const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false); + + const menuRef = useRef(null); + + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(event.target as Node)) { + onClose(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [onClose]); + + const handleAssignToMe = useCallback(async () => { + if (!projectId || !task.id || !currentSession?.team_member_id) return; + + try { + setUpdatingAssignToMe(true); + + // Immediate UI update - add current user to assignees + const currentUser = { + id: currentSession.team_member_id, + name: currentSession.name || '', + email: currentSession.email || '', + avatar_url: currentSession.avatar_url || '', + team_member_id: currentSession.team_member_id, + }; + + const updatedAssignees = task.assignees || []; + const updatedAssigneeNames = task.assignee_names || []; + + // Check if current user is already assigned + const isAlreadyAssigned = updatedAssignees.includes(currentSession.team_member_id); + + if (!isAlreadyAssigned) { + // Add current user to assignees for immediate UI feedback + const newAssignees = [...updatedAssignees, currentSession.team_member_id]; + const newAssigneeNames = [...updatedAssigneeNames, currentUser]; + + // Update Redux store immediately for instant UI feedback + dispatch( + updateTaskAssignees({ + taskId: task.id, + assigneeIds: newAssignees, + assigneeNames: newAssigneeNames, + }) + ); + } + + const body: IBulkAssignRequest = { + tasks: [task.id], + project_id: projectId, + }; + const res = await taskListBulkActionsApiService.assignToMe(body); + if (res.done) { + trackMixpanelEvent(evt_project_task_list_context_menu_assign_me); + // Socket event will handle syncing with other users + } + } catch (error) { + logger.error('Error assigning to me:', error); + // Revert the optimistic update on error + dispatch( + updateTaskAssignees({ + taskId: task.id, + assigneeIds: task.assignees || [], + assigneeNames: task.assignee_names || [], + }) + ); + } finally { + setUpdatingAssignToMe(false); + onClose(); + } + }, [projectId, task.id, task.assignees, task.assignee_names, currentSession, dispatch, onClose, trackMixpanelEvent]); + + const handleArchive = useCallback(async () => { + if (!projectId || !task.id) return; + + try { + const res = await taskListBulkActionsApiService.archiveTasks( + { + tasks: [task.id], + project_id: projectId, + }, + false + ); + + if (res.done) { + trackMixpanelEvent(evt_project_task_list_context_menu_archive); + dispatch(deleteTask(task.id)); + dispatch(deselectAll()); + if (task.parent_task_id) { + socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id); + } + } + } catch (error) { + logger.error('Error archiving task:', error); + } finally { + onClose(); + } + }, [projectId, task.id, task.parent_task_id, dispatch, socket, onClose, trackMixpanelEvent]); + + const handleDelete = useCallback(async () => { + if (!projectId || !task.id) return; + + try { + const res = await taskListBulkActionsApiService.deleteTasks({ tasks: [task.id] }, projectId); + + if (res.done) { + trackMixpanelEvent(evt_project_task_list_context_menu_delete); + dispatch(deleteTask(task.id)); + dispatch(deselectAll()); + if (task.parent_task_id) { + socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id); + } + } + } catch (error) { + logger.error('Error deleting task:', error); + } finally { + onClose(); + } + }, [projectId, task.id, task.parent_task_id, dispatch, socket, onClose, trackMixpanelEvent]); + + const handleStatusMoveTo = useCallback( + async (targetId: string) => { + if (!projectId || !task.id || !targetId) return; + + try { + socket?.emit( + SocketEvents.TASK_STATUS_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + status_id: targetId, + parent_task: task.parent_task_id || null, + team_id: currentSession?.team_id, + }) + ); + } catch (error) { + logger.error('Error moving status:', error); + } finally { + onClose(); + } + }, + [projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose] + ); + + const handlePriorityMoveTo = useCallback( + async (targetId: string) => { + if (!projectId || !task.id || !targetId) return; + + try { + socket?.emit( + SocketEvents.TASK_PRIORITY_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + priority_id: targetId, + parent_task: task.parent_task_id || null, + team_id: currentSession?.team_id, + }) + ); + } catch (error) { + logger.error('Error moving priority:', error); + } finally { + onClose(); + } + }, + [projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose] + ); + + const handlePhaseMoveTo = useCallback( + async (targetId: string) => { + if (!projectId || !task.id || !targetId) return; + + try { + socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), { + task_id: task.id, + phase_id: targetId, + parent_task: task.parent_task_id || null, + team_id: currentSession?.team_id, + }); + } catch (error) { + logger.error('Error moving phase:', error); + } finally { + onClose(); + } + }, + [projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose] + ); + + const getMoveToOptions = useCallback(() => { + let options: { key: string; label: React.ReactNode; onClick: () => void }[] = []; + + if (currentGrouping === IGroupBy.STATUS) { + options = statusList.filter(status => status.id).map(status => ({ + key: status.id!, + label: ( +
+ + {status.name} +
+ ), + onClick: () => handleStatusMoveTo(status.id!), + })); + } else if (currentGrouping === IGroupBy.PRIORITY) { + options = priorityList.filter(priority => priority.id).map(priority => ({ + key: priority.id!, + label: ( +
+ + {priority.name} +
+ ), + onClick: () => handlePriorityMoveTo(priority.id!), + })); + } else if (currentGrouping === IGroupBy.PHASE) { + options = phaseList.filter(phase => phase.id).map(phase => ({ + key: phase.id!, + label: ( +
+ + {phase.name} +
+ ), + onClick: () => handlePhaseMoveTo(phase.id!), + })); + } + return options; + }, [ + currentGrouping, + statusList, + priorityList, + phaseList, + handleStatusMoveTo, + handlePriorityMoveTo, + handlePhaseMoveTo, + ]); + + const handleConvertToTask = useCallback(async () => { + if (!task?.id || !projectId) return; + + try { + const res = await tasksApiService.convertToTask(task.id as string, projectId as string); + if (res.done) { + dispatch(deselectAll()); + dispatch(fetchTasksV3(projectId)); + } + } catch (error) { + logger.error('Error converting to task', error); + } finally { + onClose(); + } + }, [task?.id, projectId, dispatch, onClose]); + + const menuItems = useMemo(() => { + const items = [ + { + key: 'assignToMe', + label: ( + + ), + }, + ]; + + // Add Move To submenu if there are options + const moveToOptions = getMoveToOptions(); + if (moveToOptions.length > 0) { + items.push({ + key: 'moveTo', + label: ( +
+ +
    + {moveToOptions.map(option => ( +
  • + +
  • + ))} +
+
+ ), + }); + } + + // Add Archive/Unarchive for parent tasks only + if (!task?.parent_task_id) { + items.push({ + key: 'archive', + label: ( + + ), + }); + } + + // Add Convert to Sub Task for parent tasks with no subtasks + if (task?.sub_tasks_count === 0 && !task?.parent_task_id) { + items.push({ + key: 'convertToSubTask', + label: ( + + ), + }); + } + + // Add Convert to Task for subtasks + if (task?.parent_task_id) { + items.push({ + key: 'convertToTask', + label: ( + + ), + }); + } + + // Add Delete + items.push({ + key: 'delete', + label: ( + + ), + }); + + return items; + }, [ + task, + projectId, + updatingAssignToMe, + archived, + handleAssignToMe, + handleArchive, + handleDelete, + handleConvertToTask, + getMoveToOptions, + dispatch, + t, + ]); + + return ( +
+
    + {menuItems.map(item => ( +
  • + {item.label} +
  • + ))} +
+
+ ); +}; + +export default TaskContextMenu; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx b/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx index a005a1c4..1fc5ae38 100644 --- a/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx +++ b/worklenz-frontend/src/components/task-list-v2/components/TitleColumn.tsx @@ -2,6 +2,7 @@ import React, { memo, useCallback, useState, useRef, useEffect } from 'react'; import { RightOutlined, DoubleRightOutlined, ArrowsAltOutlined, CommentOutlined, EyeOutlined, PaperClipOutlined, MinusCircleOutlined, RetweetOutlined } from '@ant-design/icons'; import { Input, Tooltip } from 'antd'; import type { InputRef } from 'antd'; +import { createPortal } from 'react-dom'; import { Task } from '@/types/task-management.types'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { toggleTaskExpansion, fetchSubTasks } from '@/features/task-management/task-management.slice'; @@ -10,6 +11,7 @@ import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; import { useTranslation } from 'react-i18next'; import { getTaskDisplayName } from './TaskRowColumns'; +import TaskContextMenu from './TaskContextMenu'; interface TitleColumnProps { width: string; @@ -41,6 +43,10 @@ export const TitleColumn: React.FC = memo(({ const { t } = useTranslation('task-list-table'); const inputRef = useRef(null); const wrapperRef = useRef(null); + + // Context menu state + const [contextMenuVisible, setContextMenuVisible] = useState(false); + const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 }); // Handle task expansion toggle const handleToggleExpansion = useCallback((e: React.MouseEvent) => { @@ -71,6 +77,24 @@ export const TitleColumn: React.FC = memo(({ onEditTaskName(false); }, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name, onEditTaskName]); + // Handle context menu + const handleContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Use clientX and clientY directly for fixed positioning + setContextMenuPosition({ + x: e.clientX, + y: e.clientY + }); + setContextMenuVisible(true); + }, []); + + // Handle context menu close + const handleContextMenuClose = useCallback(() => { + setContextMenuVisible(false); + }, []); + // Handle click outside for task name editing useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -169,6 +193,7 @@ export const TitleColumn: React.FC = memo(({ e.preventDefault(); onEditTaskName(true); }} + onContextMenu={handleContextMenu} title={taskDisplayName} > {taskDisplayName} @@ -251,6 +276,17 @@ export const TitleColumn: React.FC = memo(({ )} + + {/* Context Menu */} + {contextMenuVisible && createPortal( + , + document.body + )} ); }); diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx index 5f250577..255bbf78 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx @@ -944,18 +944,7 @@ const SelectionFieldCell: React.FC<{ columnKey: string; updateValue: (taskId: string, columnKey: string, value: string) => void; }> = ({ selectionsList, value, task, columnKey, updateValue }) => { - // Debug the selectionsList data - const [loggedInfo, setLoggedInfo] = useState(false); - useEffect(() => { - if (!loggedInfo) { - console.log('Selection column data:', { - columnKey, - selectionsList, - }); - setLoggedInfo(true); - } - }, [columnKey, selectionsList, loggedInfo]); return ( { - // Debug the selectionsList data - const [loggedInfo, setLoggedInfo] = useState(false); - - useEffect(() => { - if (!loggedInfo) { - console.log('Selection column data:', { - columnKey, - selectionsList: columnObj?.selectionsList, - }); - setLoggedInfo(true); - } - }, [columnKey, loggedInfo]); - return ( = ({ taskList, tableId, active const activeTask = displayTasks.find(task => task.id === active.id); if (!activeTask) { - console.error('Active task not found:', { - activeId: active.id, - displayTasks: displayTasks.map(t => ({ id: t.id, name: t.name })), - }); return; } - console.log('Found activeTask:', { - id: activeTask.id, - name: activeTask.name, - status_id: activeTask.status_id, - status: activeTask.status, - priority: activeTask.priority, - project_id: project?.id, - team_id: project?.team_id, - fullProject: project, - }); - // Use the tableId directly as the group ID (it should be the group ID) const currentGroupId = tableId; - console.log('Drag operation:', { - activeId: active.id, - overId: over.id, - tableId, - currentGroupId, - displayTasksLength: displayTasks.length, - }); - // Check if this is a reorder within the same group const overTask = displayTasks.find(task => task.id === over.id); if (overTask) { @@ -1686,36 +1639,17 @@ const TaskListTable: React.FC = ({ taskList, tableId, active const oldIndex = displayTasks.findIndex(task => task.id === active.id); const newIndex = displayTasks.findIndex(task => task.id === over.id); - console.log('Reorder details:', { oldIndex, newIndex, activeTask: activeTask.name }); - if (oldIndex !== newIndex && oldIndex !== -1 && newIndex !== -1) { // Get the actual sort_order values from the tasks const fromSortOrder = activeTask.sort_order || oldIndex; const overTaskAtNewIndex = displayTasks[newIndex]; const toSortOrder = overTaskAtNewIndex?.sort_order || newIndex; - console.log('Sort order details:', { - oldIndex, - newIndex, - fromSortOrder, - toSortOrder, - activeTaskSortOrder: activeTask.sort_order, - overTaskSortOrder: overTaskAtNewIndex?.sort_order, - }); - // Create updated task list with reordered tasks const updatedTasks = [...displayTasks]; const [movedTask] = updatedTasks.splice(oldIndex, 1); updatedTasks.splice(newIndex, 0, movedTask); - console.log('Dispatching reorderTasks with:', { - activeGroupId: currentGroupId, - overGroupId: currentGroupId, - fromIndex: oldIndex, - toIndex: newIndex, - taskName: activeTask.name, - }); - // Update local state immediately for better UX dispatch( reorderTasks({ @@ -1758,34 +1692,10 @@ const TaskListTable: React.FC = ({ taskList, tableId, active // Validate required fields before sending if (!body.task.id) { - console.error('Cannot send socket event: task.id is missing', { activeTask, active }); return; } - console.log('Validated values:', { - from_index: body.from_index, - to_index: body.to_index, - status: body.task.status, - priority: body.task.priority, - team_id: body.team_id, - originalStatus: activeTask.status_id || activeTask.status, - originalPriority: activeTask.priority, - originalTeamId: project.team_id, - sessionTeamId: currentSession?.team_id, - finalTeamId: body.team_id, - }); - - console.log('Sending socket event:', body); socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body); - } else { - console.error('Cannot send socket event: missing required data', { - hasSocket: !!socket, - hasProjectId: !!project?.id, - hasActiveId: !!active.id, - hasActiveTaskId: !!activeTask.id, - activeTask, - active, - }); } } } From affbbbffbffb85304de3649e21a31493a1116088 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 11 Jul 2025 17:26:21 +0530 Subject: [PATCH 04/11] feat(task-phases): enhance phase creation with custom naming and localization updates - Updated the phase creation logic to allow custom names, defaulting to a generated name if none is provided. - Modified localization files for multiple languages to improve phase-related text consistency and clarity. - Enhanced the UI components to reflect the new phase naming functionality and ensure proper integration with the task management system. - Added dark mode styling for confirmation modals to improve user experience across themes. --- .../src/controllers/task-phases-controller.ts | 10 +- .../public/locales/alb/phases-drawer.json | 14 +- .../public/locales/alb/task-list-filters.json | 7 +- .../public/locales/de/phases-drawer.json | 8 +- .../public/locales/de/task-list-filters.json | 1 + .../public/locales/en/phases-drawer.json | 4 + .../public/locales/en/task-list-filters.json | 1 + .../public/locales/es/phases-drawer.json | 18 ++- .../public/locales/es/task-list-filters.json | 5 +- .../public/locales/pt/phases-drawer.json | 10 +- .../public/locales/pt/task-list-filters.json | 5 +- .../public/locales/zh/phases-drawer.json | 40 ++--- .../public/locales/zh/task-list-filters.json | 3 +- .../phases/phases.api.service.ts | 11 +- .../EnhancedKanbanCreateSection.tsx | 2 +- .../task-management/ManagePhaseModal.css | 106 +++++++++++++ .../task-management/ManagePhaseModal.tsx | 12 +- .../task-management/ManageStatusModal.css | 106 +++++++++++++ .../task-management/improved-task-filters.tsx | 6 +- .../singleProject/phase/PhaseHeader.tsx | 15 +- .../singleProject/phase/phases.slice.ts | 4 +- .../board-create-section-card.tsx | 7 +- .../src/styles/task-management.css | 146 ++++++++++++++++++ 23 files changed, 464 insertions(+), 77 deletions(-) diff --git a/worklenz-backend/src/controllers/task-phases-controller.ts b/worklenz-backend/src/controllers/task-phases-controller.ts index e72fbbab..163ff250 100644 --- a/worklenz-backend/src/controllers/task-phases-controller.ts +++ b/worklenz-backend/src/controllers/task-phases-controller.ts @@ -16,19 +16,23 @@ export default class TaskPhasesController extends WorklenzControllerBase { if (!req.query.id) return res.status(400).send(new ServerResponse(false, null, "Invalid request")); + // Use custom name if provided, otherwise use default naming pattern + const phaseName = req.body.name?.trim() || + `Untitled Phase (${(await db.query("SELECT COUNT(*) FROM project_phases WHERE project_id = $1", [req.query.id])).rows[0].count + 1})`; + const q = ` INSERT INTO project_phases (name, color_code, project_id, sort_index) VALUES ( - CONCAT('Untitled Phase (', (SELECT COUNT(*) FROM project_phases WHERE project_id = $2) + 1, ')'), $1, $2, - (SELECT COUNT(*) FROM project_phases WHERE project_id = $2) + 1) + $3, + (SELECT COUNT(*) FROM project_phases WHERE project_id = $3) + 1) RETURNING id, name, color_code, sort_index; `; req.body.color_code = this.DEFAULT_PHASE_COLOR; - const result = await db.query(q, [req.body.color_code, req.query.id]); + const result = await db.query(q, [phaseName, req.body.color_code, req.query.id]); const [data] = result.rows; data.color_code = getColor(data.name) + TASK_STATUS_COLOR_ALPHA; diff --git a/worklenz-frontend/public/locales/alb/phases-drawer.json b/worklenz-frontend/public/locales/alb/phases-drawer.json index 23816727..b0ba817b 100644 --- a/worklenz-frontend/public/locales/alb/phases-drawer.json +++ b/worklenz-frontend/public/locales/alb/phases-drawer.json @@ -1,16 +1,20 @@ { "configurePhases": "Konfiguro Fazat", + "configure": "Konfiguro", "phaseLabel": "Etiketa e Fazës", - "enterPhaseName": "Shkruani emrin e fazës", + "enterPhaseName": "Shkruaj emrin e fazës", "addOption": "Shto Opsion", "phaseOptions": "Opsionet e Fazës", - "dragToReorderPhases": "Zvarrit fazat për t'i rirenditur. Çdo fazë mund të ketë një ngjyrë të ndryshme.", - "enterNewPhaseName": "Shkruani emrin e fazës së re...", + "optionsText": "Opsione", + "dragToReorderPhases": "Tërhiq fazat për t'i rirenditur. Çdo fazë mund të ketë një ngjyrë të ndryshme.", + "enterNewPhaseName": "Shkruaj emrin e fazës së re...", "addPhase": "Shto Fazë", "noPhasesFound": "Nuk u gjetën faza", + "no": "Asnjë", + "found": "u gjet", "deletePhase": "Fshi Fazën", - "deletePhaseConfirm": "Jeni të sigurt që doni të fshini këtë fazë? Ky veprim nuk mund të zhbëhet.", - "rename": "Riemëro", + "deletePhaseConfirm": "Jeni i sigurt që doni të fshini këtë fazë? Ky veprim nuk mund të zhbëhet.", + "rename": "Riemërto", "delete": "Fshi", "create": "Krijo", "cancel": "Anulo", diff --git a/worklenz-frontend/public/locales/alb/task-list-filters.json b/worklenz-frontend/public/locales/alb/task-list-filters.json index e75e4802..4fc4dbdf 100644 --- a/worklenz-frontend/public/locales/alb/task-list-filters.json +++ b/worklenz-frontend/public/locales/alb/task-list-filters.json @@ -68,9 +68,10 @@ "clearing": "Po pastron...", "cancel": "Anulo", "search": "Kërko", - "groupedBy": "I grupuar sipas", - "manageStatuses": "Menaxho statuset", - "managePhases": "Menaxho fazat", + "groupedBy": "Grupuar sipas", + "manage": "Menaxho", + "manageStatuses": "Menaxho Statuset", + "managePhases": "Menaxho Fazat", "dragToReorderStatuses": "Statuset janë të organizuara sipas kategorive. Tërhiq për të rirenditur brenda kategorive. Kliko 'Shto status' për të krijuar statuse të reja në çdo kategori.", "enterNewStatusName": "Shkruani emrin e statusit të ri...", "addStatus": "Shto status", diff --git a/worklenz-frontend/public/locales/de/phases-drawer.json b/worklenz-frontend/public/locales/de/phases-drawer.json index 4a143c7f..3cdfb255 100644 --- a/worklenz-frontend/public/locales/de/phases-drawer.json +++ b/worklenz-frontend/public/locales/de/phases-drawer.json @@ -1,13 +1,17 @@ { "configurePhases": "Phasen konfigurieren", - "phaseLabel": "Phasenbezeichnung", - "enterPhaseName": "Phasennamen eingeben", + "configure": "Konfigurieren", + "phaseLabel": "Phasen-Label", + "enterPhaseName": "Phasenname eingeben", "addOption": "Option hinzufügen", "phaseOptions": "Phasenoptionen", + "optionsText": "Optionen", "dragToReorderPhases": "Ziehen Sie Phasen, um sie neu zu ordnen. Jede Phase kann eine andere Farbe haben.", "enterNewPhaseName": "Neuen Phasennamen eingeben...", "addPhase": "Phase hinzufügen", "noPhasesFound": "Keine Phasen gefunden", + "no": "Keine", + "found": "gefunden", "deletePhase": "Phase löschen", "deletePhaseConfirm": "Sind Sie sicher, dass Sie diese Phase löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", "rename": "Umbenennen", diff --git a/worklenz-frontend/public/locales/de/task-list-filters.json b/worklenz-frontend/public/locales/de/task-list-filters.json index 743f036a..18d50b6c 100644 --- a/worklenz-frontend/public/locales/de/task-list-filters.json +++ b/worklenz-frontend/public/locales/de/task-list-filters.json @@ -69,6 +69,7 @@ "cancel": "Abbrechen", "search": "Suchen", "groupedBy": "Gruppiert nach", + "manage": "Verwalten", "manageStatuses": "Status verwalten", "managePhases": "Phasen verwalten", "dragToReorderStatuses": "Status sind nach Kategorien organisiert. Ziehen Sie, um innerhalb von Kategorien neu zu ordnen. Klicken Sie auf 'Status hinzufügen', um neue Status in jeder Kategorie zu erstellen.", diff --git a/worklenz-frontend/public/locales/en/phases-drawer.json b/worklenz-frontend/public/locales/en/phases-drawer.json index 7791a08b..9eb24582 100644 --- a/worklenz-frontend/public/locales/en/phases-drawer.json +++ b/worklenz-frontend/public/locales/en/phases-drawer.json @@ -1,13 +1,17 @@ { "configurePhases": "Configure Phases", + "configure": "Configure", "phaseLabel": "Phase Label", "enterPhaseName": "Enter phase name", "addOption": "Add Option", "phaseOptions": "Phase Options", + "optionsText": "Options", "dragToReorderPhases": "Drag phases to reorder them. Each phase can have a different color.", "enterNewPhaseName": "Enter new phase name...", "addPhase": "Add Phase", "noPhasesFound": "No phases found", + "no": "No", + "found": "found", "deletePhase": "Delete Phase", "deletePhaseConfirm": "Are you sure you want to delete this phase? This action cannot be undone.", "rename": "Rename", diff --git a/worklenz-frontend/public/locales/en/task-list-filters.json b/worklenz-frontend/public/locales/en/task-list-filters.json index 36ee10dc..118ac4ce 100644 --- a/worklenz-frontend/public/locales/en/task-list-filters.json +++ b/worklenz-frontend/public/locales/en/task-list-filters.json @@ -69,6 +69,7 @@ "cancel": "Cancel", "search": "Search", "groupedBy": "Grouped by", + "manage": "Manage", "manageStatuses": "Manage Statuses", "managePhases": "Manage Phases", "dragToReorderStatuses": "Statuses are organized by categories. Drag to reorder within categories. Click 'Add Status' to create new statuses in each category.", diff --git a/worklenz-frontend/public/locales/es/phases-drawer.json b/worklenz-frontend/public/locales/es/phases-drawer.json index abb6ee81..ed912ac7 100644 --- a/worklenz-frontend/public/locales/es/phases-drawer.json +++ b/worklenz-frontend/public/locales/es/phases-drawer.json @@ -1,13 +1,17 @@ { - "configurePhases": "Configurar fases", - "phaseLabel": "Etiqueta de fase", - "enterPhaseName": "Introducir nombre de la fase", - "addOption": "Agregar opción", - "phaseOptions": "Opciones de fase", + "configurePhases": "Configurar Fases", + "configure": "Configurar", + "phaseLabel": "Etiqueta de Fase", + "enterPhaseName": "Ingresa el nombre de la fase", + "addOption": "Agregar Opción", + "phaseOptions": "Opciones de Fase", + "optionsText": "Opciones", "dragToReorderPhases": "Arrastra las fases para reordenarlas. Cada fase puede tener un color diferente.", - "enterNewPhaseName": "Introducir nuevo nombre de fase...", - "addPhase": "Añadir Fase", + "enterNewPhaseName": "Ingresa el nombre de la nueva fase...", + "addPhase": "Agregar Fase", "noPhasesFound": "No se encontraron fases", + "no": "No", + "found": "encontrado", "deletePhase": "Eliminar Fase", "deletePhaseConfirm": "¿Estás seguro de que quieres eliminar esta fase? Esta acción no se puede deshacer.", "rename": "Renombrar", diff --git a/worklenz-frontend/public/locales/es/task-list-filters.json b/worklenz-frontend/public/locales/es/task-list-filters.json index dc42706a..00c27f16 100644 --- a/worklenz-frontend/public/locales/es/task-list-filters.json +++ b/worklenz-frontend/public/locales/es/task-list-filters.json @@ -69,8 +69,9 @@ "cancel": "Cancelar", "search": "Buscar", "groupedBy": "Agrupado por", - "manageStatuses": "Gestionar estados", - "managePhases": "Gestionar fases", + "manage": "Gestionar", + "manageStatuses": "Gestionar Estados", + "managePhases": "Gestionar Fases", "dragToReorderStatuses": "Los estados están organizados por categorías. Arrastra para reordenar dentro de las categorías. Haz clic en 'Agregar estado' para crear nuevos estados en cada categoría.", "enterNewStatusName": "Ingrese el nombre del nuevo estado...", "addStatus": "Agregar estado", diff --git a/worklenz-frontend/public/locales/pt/phases-drawer.json b/worklenz-frontend/public/locales/pt/phases-drawer.json index b0ca7c51..0d5b8cb7 100644 --- a/worklenz-frontend/public/locales/pt/phases-drawer.json +++ b/worklenz-frontend/public/locales/pt/phases-drawer.json @@ -1,13 +1,17 @@ { - "configurePhases": "Configurar fases", - "phaseLabel": "Etiqueta de fase", + "configurePhases": "Configurar Fases", + "configure": "Configurar", + "phaseLabel": "Rótulo da Fase", "enterPhaseName": "Digite o nome da fase", "addOption": "Adicionar Opção", "phaseOptions": "Opções de Fase", + "optionsText": "Opções", "dragToReorderPhases": "Arraste as fases para reordená-las. Cada fase pode ter uma cor diferente.", - "enterNewPhaseName": "Digite o novo nome da fase...", + "enterNewPhaseName": "Digite o nome da nova fase...", "addPhase": "Adicionar Fase", "noPhasesFound": "Nenhuma fase encontrada", + "no": "Nenhuma", + "found": "encontrada", "deletePhase": "Excluir Fase", "deletePhaseConfirm": "Tem certeza de que deseja excluir esta fase? Esta ação não pode ser desfeita.", "rename": "Renomear", diff --git a/worklenz-frontend/public/locales/pt/task-list-filters.json b/worklenz-frontend/public/locales/pt/task-list-filters.json index 49841ec5..3674a29a 100644 --- a/worklenz-frontend/public/locales/pt/task-list-filters.json +++ b/worklenz-frontend/public/locales/pt/task-list-filters.json @@ -69,8 +69,9 @@ "cancel": "Cancelar", "search": "Pesquisar", "groupedBy": "Agrupado por", - "manageStatuses": "Gerenciar status", - "managePhases": "Gerenciar fases", + "manage": "Gerenciar", + "manageStatuses": "Gerenciar Status", + "managePhases": "Gerenciar Fases", "dragToReorderStatuses": "Os status estão organizados por categorias. Arraste para reordenar dentro das categorias. Clique em 'Adicionar status' para criar novos status em cada categoria.", "enterNewStatusName": "Digite o nome do novo status...", "addStatus": "Adicionar status", diff --git a/worklenz-frontend/public/locales/zh/phases-drawer.json b/worklenz-frontend/public/locales/zh/phases-drawer.json index 8f55e527..37d68cfb 100644 --- a/worklenz-frontend/public/locales/zh/phases-drawer.json +++ b/worklenz-frontend/public/locales/zh/phases-drawer.json @@ -1,20 +1,24 @@ { - "configurePhases": "配置阶段", - "phaseLabel": "阶段标签", - "enterPhaseName": "输入阶段名称", - "addOption": "添加选项", - "phaseOptions": "阶段选项", - "dragToReorderPhases": "拖拽阶段以重新排序。每个阶段可以有不同的颜色。", - "enterNewPhaseName": "输入新阶段名称...", - "addPhase": "添加阶段", - "noPhasesFound": "未找到阶段", - "deletePhase": "删除阶段", - "deletePhaseConfirm": "您确定要删除此阶段吗?此操作无法撤销。", - "rename": "重命名", - "delete": "删除", - "create": "创建", - "cancel": "取消", - "selectColor": "选择颜色", - "managePhases": "管理阶段", - "close": "关闭" + "configurePhases": "配置阶段", + "configure": "配置", + "phaseLabel": "阶段标签", + "enterPhaseName": "输入阶段名称", + "addOption": "添加选项", + "phaseOptions": "阶段选项", + "optionsText": "选项", + "dragToReorderPhases": "拖拽阶段来重新排序。每个阶段可以有不同的颜色。", + "enterNewPhaseName": "输入新阶段名称...", + "addPhase": "添加阶段", + "noPhasesFound": "未找到阶段", + "no": "没有", + "found": "找到", + "deletePhase": "删除阶段", + "deletePhaseConfirm": "您确定要删除此阶段吗?此操作无法撤消。", + "rename": "重命名", + "delete": "删除", + "create": "创建", + "cancel": "取消", + "selectColor": "选择颜色", + "managePhases": "管理阶段", + "close": "关闭" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/task-list-filters.json b/worklenz-frontend/public/locales/zh/task-list-filters.json index 50dcb8e6..4d1d6b43 100644 --- a/worklenz-frontend/public/locales/zh/task-list-filters.json +++ b/worklenz-frontend/public/locales/zh/task-list-filters.json @@ -62,7 +62,8 @@ "clearing": "清除中...", "cancel": "取消", "search": "搜索", - "groupedBy": "分组依据", + "groupedBy": "分组方式", + "manage": "管理", "manageStatuses": "管理状态", "managePhases": "管理阶段", "dragToReorderStatuses": "拖拽状态以重新排序。每个状态可以有不同的类别。", diff --git a/worklenz-frontend/src/api/taskAttributes/phases/phases.api.service.ts b/worklenz-frontend/src/api/taskAttributes/phases/phases.api.service.ts index 3c494049..fc47544a 100644 --- a/worklenz-frontend/src/api/taskAttributes/phases/phases.api.service.ts +++ b/worklenz-frontend/src/api/taskAttributes/phases/phases.api.service.ts @@ -1,12 +1,12 @@ -import apiClient from '@/api/api-client'; -import { API_BASE_URL } from '@/shared/constants'; import { IServerResponse } from '@/types/common.types'; +import apiClient from '@api/api-client'; +import { API_BASE_URL } from '@/shared/constants'; import { ITaskPhase } from '@/types/tasks/taskPhase.types'; import { toQueryString } from '@/utils/toQueryString'; const rootUrl = `${API_BASE_URL}/task-phases`; -interface UpdateSortOrderBody { +export interface UpdateSortOrderBody { from_index: number; to_index: number; phases: ITaskPhase[]; @@ -14,9 +14,10 @@ interface UpdateSortOrderBody { } export const phasesApiService = { - addPhaseOption: async (projectId: string) => { + addPhaseOption: async (projectId: string, name?: string) => { const q = toQueryString({ id: projectId, current_project_id: projectId }); - const response = await apiClient.post>(`${rootUrl}${q}`); + const body = name ? { name } : {}; + const response = await apiClient.post>(`${rootUrl}${q}`, body); return response.data; }, diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSection.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSection.tsx index 1c4d7087..eb9b87cc 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSection.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSection.tsx @@ -139,7 +139,7 @@ const EnhancedKanbanCreateSection: React.FC = () => { } if (groupBy === IGroupBy.PHASE) { try { - const response = await phasesApiService.addPhaseOption(projectId); + const response = await phasesApiService.addPhaseOption(projectId, name); if (response.done && response.body) { dispatch(fetchEnhancedKanbanGroups(projectId)); } diff --git a/worklenz-frontend/src/components/task-management/ManagePhaseModal.css b/worklenz-frontend/src/components/task-management/ManagePhaseModal.css index e36bcd73..4d75b424 100644 --- a/worklenz-frontend/src/components/task-management/ManagePhaseModal.css +++ b/worklenz-frontend/src/components/task-management/ManagePhaseModal.css @@ -20,6 +20,112 @@ border-top: 1px solid #303030; } +/* Dark mode confirmation modal styling */ +.dark .ant-modal-confirm .ant-modal-content, +[data-theme="dark"] .ant-modal-confirm .ant-modal-content { + background-color: #1f1f1f !important; + border: 1px solid #303030 !important; +} + +.dark .ant-modal-confirm .ant-modal-header, +[data-theme="dark"] .ant-modal-confirm .ant-modal-header { + background-color: #1f1f1f !important; + border-bottom: 1px solid #303030 !important; +} + +.dark .ant-modal-confirm .ant-modal-body, +[data-theme="dark"] .ant-modal-confirm .ant-modal-body { + background-color: #1f1f1f !important; + color: #d9d9d9 !important; +} + +.dark .ant-modal-confirm .ant-modal-footer, +[data-theme="dark"] .ant-modal-confirm .ant-modal-footer { + background-color: #1f1f1f !important; + border-top: 1px solid #303030 !important; +} + +.dark .ant-modal-confirm .ant-modal-confirm-title, +[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-title { + color: #d9d9d9 !important; +} + +.dark .ant-modal-confirm .ant-modal-confirm-content, +[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-content { + color: #8c8c8c !important; +} + +.dark .ant-modal-confirm .ant-btn-default, +[data-theme="dark"] .ant-modal-confirm .ant-btn-default { + background-color: #141414 !important; + border-color: #303030 !important; + color: #d9d9d9 !important; +} + +.dark .ant-modal-confirm .ant-btn-default:hover, +[data-theme="dark"] .ant-modal-confirm .ant-btn-default:hover { + background-color: #262626 !important; + border-color: #40a9ff !important; + color: #d9d9d9 !important; +} + +.dark .ant-modal-confirm .ant-btn-primary, +[data-theme="dark"] .ant-modal-confirm .ant-btn-primary { + background-color: #1890ff !important; + border-color: #1890ff !important; + color: #ffffff !important; +} + +.dark .ant-modal-confirm .ant-btn-primary:hover, +[data-theme="dark"] .ant-modal-confirm .ant-btn-primary:hover { + background-color: #40a9ff !important; + border-color: #40a9ff !important; + color: #ffffff !important; +} + +.dark .ant-modal-confirm .ant-btn-dangerous, +[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous { + background-color: #ff4d4f !important; + border-color: #ff4d4f !important; + color: #ffffff !important; +} + +.dark .ant-modal-confirm .ant-btn-dangerous:hover, +[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous:hover { + background-color: #ff7875 !important; + border-color: #ff7875 !important; + color: #ffffff !important; +} + +/* Light mode confirmation modal styling (ensure consistency) */ +.ant-modal-confirm .ant-modal-content { + background-color: #ffffff; + border: 1px solid #f0f0f0; +} + +.ant-modal-confirm .ant-modal-header { + background-color: #ffffff; + border-bottom: 1px solid #f0f0f0; +} + +.ant-modal-confirm .ant-modal-body { + background-color: #ffffff; + color: #262626; +} + +.ant-modal-confirm .ant-modal-footer { + background-color: #ffffff; + border-top: 1px solid #f0f0f0; +} + +.ant-modal-confirm .ant-modal-confirm-title { + color: #262626; +} + +.ant-modal-confirm .ant-modal-confirm-content { + color: #595959; +} + .dark-modal .ant-form-item-label > label { color: #d9d9d9; } diff --git a/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx b/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx index 81a44378..e746851c 100644 --- a/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx +++ b/worklenz-frontend/src/components/task-management/ManagePhaseModal.tsx @@ -18,6 +18,7 @@ import { deletePhaseOption, updatePhaseColor, } from '@/features/projects/singleProject/phase/phases.slice'; +import { updatePhaseLabel } from '@/features/project/project.slice'; import { ITaskPhase } from '@/types/tasks/taskPhase.types'; import { Modal as AntModal } from 'antd'; import { fetchTasksV3 } from '@/features/task-management/task-management.slice'; @@ -307,7 +308,7 @@ const ManagePhaseModal: React.FC = ({ if (!newPhaseName.trim() || !finalProjectId) return; try { - await dispatch(addPhaseOption({ projectId: finalProjectId })); + await dispatch(addPhaseOption({ projectId: finalProjectId, name: newPhaseName.trim() })); await dispatch(fetchPhasesByProjectId(finalProjectId)); await refreshTasks(); setNewPhaseName(''); @@ -408,6 +409,7 @@ const ManagePhaseModal: React.FC = ({ ).unwrap(); if (res.done) { + dispatch(updatePhaseLabel(phaseName)); setInitialPhaseName(phaseName); await refreshTasks(); } @@ -428,7 +430,7 @@ const ManagePhaseModal: React.FC = ({ - {t('configurePhases')} + {t('configure')} {phaseName || project?.phase_label || t('phasesText')} } open={open} @@ -495,7 +497,7 @@ const ManagePhaseModal: React.FC = ({ - 🎨 Drag phases to reorder them. Click on a phase name to rename it. Each phase can have a custom color. + 🎨 Drag {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()} to reorder them. Click on a {(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} name to rename it. Each {(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} can have a custom color. @@ -558,7 +560,7 @@ const ManagePhaseModal: React.FC = ({ - {t('phaseOptions')} + {phaseName || project?.phase_label || t('phasesText')} {t('optionsText')} )} {section.selectedValues[0] === 'status' && ( @@ -994,6 +996,7 @@ const ImprovedTaskFilters: React.FC = ({ position, cla const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark'); const { projectId } = useAppSelector(state => state.projectReducer); const { projectView } = useTabSearchParam(); + const projectPhaseLabel = useAppSelector(state => state.projectReducer.project?.phase_label); // Theme-aware class names - memoize to prevent unnecessary re-renders // Using greyish colors for both dark and light modes @@ -1298,6 +1301,7 @@ const ImprovedTaskFilters: React.FC = ({ position, cla dispatch={dispatch} onManageStatus={() => setShowManageStatusModal(true)} onManagePhase={() => setShowManagePhaseModal(true)} + projectPhaseLabel={projectPhaseLabel} /> )) ) : ( diff --git a/worklenz-frontend/src/features/projects/singleProject/phase/PhaseHeader.tsx b/worklenz-frontend/src/features/projects/singleProject/phase/PhaseHeader.tsx index 4607ae35..e10df057 100644 --- a/worklenz-frontend/src/features/projects/singleProject/phase/PhaseHeader.tsx +++ b/worklenz-frontend/src/features/projects/singleProject/phase/PhaseHeader.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import { useSelectedProject } from '../../../../hooks/useSelectedProject'; import { useAppSelector } from '../../../../hooks/useAppSelector'; import { Flex } from 'antd'; import ConfigPhaseButton from './ConfigPhaseButton'; @@ -10,19 +9,13 @@ const PhaseHeader = () => { // localization const { t } = useTranslation('task-list-filters'); - // get selected project for useSelectedProject hook - const selectedProject = useSelectedProject(); - - // get phase data from redux - const phaseList = useAppSelector(state => state.phaseReducer.phaseList); - - //get phases details from phases slice - const phase = phaseList.find(el => el.projectId === selectedProject?.projectId); + // get project data from redux + const { project } = useAppSelector(state => state.projectReducer); return ( - {phase?.phase || t('phasesText')} - + {project?.phase_label || t('phasesText')} + ); }; diff --git a/worklenz-frontend/src/features/projects/singleProject/phase/phases.slice.ts b/worklenz-frontend/src/features/projects/singleProject/phase/phases.slice.ts index 050c5765..304a1635 100644 --- a/worklenz-frontend/src/features/projects/singleProject/phase/phases.slice.ts +++ b/worklenz-frontend/src/features/projects/singleProject/phase/phases.slice.ts @@ -16,9 +16,9 @@ const initialState: PhaseState = { export const addPhaseOption = createAsyncThunk( 'phase/addPhaseOption', - async ({ projectId }: { projectId: string }, { rejectWithValue }) => { + async ({ projectId, name }: { projectId: string; name?: string }, { rejectWithValue }) => { try { - const response = await phasesApiService.addPhaseOption(projectId); + const response = await phasesApiService.addPhaseOption(projectId, name); return response; } catch (error) { return rejectWithValue(error); diff --git a/worklenz-frontend/src/pages/projects/projectView/board/board-section/board-section-card/board-create-section-card.tsx b/worklenz-frontend/src/pages/projects/projectView/board/board-section/board-section-card/board-create-section-card.tsx index 5abae368..7e61385e 100644 --- a/worklenz-frontend/src/pages/projects/projectView/board/board-section/board-section-card/board-create-section-card.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/board/board-section/board-section-card/board-create-section-card.tsx @@ -106,13 +106,8 @@ const BoardCreateSectionCard = () => { } if (groupBy === IGroupBy.PHASE && projectId) { - const body = { - name: sectionName, - project_id: projectId, - }; - try { - const response = await phasesApiService.addPhaseOption(projectId); + const response = await phasesApiService.addPhaseOption(projectId, sectionName); if (response.done && response.body) { dispatch(fetchBoardTaskGroups(projectId)); } diff --git a/worklenz-frontend/src/styles/task-management.css b/worklenz-frontend/src/styles/task-management.css index 1cfc2669..c4982dac 100644 --- a/worklenz-frontend/src/styles/task-management.css +++ b/worklenz-frontend/src/styles/task-management.css @@ -4,6 +4,152 @@ width: 100%; } +/* Global Confirmation Modal Styles */ +/* Light mode confirmation modal styling (default) */ +.ant-modal-confirm .ant-modal-content { + background-color: #ffffff; + border: 1px solid #f0f0f0; +} + +.ant-modal-confirm .ant-modal-header { + background-color: #ffffff; + border-bottom: 1px solid #f0f0f0; +} + +.ant-modal-confirm .ant-modal-body { + background-color: #ffffff; + color: #262626; +} + +.ant-modal-confirm .ant-modal-footer { + background-color: #ffffff; + border-top: 1px solid #f0f0f0; +} + +.ant-modal-confirm .ant-modal-confirm-title { + color: #262626; +} + +.ant-modal-confirm .ant-modal-confirm-content { + color: #595959; +} + +/* Dark mode confirmation modal styling */ +.dark .ant-modal-confirm .ant-modal-content, +[data-theme="dark"] .ant-modal-confirm .ant-modal-content, +html.dark .ant-modal-confirm .ant-modal-content { + background-color: #1f1f1f !important; + border: 1px solid #303030 !important; +} + +.dark .ant-modal-confirm .ant-modal-header, +[data-theme="dark"] .ant-modal-confirm .ant-modal-header, +html.dark .ant-modal-confirm .ant-modal-header { + background-color: #1f1f1f !important; + border-bottom: 1px solid #303030 !important; +} + +.dark .ant-modal-confirm .ant-modal-body, +[data-theme="dark"] .ant-modal-confirm .ant-modal-body, +html.dark .ant-modal-confirm .ant-modal-body { + background-color: #1f1f1f !important; + color: #d9d9d9 !important; +} + +.dark .ant-modal-confirm .ant-modal-footer, +[data-theme="dark"] .ant-modal-confirm .ant-modal-footer, +html.dark .ant-modal-confirm .ant-modal-footer { + background-color: #1f1f1f !important; + border-top: 1px solid #303030 !important; +} + +.dark .ant-modal-confirm .ant-modal-confirm-title, +[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-title, +html.dark .ant-modal-confirm .ant-modal-confirm-title { + color: #d9d9d9 !important; +} + +.dark .ant-modal-confirm .ant-modal-confirm-content, +[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-content, +html.dark .ant-modal-confirm .ant-modal-confirm-content { + color: #8c8c8c !important; +} + +.dark .ant-modal-confirm .ant-btn-default, +[data-theme="dark"] .ant-modal-confirm .ant-btn-default, +html.dark .ant-modal-confirm .ant-btn-default { + background-color: #141414 !important; + border-color: #303030 !important; + color: #d9d9d9 !important; +} + +.dark .ant-modal-confirm .ant-btn-default:hover, +[data-theme="dark"] .ant-modal-confirm .ant-btn-default:hover, +html.dark .ant-modal-confirm .ant-btn-default:hover { + background-color: #262626 !important; + border-color: #40a9ff !important; + color: #d9d9d9 !important; +} + +.dark .ant-modal-confirm .ant-btn-primary, +[data-theme="dark"] .ant-modal-confirm .ant-btn-primary, +html.dark .ant-modal-confirm .ant-btn-primary { + background-color: #1890ff !important; + border-color: #1890ff !important; + color: #ffffff !important; +} + +.dark .ant-modal-confirm .ant-btn-primary:hover, +[data-theme="dark"] .ant-modal-confirm .ant-btn-primary:hover, +html.dark .ant-modal-confirm .ant-btn-primary:hover { + background-color: #40a9ff !important; + border-color: #40a9ff !important; + color: #ffffff !important; +} + +.dark .ant-modal-confirm .ant-btn-dangerous, +[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous, +html.dark .ant-modal-confirm .ant-btn-dangerous { + background-color: #ff4d4f !important; + border-color: #ff4d4f !important; + color: #ffffff !important; +} + +.dark .ant-modal-confirm .ant-btn-dangerous:hover, +[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous:hover, +html.dark .ant-modal-confirm .ant-btn-dangerous:hover { + background-color: #ff7875 !important; + border-color: #ff7875 !important; + color: #ffffff !important; +} + +/* Error modal specific styling */ +.dark .ant-modal-error .ant-modal-content, +[data-theme="dark"] .ant-modal-error .ant-modal-content, +html.dark .ant-modal-error .ant-modal-content { + background-color: #1f1f1f !important; + border: 1px solid #303030 !important; +} + +.dark .ant-modal-error .ant-modal-body, +[data-theme="dark"] .ant-modal-error .ant-modal-body, +html.dark .ant-modal-error .ant-modal-body { + background-color: #1f1f1f !important; + color: #d9d9d9 !important; +} + +.dark .ant-modal-error .ant-modal-confirm-title, +[data-theme="dark"] .ant-modal-error .ant-modal-confirm-title, +html.dark .ant-modal-error .ant-modal-confirm-title { + color: #d9d9d9 !important; +} + +.dark .ant-modal-error .ant-modal-confirm-content, +[data-theme="dark"] .ant-modal-error .ant-modal-content, +html.dark .ant-modal-error .ant-modal-confirm-content { + color: #8c8c8c !important; +} + .task-group { transition: all 0.2s ease; } From 747088e7ccf089fad5c473cb97db17a4db6998d4 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 11 Jul 2025 17:37:22 +0530 Subject: [PATCH 05/11] refactor(task-management): enhance empty state visuals and improve layout - Updated the empty state message styling in VirtualizedTaskList and TaskGroup components for better visibility and user experience. - Adjusted padding and height in various components to create a more consistent and visually appealing layout. - Removed console log statements from TaskPhaseDropdown to clean up the codebase. --- .../enhanced-kanban/VirtualizedTaskList.tsx | 13 +++++++++++-- .../kanban-board-management-v2/kanbanGroup.tsx | 2 +- .../src/components/task-list-v2/TaskListV2Table.tsx | 6 ++++-- .../src/components/task-management/task-group.tsx | 4 ++-- .../task-management/task-phase-dropdown.tsx | 4 ---- 5 files changed, 18 insertions(+), 11 deletions(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/VirtualizedTaskList.tsx b/worklenz-frontend/src/components/enhanced-kanban/VirtualizedTaskList.tsx index c6486b62..7269bf2e 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/VirtualizedTaskList.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/VirtualizedTaskList.tsx @@ -73,8 +73,17 @@ const VirtualizedTaskList: React.FC = ({ if (tasks.length === 0) { return ( -
-
No tasks in this group
+
+
+ No tasks in this group +
); } diff --git a/worklenz-frontend/src/components/kanban-board-management-v2/kanbanGroup.tsx b/worklenz-frontend/src/components/kanban-board-management-v2/kanbanGroup.tsx index 3409237e..98f57505 100644 --- a/worklenz-frontend/src/components/kanban-board-management-v2/kanbanGroup.tsx +++ b/worklenz-frontend/src/components/kanban-board-management-v2/kanbanGroup.tsx @@ -173,7 +173,7 @@ const KanbanGroup: React.FC = ({ .kanban-group-empty { text-align: center; color: #bfbfbf; - padding: 32px 0; + padding: 48px 16px; } .kanban-group-add-task { padding: 12px; diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index 8d3c8452..7ef747ed 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -54,6 +54,7 @@ import { setCustomColumnModalAttributes, toggleCustomColumnModalOpen, } from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice'; +import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice'; // Components import TaskRowWithSubtasks from './TaskRowWithSubtasks'; @@ -212,6 +213,7 @@ const TaskListV2Section: React.FC = () => { if (urlProjectId) { dispatch(fetchTasksV3(urlProjectId)); dispatch(fetchTaskListColumns(urlProjectId)); + dispatch(fetchPhasesByProjectId(urlProjectId)); } }, [dispatch, urlProjectId]); @@ -463,7 +465,7 @@ const TaskListV2Section: React.FC = () => { /> {isGroupEmpty && !isGroupCollapsed && (
-
+
{visibleColumns.map((column, index) => { const emptyColumnStyle = { width: column.width, @@ -482,7 +484,7 @@ const TaskListV2Section: React.FC = () => { })}
-
+
{t('noTasksInGroup')}
diff --git a/worklenz-frontend/src/components/task-management/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx index 07d0b679..335f2815 100644 --- a/worklenz-frontend/src/components/task-management/task-group.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -312,7 +312,7 @@ const TaskGroup: React.FC = React.memo( {groupTasks.length === 0 ? (
-
+
No tasks in this group
@@ -487,7 +487,7 @@ const TaskGroup: React.FC = React.memo( .task-group-empty { display: flex; - height: 80px; + height: 120px; align-items: center; background: var(--task-bg-primary, white); transition: background-color 0.3s ease; diff --git a/worklenz-frontend/src/components/task-management/task-phase-dropdown.tsx b/worklenz-frontend/src/components/task-management/task-phase-dropdown.tsx index 49eacc87..536215e0 100644 --- a/worklenz-frontend/src/components/task-management/task-phase-dropdown.tsx +++ b/worklenz-frontend/src/components/task-management/task-phase-dropdown.tsx @@ -35,8 +35,6 @@ const TaskPhaseDropdown: React.FC = ({ (phaseId: string, phaseName: string) => { if (!task.id || !phaseId || !connected) return; - console.log('🎯 Phase change initiated:', { taskId: task.id, phaseId, phaseName }); - socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), { task_id: task.id, phase_id: phaseId, @@ -51,8 +49,6 @@ const TaskPhaseDropdown: React.FC = ({ const handlePhaseClear = useCallback(() => { if (!task.id || !connected) return; - console.log('🎯 Phase clear initiated:', { taskId: task.id }); - socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), { task_id: task.id, phase_id: null, From a26d8d0f90ab4c654d44022499cd4f5fca58cf61 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 11 Jul 2025 18:09:03 +0530 Subject: [PATCH 06/11] feat(task-management): enhance task localization and progress visualization - Added localization entries for task statuses (To Do, In Progress, Done) across multiple languages including Albanian, German, Spanish, Portuguese, and Chinese. - Updated the GroupProgressBar component to improve visual representation of task progress with distinct color coding for each status. - Enhanced TaskGroupHeader to calculate and display group progress dynamically based on task completion and status distribution. - Integrated a new Convert To Subtask Drawer for improved task management functionality. --- .../public/locales/alb/task-management.json | 19 ++++- .../public/locales/de/task-management.json | 19 ++++- .../public/locales/en/task-management.json | 3 + .../public/locales/es/task-management.json | 19 ++++- .../public/locales/pt/task-management.json | 19 ++++- .../public/locales/zh/task-management.json | 3 + .../task-list-v2/GroupProgressBar.tsx | 18 ++--- .../task-list-v2/TaskGroupHeader.tsx | 81 +++++++++++++++++-- .../task-list-v2/TaskListV2Table.tsx | 4 + .../components/TaskContextMenu.tsx | 44 +++++++++- 10 files changed, 207 insertions(+), 22 deletions(-) diff --git a/worklenz-frontend/public/locales/alb/task-management.json b/worklenz-frontend/public/locales/alb/task-management.json index a156ef3f..d7ae3f13 100644 --- a/worklenz-frontend/public/locales/alb/task-management.json +++ b/worklenz-frontend/public/locales/alb/task-management.json @@ -17,5 +17,22 @@ "renamePhase": "Riemërto Fazën", "changeCategory": "Ndrysho Kategorinë", "clickToEditGroupName": "Kliko për të ndryshuar emrin e grupit", - "enterGroupName": "Shkruani emrin e grupit" + "enterGroupName": "Shkruani emrin e grupit", + "todo": "Për t'u bërë", + "inProgress": "Në progres", + "done": "E kryer", + + "indicators": { + "tooltips": { + "subtasks": "{{count}} nën-detyrë", + "subtasks_plural": "{{count}} nën-detyra", + "comments": "{{count}} koment", + "comments_plural": "{{count}} komente", + "attachments": "{{count}} bashkëngjitje", + "attachments_plural": "{{count}} bashkëngjitje", + "subscribers": "Detyra ka abonentë", + "dependencies": "Detyra ka varësi", + "recurring": "Detyrë përsëritëse" + } + } } diff --git a/worklenz-frontend/public/locales/de/task-management.json b/worklenz-frontend/public/locales/de/task-management.json index b20d94a4..1bbdf7c9 100644 --- a/worklenz-frontend/public/locales/de/task-management.json +++ b/worklenz-frontend/public/locales/de/task-management.json @@ -17,5 +17,22 @@ "renamePhase": "Phase umbenennen", "changeCategory": "Kategorie ändern", "clickToEditGroupName": "Klicken Sie, um den Gruppennamen zu bearbeiten", - "enterGroupName": "Gruppennamen eingeben" + "enterGroupName": "Gruppennamen eingeben", + "todo": "Zu erledigen", + "inProgress": "In Bearbeitung", + "done": "Erledigt", + + "indicators": { + "tooltips": { + "subtasks": "{{count}} Unteraufgabe", + "subtasks_plural": "{{count}} Unteraufgaben", + "comments": "{{count}} Kommentar", + "comments_plural": "{{count}} Kommentare", + "attachments": "{{count}} Anhang", + "attachments_plural": "{{count}} Anhänge", + "subscribers": "Aufgabe hat Abonnenten", + "dependencies": "Aufgabe hat Abhängigkeiten", + "recurring": "Wiederkehrende Aufgabe" + } + } } diff --git a/worklenz-frontend/public/locales/en/task-management.json b/worklenz-frontend/public/locales/en/task-management.json index 2d21c746..99b8b0d5 100644 --- a/worklenz-frontend/public/locales/en/task-management.json +++ b/worklenz-frontend/public/locales/en/task-management.json @@ -18,6 +18,9 @@ "changeCategory": "Change Category", "clickToEditGroupName": "Click to edit group name", "enterGroupName": "Enter group name", + "todo": "To Do", + "inProgress": "Doing", + "done": "Done", "indicators": { "tooltips": { diff --git a/worklenz-frontend/public/locales/es/task-management.json b/worklenz-frontend/public/locales/es/task-management.json index 1c80304c..e28569d1 100644 --- a/worklenz-frontend/public/locales/es/task-management.json +++ b/worklenz-frontend/public/locales/es/task-management.json @@ -17,5 +17,22 @@ "renamePhase": "Renombrar Fase", "changeCategory": "Cambiar Categoría", "clickToEditGroupName": "Haz clic para editar el nombre del grupo", - "enterGroupName": "Ingresa el nombre del grupo" + "enterGroupName": "Ingresa el nombre del grupo", + "todo": "Por Hacer", + "inProgress": "En Progreso", + "done": "Hecho", + + "indicators": { + "tooltips": { + "subtasks": "{{count}} subtarea", + "subtasks_plural": "{{count}} subtareas", + "comments": "{{count}} comentario", + "comments_plural": "{{count}} comentarios", + "attachments": "{{count}} adjunto", + "attachments_plural": "{{count}} adjuntos", + "subscribers": "La tarea tiene suscriptores", + "dependencies": "La tarea tiene dependencias", + "recurring": "Tarea recurrente" + } + } } diff --git a/worklenz-frontend/public/locales/pt/task-management.json b/worklenz-frontend/public/locales/pt/task-management.json index 946b3162..24beb53c 100644 --- a/worklenz-frontend/public/locales/pt/task-management.json +++ b/worklenz-frontend/public/locales/pt/task-management.json @@ -17,5 +17,22 @@ "renamePhase": "Renomear Fase", "changeCategory": "Alterar Categoria", "clickToEditGroupName": "Clique para editar o nome do grupo", - "enterGroupName": "Digite o nome do grupo" + "enterGroupName": "Digite o nome do grupo", + "todo": "A Fazer", + "inProgress": "Em Andamento", + "done": "Concluído", + + "indicators": { + "tooltips": { + "subtasks": "{{count}} subtarefa", + "subtasks_plural": "{{count}} subtarefas", + "comments": "{{count}} comentário", + "comments_plural": "{{count}} comentários", + "attachments": "{{count}} anexo", + "attachments_plural": "{{count}} anexos", + "subscribers": "Tarefa tem assinantes", + "dependencies": "Tarefa tem dependências", + "recurring": "Tarefa recorrente" + } + } } diff --git a/worklenz-frontend/public/locales/zh/task-management.json b/worklenz-frontend/public/locales/zh/task-management.json index 341ecc64..7f185e34 100644 --- a/worklenz-frontend/public/locales/zh/task-management.json +++ b/worklenz-frontend/public/locales/zh/task-management.json @@ -18,6 +18,9 @@ "changeCategory": "更改类别", "clickToEditGroupName": "点击编辑组名称", "enterGroupName": "输入组名称", + "todo": "待办", + "inProgress": "进行中", + "done": "已完成", "indicators": { "tooltips": { diff --git a/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx b/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx index fd280bdf..a8623d27 100644 --- a/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx +++ b/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx @@ -38,26 +38,26 @@ const GroupProgressBar: React.FC = ({ {/* Compact progress bar */}
- {/* Todo section - light gray */} + {/* Todo section - light green */} {todoProgress > 0 && (
)} - {/* Doing section - blue */} + {/* Doing section - medium green */} {doingProgress > 0 && (
)} - {/* Done section - green */} + {/* Done section - dark green */} {doneProgress > 0 && (
@@ -69,19 +69,19 @@ const GroupProgressBar: React.FC = ({
{todoProgress > 0 && (
)} {doingProgress > 0 && (
)} {doneProgress > 0 && (
)} diff --git a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx index 0b25be2e..d3f2e5b7 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx @@ -9,7 +9,7 @@ import { getContrastColor } from '@/utils/colorUtils'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { selectSelectedTaskIds, selectTask, deselectTask } from '@/features/task-management/selection.slice'; -import { selectGroups, fetchTasksV3 } from '@/features/task-management/task-management.slice'; +import { selectGroups, fetchTasksV3, selectAllTasksArray } from '@/features/task-management/task-management.slice'; import { selectCurrentGrouping } from '@/features/task-management/grouping.slice'; import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service'; @@ -43,8 +43,9 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o const dispatch = useAppDispatch(); const selectedTaskIds = useAppSelector(selectSelectedTaskIds); const groups = useAppSelector(selectGroups); + const allTasks = useAppSelector(selectAllTasksArray); const currentGrouping = useAppSelector(selectCurrentGrouping); - const { statusCategories } = useAppSelector(state => state.taskStatusReducer); + const { statusCategories, status: statusList } = useAppSelector(state => state.taskStatusReducer); const { trackMixpanelEvent } = useMixpanelTracking(); const { isOwnerOrAdmin } = useAuthService(); @@ -67,6 +68,74 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o return currentGroup?.taskIds || []; }, [currentGroup]); + // Calculate group progress values dynamically + const groupProgressValues = useMemo(() => { + if (!currentGroup || !allTasks.length) { + return { todoProgress: 0, doingProgress: 0, doneProgress: 0 }; + } + + const tasksInCurrentGroup = currentGroup.taskIds + .map(taskId => allTasks.find(task => task.id === taskId)) + .filter(task => task !== undefined); + + if (tasksInCurrentGroup.length === 0) { + return { todoProgress: 0, doingProgress: 0, doneProgress: 0 }; + } + + // If we're grouping by status, show progress based on task completion + if (currentGrouping === 'status') { + // For status grouping, calculate based on task progress values + const progressStats = tasksInCurrentGroup.reduce((acc, task) => { + const progress = task.progress || 0; + if (progress === 0) { + acc.todo += 1; + } else if (progress === 100) { + acc.done += 1; + } else { + acc.doing += 1; + } + return acc; + }, { todo: 0, doing: 0, done: 0 }); + + const totalTasks = tasksInCurrentGroup.length; + + return { + todoProgress: totalTasks > 0 ? Math.round((progressStats.todo / totalTasks) * 100) : 0, + doingProgress: totalTasks > 0 ? Math.round((progressStats.doing / totalTasks) * 100) : 0, + doneProgress: totalTasks > 0 ? Math.round((progressStats.done / totalTasks) * 100) : 0, + }; + } else { + // For priority/phase grouping, show progress based on status distribution + // Use a simplified approach based on status names and common patterns + const statusCounts = tasksInCurrentGroup.reduce((acc, task) => { + // Find the status by ID first + const statusInfo = statusList.find(s => s.id === task.status); + const statusName = statusInfo?.name?.toLowerCase() || task.status?.toLowerCase() || ''; + + // Categorize based on common status name patterns + if (statusName.includes('todo') || statusName.includes('to do') || statusName.includes('pending') || statusName.includes('open') || statusName.includes('backlog')) { + acc.todo += 1; + } else if (statusName.includes('doing') || statusName.includes('progress') || statusName.includes('active') || statusName.includes('working') || statusName.includes('development')) { + acc.doing += 1; + } else if (statusName.includes('done') || statusName.includes('completed') || statusName.includes('finished') || statusName.includes('closed') || statusName.includes('resolved')) { + acc.done += 1; + } else { + // Default unknown statuses to "doing" (in progress) + acc.doing += 1; + } + return acc; + }, { todo: 0, doing: 0, done: 0 }); + + const totalTasks = tasksInCurrentGroup.length; + + return { + todoProgress: totalTasks > 0 ? Math.round((statusCounts.todo / totalTasks) * 100) : 0, + doingProgress: totalTasks > 0 ? Math.round((statusCounts.doing / totalTasks) * 100) : 0, + doneProgress: totalTasks > 0 ? Math.round((statusCounts.done / totalTasks) * 100) : 0, + }; + } + }, [currentGroup, allTasks, statusList, currentGrouping]); + // Calculate selection state for this group const { isAllSelected, isPartiallySelected } = useMemo(() => { if (tasksInGroup.length === 0) { @@ -369,7 +438,7 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o {/* Progress Bar - sticky to the right edge during horizontal scroll */} {(currentGrouping === 'priority' || currentGrouping === 'phase') && - (group.todo_progress || group.doing_progress || group.done_progress) && ( + (groupProgressValues.todoProgress || groupProgressValues.doingProgress || groupProgressValues.doneProgress) && (
= ({ group, isCollapsed, o }} >
diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx index 7ef747ed..624ff623 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2Table.tsx @@ -65,6 +65,7 @@ import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-t import AddTaskRow from './components/AddTaskRow'; import { AddCustomColumnButton, CustomColumnHeader } from './components/CustomColumnComponents'; import TaskListSkeleton from './components/TaskListSkeleton'; +import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer'; // Hooks and utilities import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; @@ -766,6 +767,9 @@ const TaskListV2Section: React.FC = () => { {/* Custom Column Modal */} {createPortal(, document.body, 'custom-column-modal')} + + {/* Convert To Subtask Drawer */} + {createPortal(, document.body, 'convert-to-subtask-drawer')}
); diff --git a/worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx b/worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx index 0982dafa..28b0e119 100644 --- a/worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx +++ b/worklenz-frontend/src/components/task-list-v2/components/TaskContextMenu.tsx @@ -16,8 +16,8 @@ import { toggleTaskExpansion, updateTaskAssignees, } from '@/features/task-management/task-management.slice'; -import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; -import { setConvertToSubtaskDrawerOpen } from '@/features/task-drawer/task-drawer.slice'; +import { deselectAll, selectTasks } from '@/features/projects/bulkActions/bulkActionSlice'; +import { setConvertToSubtaskDrawerOpen } from '@/features/tasks/tasks.slice'; import { useTranslation } from 'react-i18next'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; import { @@ -412,7 +412,45 @@ const TaskContextMenu: React.FC = ({ key: 'convertToSubTask', label: (