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:
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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') {
|
||||||
items.push({
|
const categorySubMenuItems = statusCategories.map((category) => ({
|
||||||
key: 'changeCategory',
|
key: `category-${category.id}`,
|
||||||
icon: <ArrowPathIcon className="h-4 w-4" />,
|
label: (
|
||||||
label: t('changeCategory'),
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge color={category.color_code} />
|
||||||
|
<span>{category.name}</span>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
onClick: (e: any) => {
|
onClick: (e: any) => {
|
||||||
e?.domEvent?.stopPropagation();
|
e?.domEvent?.stopPropagation();
|
||||||
handleChangeCategory();
|
handleCategoryChange(category.id || '', e?.domEvent);
|
||||||
},
|
},
|
||||||
});
|
}));
|
||||||
|
|
||||||
|
items.push({
|
||||||
|
key: 'changeCategory',
|
||||||
|
icon: <ArrowPathIcon className="h-4 w-4" />,
|
||||||
|
label: t('changeCategory'),
|
||||||
|
children: categorySubMenuItems,
|
||||||
|
} as any);
|
||||||
}
|
}
|
||||||
|
|
||||||
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({
|
||||||
@@ -232,75 +248,146 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="relative flex items-center">
|
||||||
ref={setNodeRef}
|
<div
|
||||||
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 ${
|
ref={setNodeRef}
|
||||||
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
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 ${
|
||||||
}`}
|
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
||||||
style={{
|
}`}
|
||||||
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
|
style={{
|
||||||
color: headerTextColor,
|
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
|
||||||
position: 'sticky',
|
color: headerTextColor,
|
||||||
top: 0,
|
position: 'sticky',
|
||||||
zIndex: 25, // Higher than task rows but lower than column headers (z-30)
|
top: 0,
|
||||||
height: '36px',
|
zIndex: 25, // Higher than task rows but lower than column headers (z-30)
|
||||||
minHeight: '36px',
|
height: '36px',
|
||||||
maxHeight: '36px'
|
minHeight: '36px',
|
||||||
}}
|
maxHeight: '36px'
|
||||||
onClick={onToggle}
|
}}
|
||||||
>
|
onClick={onToggle}
|
||||||
{/* Drag Handle Space - ultra minimal width */}
|
>
|
||||||
<div style={{ width: '20px' }} className="flex items-center justify-center">
|
{/* Drag Handle Space - ultra minimal width */}
|
||||||
{/* Chevron button */}
|
<div style={{ width: '20px' }} className="flex items-center justify-center">
|
||||||
<button
|
{/* Chevron button */}
|
||||||
className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
|
<button
|
||||||
style={{ backgroundColor: 'transparent', color: headerTextColor }}
|
className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
|
||||||
onClick={(e) => {
|
style={{ backgroundColor: 'transparent', color: headerTextColor }}
|
||||||
e.stopPropagation();
|
onClick={(e) => {
|
||||||
onToggle();
|
e.stopPropagation();
|
||||||
}}
|
onToggle();
|
||||||
>
|
|
||||||
<div
|
|
||||||
className="transition-transform duration-300 ease-out"
|
|
||||||
style={{
|
|
||||||
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)',
|
|
||||||
transformOrigin: 'center'
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChevronRightIcon className="h-3 w-3" style={{ color: headerTextColor }} />
|
<div
|
||||||
</div>
|
className="transition-transform duration-300 ease-out"
|
||||||
</button>
|
style={{
|
||||||
</div>
|
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)',
|
||||||
|
transformOrigin: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="h-3 w-3" style={{ color: headerTextColor }} />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Select All Checkbox Space - ultra minimal width */}
|
{/* Select All Checkbox Space - ultra minimal width */}
|
||||||
<div style={{ width: '28px' }} className="flex items-center justify-center">
|
<div style={{ width: '28px' }} className="flex items-center justify-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isAllSelected}
|
checked={isAllSelected}
|
||||||
indeterminate={isPartiallySelected}
|
indeterminate={isPartiallySelected}
|
||||||
onChange={handleSelectAllChange}
|
onChange={handleSelectAllChange}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
color: headerTextColor,
|
color: headerTextColor,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Group indicator and name - no gap at all */}
|
{/* Group indicator and name - no gap at all */}
|
||||||
<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">
|
||||||
<span
|
{isEditingName ? (
|
||||||
className="text-sm font-semibold pr-2"
|
<Input
|
||||||
style={{ color: headerTextColor }}
|
value={editingName}
|
||||||
>
|
onChange={(e) => setEditingName(e.target.value)}
|
||||||
{group.name}
|
onKeyDown={handleNameKeyDown}
|
||||||
</span>
|
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
|
||||||
|
className="text-sm font-semibold pr-2 cursor-pointer hover:underline"
|
||||||
|
style={{ color: headerTextColor }}
|
||||||
|
onClick={handleNameClick}
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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)}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>) => {
|
||||||
|
|||||||
Reference in New Issue
Block a user