From 26de439faba47f31faad76047c31b4272ce44ae5 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 11 Jul 2025 15:54:43 +0530 Subject: [PATCH] feat(task-management): add progress statistics and visual representation for task groups - Implemented progress calculations for tasks grouped by priority and phase, including todo, doing, and done counts. - Introduced a new GroupProgressBar component to visually represent task progress in the TaskGroupHeader. - Updated TaskGroupHeader and TaskListV2Table to integrate progress data and display the progress bar conditionally based on grouping. - Enhanced local storage handling for grouping preferences in the task management feature. --- .../src/controllers/tasks-controller-v2.ts | 61 ++++- .../task-list-v2/GroupProgressBar.tsx | 93 +++++++ .../task-list-v2/TaskGroupHeader.tsx | 237 ++++++++++++------ .../task-list-v2/TaskListV2Table.tsx | 4 + .../enhanced-kanban/enhanced-kanban.slice.ts | 2 +- .../task-management/grouping.slice.ts | 31 ++- 6 files changed, 350 insertions(+), 78 deletions(-) create mode 100644 worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 27df13e7..d941f824 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -1174,9 +1174,39 @@ export default class TasksControllerV2 extends TasksControllerBase { } }); + // Calculate progress stats for priority and phase grouping + if (groupBy === GroupBy.PRIORITY || groupBy === GroupBy.PHASE) { + Object.values(groupedResponse).forEach((group: any) => { + if (group.tasks && group.tasks.length > 0) { + const todoCount = group.tasks.filter((task: any) => { + // For tasks, we need to check their original status category + const originalTask = tasks.find(t => t.id === task.id); + return originalTask?.status_category?.is_todo; + }).length; + + const doingCount = group.tasks.filter((task: any) => { + const originalTask = tasks.find(t => t.id === task.id); + return originalTask?.status_category?.is_doing; + }).length; + + const doneCount = group.tasks.filter((task: any) => { + const originalTask = tasks.find(t => t.id === task.id); + return originalTask?.status_category?.is_done; + }).length; + + const total = group.tasks.length; + + // Calculate progress percentages + group.todo_progress = total > 0 ? +((todoCount / total) * 100).toFixed(0) : 0; + group.doing_progress = total > 0 ? +((doingCount / total) * 100).toFixed(0) : 0; + group.done_progress = total > 0 ? +((doneCount / total) * 100).toFixed(0) : 0; + } + }); + } + // Create unmapped group if there are tasks without proper phase assignment if (unmappedTasks.length > 0 && groupBy === GroupBy.PHASE) { - groupedResponse[UNMAPPED.toLowerCase()] = { + const unmappedGroup = { id: UNMAPPED, title: UNMAPPED, groupType: groupBy, @@ -1189,7 +1219,36 @@ export default class TasksControllerV2 extends TasksControllerBase { start_date: null, end_date: null, sort_index: 999, // Put unmapped group at the end + todo_progress: 0, + doing_progress: 0, + done_progress: 0, }; + + // Calculate progress stats for unmapped group + if (unmappedTasks.length > 0) { + const todoCount = unmappedTasks.filter((task: any) => { + const originalTask = tasks.find(t => t.id === task.id); + return originalTask?.status_category?.is_todo; + }).length; + + const doingCount = unmappedTasks.filter((task: any) => { + const originalTask = tasks.find(t => t.id === task.id); + return originalTask?.status_category?.is_doing; + }).length; + + const doneCount = unmappedTasks.filter((task: any) => { + const originalTask = tasks.find(t => t.id === task.id); + return originalTask?.status_category?.is_done; + }).length; + + const total = unmappedTasks.length; + + unmappedGroup.todo_progress = total > 0 ? +((todoCount / total) * 100).toFixed(0) : 0; + unmappedGroup.doing_progress = total > 0 ? +((doingCount / total) * 100).toFixed(0) : 0; + unmappedGroup.done_progress = total > 0 ? +((doneCount / total) * 100).toFixed(0) : 0; + } + + groupedResponse[UNMAPPED.toLowerCase()] = unmappedGroup; } // Sort tasks within each group by order diff --git a/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx b/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx new file mode 100644 index 00000000..fd280bdf --- /dev/null +++ b/worklenz-frontend/src/components/task-list-v2/GroupProgressBar.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; + +interface GroupProgressBarProps { + todoProgress: number; + doingProgress: number; + doneProgress: number; + groupType: string; +} + +const GroupProgressBar: React.FC = ({ + todoProgress, + doingProgress, + doneProgress, + groupType +}) => { + const { t } = useTranslation('task-management'); + + // Only show for priority and phase grouping + if (groupType !== 'priority' && groupType !== 'phase') { + return null; + } + + const total = todoProgress + doingProgress + doneProgress; + + // Don't show if no progress values exist + if (total === 0) { + return null; + } + + return ( +
+ {/* Compact progress text */} + + {doneProgress}% {t('done')} + + + {/* Compact progress bar */} +
+
+ {/* Todo section - light gray */} + {todoProgress > 0 && ( +
+ )} + {/* Doing section - blue */} + {doingProgress > 0 && ( +
+ )} + {/* Done section - green */} + {doneProgress > 0 && ( +
+ )} +
+
+ + {/* Small legend dots with better spacing */} +
+ {todoProgress > 0 && ( +
+ )} + {doingProgress > 0 && ( +
+ )} + {doneProgress > 0 && ( +
+ )} +
+
+ ); +}; + +export default GroupProgressBar; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx index 32456b86..0b25be2e 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskGroupHeader.tsx @@ -3,6 +3,7 @@ import { useDroppable } from '@dnd-kit/core'; // @ts-ignore: Heroicons module types import { ChevronDownIcon, ChevronRightIcon, EllipsisHorizontalIcon, PencilIcon, ArrowPathIcon } from '@heroicons/react/24/outline'; import { Checkbox, Dropdown, Menu, Input, Modal, Badge, Flex } from 'antd'; +import GroupProgressBar from './GroupProgressBar'; import { useTranslation } from 'react-i18next'; import { getContrastColor } from '@/utils/colorUtils'; import { useAppSelector } from '@/hooks/useAppSelector'; @@ -27,6 +28,10 @@ interface TaskGroupHeaderProps { name: string; count: number; color?: string; // Color for the group indicator + todo_progress?: number; + doing_progress?: number; + done_progress?: number; + groupType?: string; }; isCollapsed: boolean; onToggle: () => void; @@ -44,7 +49,7 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o const { isOwnerOrAdmin } = useAuthService(); const [dropdownVisible, setDropdownVisible] = useState(false); - const [categoryModalVisible, setCategoryModalVisible] = useState(false); + const [isRenaming, setIsRenaming] = useState(false); const [isChangingCategory, setIsChangingCategory] = useState(false); const [isEditingName, setIsEditingName] = useState(false); @@ -94,7 +99,12 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o // Handle inline name editing const handleNameSave = useCallback(async () => { - if (!editingName.trim() || editingName.trim() === group.name || isRenaming) return; + // If no changes or already renaming, just exit editing mode + if (!editingName.trim() || editingName.trim() === group.name || isRenaming) { + setIsEditingName(false); + setEditingName(group.name); + return; + } setIsRenaming(true); try { @@ -122,12 +132,12 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o // Refresh task list to get updated group names dispatch(fetchTasksV3(projectId)); - setIsEditingName(false); } catch (error) { logger.error('Error renaming group:', error); setEditingName(group.name); } finally { + setIsEditingName(false); setIsRenaming(false); } }, [editingName, group.name, group.id, currentGrouping, projectId, dispatch, trackMixpanelEvent, isRenaming]); @@ -150,9 +160,8 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o }, [group.name, handleNameSave]); const handleNameBlur = useCallback(() => { - setIsEditingName(false); - setEditingName(group.name); - }, [group.name]); + handleNameSave(); + }, [handleNameSave]); // Handle dropdown menu actions const handleRenameGroup = useCallback(() => { @@ -161,10 +170,7 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o setEditingName(group.name); }, [group.name]); - const handleChangeCategory = useCallback(() => { - setDropdownVisible(false); - setCategoryModalVisible(true); - }, []); + // Handle category change const handleCategoryChange = useCallback(async (categoryId: string, e?: React.MouseEvent) => { @@ -182,7 +188,6 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o // Refresh status list and tasks dispatch(fetchStatuses(projectId)); dispatch(fetchTasksV3(projectId)); - setCategoryModalVisible(false); } catch (error) { logger.error('Error changing category:', error); @@ -209,19 +214,30 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o // Only show "Change Category" when grouped by status if (currentGrouping === 'status') { - items.push({ - key: 'changeCategory', - icon: , - label: t('changeCategory'), + const categorySubMenuItems = statusCategories.map((category) => ({ + key: `category-${category.id}`, + label: ( +
+ + {category.name} +
+ ), onClick: (e: any) => { e?.domEvent?.stopPropagation(); - handleChangeCategory(); + handleCategoryChange(category.id || '', e?.domEvent); }, - }); + })); + + items.push({ + key: 'changeCategory', + icon: , + label: t('changeCategory'), + children: categorySubMenuItems, + } as any); } return items; - }, [currentGrouping, handleRenameGroup, handleChangeCategory, isOwnerOrAdmin]); + }, [currentGrouping, handleRenameGroup, handleCategoryChange, isOwnerOrAdmin, statusCategories, t]); // Make the group header droppable const { isOver, setNodeRef } = useDroppable({ @@ -232,75 +248,146 @@ const TaskGroupHeader: React.FC = ({ group, isCollapsed, o }, }); - return ( -
- {/* Drag Handle Space - ultra minimal width */} -
- {/* Chevron button */} -
- -
+
+ +
+ +
- {/* 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) => {