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.
This commit is contained in:
chamikaJ
2025-07-11 15:54:43 +05:30
parent 5c938586b8
commit 26de439fab
6 changed files with 350 additions and 78 deletions

View File

@@ -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 // Create unmapped group if there are tasks without proper phase assignment
if (unmappedTasks.length > 0 && groupBy === GroupBy.PHASE) { if (unmappedTasks.length > 0 && groupBy === GroupBy.PHASE) {
groupedResponse[UNMAPPED.toLowerCase()] = { const unmappedGroup = {
id: UNMAPPED, id: UNMAPPED,
title: UNMAPPED, title: UNMAPPED,
groupType: groupBy, groupType: groupBy,
@@ -1189,7 +1219,36 @@ export default class TasksControllerV2 extends TasksControllerBase {
start_date: null, start_date: null,
end_date: null, end_date: null,
sort_index: 999, // Put unmapped group at the end 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 // Sort tasks within each group by order

View File

@@ -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<GroupProgressBarProps> = ({
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 (
<div className="flex items-center gap-2">
{/* Compact progress text */}
<span className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap font-medium">
{doneProgress}% {t('done')}
</span>
{/* Compact progress bar */}
<div className="w-20 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 overflow-hidden">
<div className="h-full flex">
{/* Todo section - light gray */}
{todoProgress > 0 && (
<div
className="bg-gray-300 dark:bg-gray-600 transition-all duration-300"
style={{ width: `${(todoProgress / total) * 100}%` }}
title={`${t('todo')}: ${todoProgress}%`}
/>
)}
{/* Doing section - blue */}
{doingProgress > 0 && (
<div
className="bg-blue-500 transition-all duration-300"
style={{ width: `${(doingProgress / total) * 100}%` }}
title={`${t('inProgress')}: ${doingProgress}%`}
/>
)}
{/* Done section - green */}
{doneProgress > 0 && (
<div
className="bg-green-500 transition-all duration-300"
style={{ width: `${(doneProgress / total) * 100}%` }}
title={`${t('done')}: ${doneProgress}%`}
/>
)}
</div>
</div>
{/* Small legend dots with better spacing */}
<div className="flex items-center gap-1">
{todoProgress > 0 && (
<div
className="w-1.5 h-1.5 bg-gray-300 dark:bg-gray-600 rounded-full"
title={`${t('todo')}: ${todoProgress}%`}
/>
)}
{doingProgress > 0 && (
<div
className="w-1.5 h-1.5 bg-blue-500 rounded-full"
title={`${t('inProgress')}: ${doingProgress}%`}
/>
)}
{doneProgress > 0 && (
<div
className="w-1.5 h-1.5 bg-green-500 rounded-full"
title={`${t('done')}: ${doneProgress}%`}
/>
)}
</div>
</div>
);
};
export default GroupProgressBar;

View File

@@ -3,6 +3,7 @@ import { useDroppable } from '@dnd-kit/core';
// @ts-ignore: Heroicons module types // @ts-ignore: Heroicons module types
import { ChevronDownIcon, ChevronRightIcon, EllipsisHorizontalIcon, PencilIcon, ArrowPathIcon } from '@heroicons/react/24/outline'; import { ChevronDownIcon, ChevronRightIcon, EllipsisHorizontalIcon, PencilIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
import { Checkbox, Dropdown, Menu, Input, Modal, Badge, Flex } from 'antd'; import { Checkbox, Dropdown, Menu, Input, Modal, Badge, Flex } from 'antd';
import GroupProgressBar from './GroupProgressBar';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { getContrastColor } from '@/utils/colorUtils'; import { getContrastColor } from '@/utils/colorUtils';
import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppSelector } from '@/hooks/useAppSelector';
@@ -27,6 +28,10 @@ interface TaskGroupHeaderProps {
name: string; name: string;
count: number; count: number;
color?: string; // Color for the group indicator color?: string; // Color for the group indicator
todo_progress?: number;
doing_progress?: number;
done_progress?: number;
groupType?: string;
}; };
isCollapsed: boolean; isCollapsed: boolean;
onToggle: () => void; onToggle: () => void;
@@ -44,7 +49,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
const { isOwnerOrAdmin } = useAuthService(); const { isOwnerOrAdmin } = useAuthService();
const [dropdownVisible, setDropdownVisible] = useState(false); const [dropdownVisible, setDropdownVisible] = useState(false);
const [categoryModalVisible, setCategoryModalVisible] = useState(false);
const [isRenaming, setIsRenaming] = useState(false); const [isRenaming, setIsRenaming] = useState(false);
const [isChangingCategory, setIsChangingCategory] = useState(false); const [isChangingCategory, setIsChangingCategory] = useState(false);
const [isEditingName, setIsEditingName] = useState(false); const [isEditingName, setIsEditingName] = useState(false);
@@ -94,7 +99,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Handle inline name editing // Handle inline name editing
const handleNameSave = useCallback(async () => { 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); setIsRenaming(true);
try { try {
@@ -122,12 +132,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Refresh task list to get updated group names // Refresh task list to get updated group names
dispatch(fetchTasksV3(projectId)); dispatch(fetchTasksV3(projectId));
setIsEditingName(false);
} catch (error) { } catch (error) {
logger.error('Error renaming group:', error); logger.error('Error renaming group:', error);
setEditingName(group.name); setEditingName(group.name);
} finally { } finally {
setIsEditingName(false);
setIsRenaming(false); setIsRenaming(false);
} }
}, [editingName, group.name, group.id, currentGrouping, projectId, dispatch, trackMixpanelEvent, isRenaming]); }, [editingName, group.name, group.id, currentGrouping, projectId, dispatch, trackMixpanelEvent, isRenaming]);
@@ -150,9 +160,8 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
}, [group.name, handleNameSave]); }, [group.name, handleNameSave]);
const handleNameBlur = useCallback(() => { const handleNameBlur = useCallback(() => {
setIsEditingName(false); handleNameSave();
setEditingName(group.name); }, [handleNameSave]);
}, [group.name]);
// Handle dropdown menu actions // Handle dropdown menu actions
const handleRenameGroup = useCallback(() => { const handleRenameGroup = useCallback(() => {
@@ -161,10 +170,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
setEditingName(group.name); setEditingName(group.name);
}, [group.name]); }, [group.name]);
const handleChangeCategory = useCallback(() => {
setDropdownVisible(false);
setCategoryModalVisible(true);
}, []);
// Handle category change // Handle category change
const handleCategoryChange = useCallback(async (categoryId: string, e?: React.MouseEvent) => { const handleCategoryChange = useCallback(async (categoryId: string, e?: React.MouseEvent) => {
@@ -182,7 +188,6 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Refresh status list and tasks // Refresh status list and tasks
dispatch(fetchStatuses(projectId)); dispatch(fetchStatuses(projectId));
dispatch(fetchTasksV3(projectId)); dispatch(fetchTasksV3(projectId));
setCategoryModalVisible(false);
} catch (error) { } catch (error) {
logger.error('Error changing category:', error); logger.error('Error changing category:', error);
@@ -209,19 +214,30 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
// Only show "Change Category" when grouped by status // Only show "Change Category" when grouped by status
if (currentGrouping === 'status') { if (currentGrouping === 'status') {
const categorySubMenuItems = statusCategories.map((category) => ({
key: `category-${category.id}`,
label: (
<div className="flex items-center gap-2">
<Badge color={category.color_code} />
<span>{category.name}</span>
</div>
),
onClick: (e: any) => {
e?.domEvent?.stopPropagation();
handleCategoryChange(category.id || '', e?.domEvent);
},
}));
items.push({ items.push({
key: 'changeCategory', key: 'changeCategory',
icon: <ArrowPathIcon className="h-4 w-4" />, icon: <ArrowPathIcon className="h-4 w-4" />,
label: t('changeCategory'), label: t('changeCategory'),
onClick: (e: any) => { children: categorySubMenuItems,
e?.domEvent?.stopPropagation(); } as any);
handleChangeCategory();
},
});
} }
return items; return items;
}, [currentGrouping, handleRenameGroup, handleChangeCategory, isOwnerOrAdmin]); }, [currentGrouping, handleRenameGroup, handleCategoryChange, isOwnerOrAdmin, statusCategories, t]);
// Make the group header droppable // Make the group header droppable
const { isOver, setNodeRef } = useDroppable({ const { isOver, setNodeRef } = useDroppable({
@@ -233,6 +249,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
}); });
return ( return (
<div className="relative flex items-center">
<div <div
ref={setNodeRef} ref={setNodeRef}
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${ className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${
@@ -290,17 +307,87 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
<div className="flex items-center flex-1 ml-1"> <div className="flex items-center flex-1 ml-1">
{/* Group name and count */} {/* Group name and count */}
<div className="flex items-center"> <div className="flex items-center">
{isEditingName ? (
<Input
value={editingName}
onChange={(e) => 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()}
/>
) : (
<span <span
className="text-sm font-semibold pr-2" className="text-sm font-semibold pr-2 cursor-pointer hover:underline"
style={{ color: headerTextColor }} style={{ color: headerTextColor }}
onClick={handleNameClick}
> >
{group.name} {group.name}
</span> </span>
)}
<span className="text-sm font-semibold ml-1" style={{ color: headerTextColor }}> <span className="text-sm font-semibold ml-1" style={{ color: headerTextColor }}>
({group.count}) ({group.count})
</span> </span>
</div> </div>
</div> </div>
{/* Three-dot menu - only show for status and phase grouping */}
{menuItems.length > 0 && (currentGrouping === 'status' || currentGrouping === 'phase') && (
<div className="flex items-center ml-2">
<Dropdown
menu={{ items: menuItems }}
trigger={['click']}
open={dropdownVisible}
onOpenChange={setDropdownVisible}
placement="bottomRight"
overlayStyle={{ zIndex: 1000 }}
>
<button
className="p-1 rounded-sm hover:bg-black hover:bg-opacity-10 transition-colors duration-200"
style={{ color: headerTextColor }}
onClick={(e) => {
e.stopPropagation();
setDropdownVisible(!dropdownVisible);
}}
>
<EllipsisHorizontalIcon className="h-4 w-4" />
</button>
</Dropdown>
</div>
)}
</div>
{/* Progress Bar - sticky to the right edge during horizontal scroll */}
{(currentGrouping === 'priority' || currentGrouping === 'phase') &&
(group.todo_progress || group.doing_progress || group.done_progress) && (
<div
className="flex items-center bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm px-3 py-1.5 ml-auto"
style={{
position: 'sticky',
right: '16px',
zIndex: 35, // Higher than header
minWidth: '160px',
height: '30px'
}}
>
<GroupProgressBar
todoProgress={group.todo_progress || 0}
doingProgress={group.doing_progress || 0}
doneProgress={group.done_progress || 0}
groupType={group.groupType || currentGrouping || ''}
/>
</div>
)}
</div> </div>
); );
}; };

View File

@@ -452,6 +452,10 @@ const TaskListV2Section: React.FC = () => {
name: group.title, name: group.title,
count: group.actualCount, count: group.actualCount,
color: group.color, color: group.color,
todo_progress: group.todo_progress,
doing_progress: group.doing_progress,
done_progress: group.done_progress,
groupType: group.groupType,
}} }}
isCollapsed={isGroupCollapsed} isCollapsed={isGroupCollapsed}
onToggle={() => handleGroupCollapse(group.id)} onToggle={() => handleGroupCollapse(group.id)}

View File

@@ -33,7 +33,7 @@ export const GROUP_BY_OPTIONS: IGroupByOption[] = [
{ label: 'Phase', value: IGroupBy.PHASE }, { 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 => { export const getCurrentGroup = (): IGroupBy => {
const key = localStorage.getItem(LOCALSTORAGE_GROUP_KEY); const key = localStorage.getItem(LOCALSTORAGE_GROUP_KEY);

View File

@@ -17,8 +17,36 @@ interface LocalGroupingState {
collapsedGroups: string[]; 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 = { const initialState: LocalGroupingState = {
currentGrouping: null, currentGrouping: loadGroupingFromLocalStorage(),
customPhases: ['Planning', 'Development', 'Testing', 'Deployment'], customPhases: ['Planning', 'Development', 'Testing', 'Deployment'],
groupOrder: { groupOrder: {
status: ['todo', 'doing', 'done'], status: ['todo', 'doing', 'done'],
@@ -35,6 +63,7 @@ const groupingSlice = createSlice({
reducers: { reducers: {
setCurrentGrouping: (state, action: PayloadAction<GroupingType | null>) => { setCurrentGrouping: (state, action: PayloadAction<GroupingType | null>) => {
state.currentGrouping = action.payload; state.currentGrouping = action.payload;
saveGroupingToLocalStorage(action.payload);
}, },
addCustomPhase: (state, action: PayloadAction<string>) => { addCustomPhase: (state, action: PayloadAction<string>) => {