Merge branch 'main' of https://github.com/Worklenz/worklenz into test/row-kanban-board-v1.2.0
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -73,8 +73,17 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = ({
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="virtualized-empty-state" style={{ height }}>
|
||||
<div className="empty-message">No tasks in this group</div>
|
||||
<div className="virtualized-empty-state" style={{ height, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div className="empty-message" style={{
|
||||
padding: '32px 24px',
|
||||
color: '#8c8c8c',
|
||||
fontSize: '14px',
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #f0f0f0'
|
||||
}}>
|
||||
No tasks in this group
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ const KanbanGroup: React.FC<TaskGroupProps> = ({
|
||||
.kanban-group-empty {
|
||||
text-align: center;
|
||||
color: #bfbfbf;
|
||||
padding: 32px 0;
|
||||
padding: 48px 16px;
|
||||
}
|
||||
.kanban-group-add-task {
|
||||
padding: 12px;
|
||||
|
||||
@@ -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 green */}
|
||||
{todoProgress > 0 && (
|
||||
<div
|
||||
className="bg-green-200 dark:bg-green-800 transition-all duration-300"
|
||||
style={{ width: `${(todoProgress / total) * 100}%` }}
|
||||
title={`${t('todo')}: ${todoProgress}%`}
|
||||
/>
|
||||
)}
|
||||
{/* Doing section - medium green */}
|
||||
{doingProgress > 0 && (
|
||||
<div
|
||||
className="bg-green-400 dark:bg-green-600 transition-all duration-300"
|
||||
style={{ width: `${(doingProgress / total) * 100}%` }}
|
||||
title={`${t('inProgress')}: ${doingProgress}%`}
|
||||
/>
|
||||
)}
|
||||
{/* Done section - dark green */}
|
||||
{doneProgress > 0 && (
|
||||
<div
|
||||
className="bg-green-600 dark:bg-green-400 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-green-200 dark:bg-green-800 rounded-full"
|
||||
title={`${t('todo')}: ${todoProgress}%`}
|
||||
/>
|
||||
)}
|
||||
{doingProgress > 0 && (
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-green-400 dark:bg-green-600 rounded-full"
|
||||
title={`${t('inProgress')}: ${doingProgress}%`}
|
||||
/>
|
||||
)}
|
||||
{doneProgress > 0 && (
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-green-600 dark:bg-green-400 rounded-full"
|
||||
title={`${t('done')}: ${doneProgress}%`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupProgressBar;
|
||||
@@ -3,12 +3,13 @@ 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';
|
||||
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';
|
||||
@@ -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;
|
||||
@@ -38,13 +43,14 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ 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();
|
||||
|
||||
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);
|
||||
@@ -62,6 +68,74 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ 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) {
|
||||
@@ -94,7 +168,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ 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 +201,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ 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 +229,8 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ 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 +239,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ 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 +257,6 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ 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 +283,30 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
||||
|
||||
// Only show "Change Category" when grouped by status
|
||||
if (currentGrouping === 'status') {
|
||||
items.push({
|
||||
key: 'changeCategory',
|
||||
icon: <ArrowPathIcon className="h-4 w-4" />,
|
||||
label: t('changeCategory'),
|
||||
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();
|
||||
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;
|
||||
}, [currentGrouping, handleRenameGroup, handleChangeCategory, isOwnerOrAdmin]);
|
||||
}, [currentGrouping, handleRenameGroup, handleCategoryChange, isOwnerOrAdmin, statusCategories, t]);
|
||||
|
||||
// Make the group header droppable
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
@@ -232,75 +317,146 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
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 ${
|
||||
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
|
||||
color: headerTextColor,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 25, // Higher than task rows but lower than column headers (z-30)
|
||||
height: '36px',
|
||||
minHeight: '36px',
|
||||
maxHeight: '36px'
|
||||
}}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{/* Drag Handle Space - ultra minimal width */}
|
||||
<div style={{ width: '20px' }} className="flex items-center justify-center">
|
||||
{/* Chevron button */}
|
||||
<button
|
||||
className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
|
||||
style={{ backgroundColor: 'transparent', color: headerTextColor }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="transition-transform duration-300 ease-out"
|
||||
style={{
|
||||
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)',
|
||||
transformOrigin: 'center'
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<div
|
||||
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 ${
|
||||
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
|
||||
color: headerTextColor,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 25, // Higher than task rows but lower than column headers (z-30)
|
||||
height: '36px',
|
||||
minHeight: '36px',
|
||||
maxHeight: '36px'
|
||||
}}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{/* Drag Handle Space - ultra minimal width */}
|
||||
<div style={{ width: '20px' }} className="flex items-center justify-center">
|
||||
{/* Chevron button */}
|
||||
<button
|
||||
className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
|
||||
style={{ backgroundColor: 'transparent', color: headerTextColor }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon className="h-3 w-3" style={{ color: headerTextColor }} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Select All Checkbox Space - ultra minimal width */}
|
||||
<div style={{ width: '28px' }} className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isPartiallySelected}
|
||||
onChange={handleSelectAllChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
color: headerTextColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Select All Checkbox Space - ultra minimal width */}
|
||||
<div style={{ width: '28px' }} className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isPartiallySelected}
|
||||
onChange={handleSelectAllChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
color: headerTextColor,
|
||||
}}
|
||||
/>
|
||||
</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">
|
||||
{/* Group name and count */}
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className="text-sm font-semibold pr-2"
|
||||
style={{ color: headerTextColor }}
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
{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
|
||||
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 }}>
|
||||
({group.count})
|
||||
</span>
|
||||
</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') &&
|
||||
(groupProgressValues.todoProgress || groupProgressValues.doingProgress || groupProgressValues.doneProgress) && (
|
||||
<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={groupProgressValues.todoProgress}
|
||||
doingProgress={groupProgressValues.doingProgress}
|
||||
doneProgress={groupProgressValues.doneProgress}
|
||||
groupType={group.groupType || currentGrouping || ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
@@ -64,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';
|
||||
@@ -212,6 +214,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
if (urlProjectId) {
|
||||
dispatch(fetchTasksV3(urlProjectId));
|
||||
dispatch(fetchTaskListColumns(urlProjectId));
|
||||
dispatch(fetchPhasesByProjectId(urlProjectId));
|
||||
}
|
||||
}, [dispatch, urlProjectId]);
|
||||
|
||||
@@ -452,6 +455,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)}
|
||||
@@ -459,7 +466,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
/>
|
||||
{isGroupEmpty && !isGroupCollapsed && (
|
||||
<div className="relative w-full">
|
||||
<div className="flex items-center min-w-max px-1 py-3">
|
||||
<div className="flex items-center min-w-max px-1 py-6">
|
||||
{visibleColumns.map((column, index) => {
|
||||
const emptyColumnStyle = {
|
||||
width: column.width,
|
||||
@@ -478,7 +485,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
})}
|
||||
</div>
|
||||
<div className="absolute left-4 top-1/2 transform -translate-y-1/2 flex items-center">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-900 px-3 py-1.5 rounded-md border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-900 px-4 py-3 rounded-md border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
{t('noTasksInGroup')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -760,6 +767,9 @@ const TaskListV2Section: React.FC = () => {
|
||||
|
||||
{/* Custom Column Modal */}
|
||||
{createPortal(<CustomColumnModal />, document.body, 'custom-column-modal')}
|
||||
|
||||
{/* Convert To Subtask Drawer */}
|
||||
{createPortal(<ConvertToSubtaskDrawer />, document.body, 'convert-to-subtask-drawer')}
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
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, selectTasks } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||
import { setConvertToSubtaskDrawerOpen } from '@/features/tasks/tasks.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<TaskContextMenuProps> = ({
|
||||
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<HTMLDivElement>(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: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: status.color_code }}
|
||||
></span>
|
||||
<span>{status.name}</span>
|
||||
</div>
|
||||
),
|
||||
onClick: () => handleStatusMoveTo(status.id!),
|
||||
}));
|
||||
} else if (currentGrouping === IGroupBy.PRIORITY) {
|
||||
options = priorityList.filter(priority => priority.id).map(priority => ({
|
||||
key: priority.id!,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: priority.color_code }}
|
||||
></span>
|
||||
<span>{priority.name}</span>
|
||||
</div>
|
||||
),
|
||||
onClick: () => handlePriorityMoveTo(priority.id!),
|
||||
}));
|
||||
} else if (currentGrouping === IGroupBy.PHASE) {
|
||||
options = phaseList.filter(phase => phase.id).map(phase => ({
|
||||
key: phase.id!,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: phase.color_code }}
|
||||
></span>
|
||||
<span>{phase.name}</span>
|
||||
</div>
|
||||
),
|
||||
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: (
|
||||
<button
|
||||
onClick={handleAssignToMe}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||
disabled={updatingAssignToMe}
|
||||
>
|
||||
{updatingAssignToMe ? (
|
||||
<LoadingOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
) : (
|
||||
<UserAddOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
)}
|
||||
<span>{t('contextMenu.assignToMe')}</span>
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Add Move To submenu if there are options
|
||||
const moveToOptions = getMoveToOptions();
|
||||
if (moveToOptions.length > 0) {
|
||||
items.push({
|
||||
key: 'moveTo',
|
||||
label: (
|
||||
<div className="relative group">
|
||||
<button className="flex items-center justify-between gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<RetweetOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('contextMenu.moveTo')}</span>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-500 dark:text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<ul className="absolute left-full top-0 mt-0 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-20 hidden group-hover:block">
|
||||
{moveToOptions.map(option => (
|
||||
<li key={option.key}>
|
||||
<button
|
||||
onClick={option.onClick}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Add Archive/Unarchive for parent tasks only
|
||||
if (!task?.parent_task_id) {
|
||||
items.push({
|
||||
key: 'archive',
|
||||
label: (
|
||||
<button
|
||||
onClick={handleArchive}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||
>
|
||||
<InboxOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{archived ? t('contextMenu.unarchive') : t('contextMenu.archive')}</span>
|
||||
</button>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// 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: (
|
||||
<button
|
||||
onClick={() => {
|
||||
// Convert task to the format expected by bulkActionSlice
|
||||
const projectTask = {
|
||||
id: task.id,
|
||||
name: task.title || task.name || '',
|
||||
task_key: task.task_key,
|
||||
status: task.status,
|
||||
status_id: task.status,
|
||||
priority: task.priority,
|
||||
phase_id: task.phase,
|
||||
phase_name: task.phase,
|
||||
description: task.description,
|
||||
start_date: task.startDate,
|
||||
end_date: task.dueDate,
|
||||
total_hours: task.timeTracking?.estimated || 0,
|
||||
total_minutes: task.timeTracking?.logged || 0,
|
||||
progress: task.progress,
|
||||
sub_tasks_count: task.sub_tasks_count || 0,
|
||||
assignees: task.assignees?.map((assigneeId: string) => ({
|
||||
id: assigneeId,
|
||||
name: '',
|
||||
email: '',
|
||||
avatar_url: '',
|
||||
team_member_id: assigneeId,
|
||||
project_member_id: assigneeId,
|
||||
})) || [],
|
||||
labels: task.labels || [],
|
||||
manual_progress: false,
|
||||
created_at: task.createdAt,
|
||||
updated_at: task.updatedAt,
|
||||
sort_order: task.order,
|
||||
};
|
||||
|
||||
// Select the task in bulk action reducer
|
||||
dispatch(selectTasks([projectTask]));
|
||||
|
||||
// Open the drawer
|
||||
dispatch(setConvertToSubtaskDrawerOpen(true));
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||
>
|
||||
<DoubleRightOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('contextMenu.convertToSubTask')}</span>
|
||||
</button>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Add Convert to Task for subtasks
|
||||
if (task?.parent_task_id) {
|
||||
items.push({
|
||||
key: 'convertToTask',
|
||||
label: (
|
||||
<button
|
||||
onClick={handleConvertToTask}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||
>
|
||||
<DoubleRightOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('contextMenu.convertToTask')}</span>
|
||||
</button>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Add Delete
|
||||
items.push({
|
||||
key: 'delete',
|
||||
label: (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/20 w-full text-left"
|
||||
>
|
||||
<DeleteOutlined className="text-red-500 dark:text-red-400" />
|
||||
<span>{t('contextMenu.delete')}</span>
|
||||
</button>
|
||||
),
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [
|
||||
task,
|
||||
projectId,
|
||||
updatingAssignToMe,
|
||||
archived,
|
||||
handleAssignToMe,
|
||||
handleArchive,
|
||||
handleDelete,
|
||||
handleConvertToTask,
|
||||
getMoveToOptions,
|
||||
dispatch,
|
||||
t,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg py-1 min-w-48"
|
||||
style={{
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<ul className="list-none p-0 m-0">
|
||||
{menuItems.map(item => (
|
||||
<li key={item.key} className="relative group">
|
||||
{item.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskContextMenu;
|
||||
@@ -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<TitleColumnProps> = memo(({
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(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<TitleColumnProps> = 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<TitleColumnProps> = memo(({
|
||||
e.preventDefault();
|
||||
onEditTaskName(true);
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
title={taskDisplayName}
|
||||
>
|
||||
{taskDisplayName}
|
||||
@@ -251,6 +276,17 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenuVisible && createPortal(
|
||||
<TaskContextMenu
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
position={contextMenuPosition}
|
||||
onClose={handleContextMenuClose}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<ManagePhaseModalProps> = ({
|
||||
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<ManagePhaseModalProps> = ({
|
||||
).unwrap();
|
||||
|
||||
if (res.done) {
|
||||
dispatch(updatePhaseLabel(phaseName));
|
||||
setInitialPhaseName(phaseName);
|
||||
await refreshTasks();
|
||||
}
|
||||
@@ -428,7 +430,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
<Title level={4} className={`m-0 font-semibold ${
|
||||
isDarkMode ? 'text-gray-100' : 'text-gray-800'
|
||||
}`}>
|
||||
{t('configurePhases')}
|
||||
{t('configure')} {phaseName || project?.phase_label || t('phasesText')}
|
||||
</Title>
|
||||
}
|
||||
open={open}
|
||||
@@ -495,7 +497,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
<Text className={`text-xs font-medium ${
|
||||
isDarkMode ? 'text-gray-300' : 'text-blue-700'
|
||||
}`}>
|
||||
🎨 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.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -558,7 +560,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
<Text className={`text-xs font-medium ${
|
||||
isDarkMode ? 'text-gray-300' : 'text-gray-700'
|
||||
}`}>
|
||||
{t('phaseOptions')}
|
||||
{phaseName || project?.phase_label || t('phasesText')} {t('optionsText')}
|
||||
</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -601,7 +603,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
isDarkMode ? 'text-gray-400' : 'text-gray-500'
|
||||
}`}>
|
||||
<Text className="text-sm font-medium">
|
||||
{t('noPhasesFound')}
|
||||
{t('no')} {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()} {t('found')}
|
||||
</Text>
|
||||
<br />
|
||||
<Button
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<ManageStatusModalProps> = ({
|
||||
// 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<ManageStatusModalProps> = ({
|
||||
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<ManageStatusModalProps> = ({
|
||||
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<ManageStatusModalProps> = ({
|
||||
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<ManageStatusModalProps> = ({
|
||||
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);
|
||||
|
||||
@@ -369,6 +369,7 @@ const FilterDropdown: React.FC<{
|
||||
dispatch?: any;
|
||||
onManageStatus?: () => void;
|
||||
onManagePhase?: () => void;
|
||||
projectPhaseLabel?: string; // Add this prop
|
||||
}> = ({
|
||||
section,
|
||||
onSelectionChange,
|
||||
@@ -380,6 +381,7 @@ const FilterDropdown: React.FC<{
|
||||
dispatch,
|
||||
onManageStatus,
|
||||
onManagePhase,
|
||||
projectPhaseLabel, // Add this prop
|
||||
}) => {
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
// Add permission checks for groupBy section
|
||||
@@ -495,7 +497,7 @@ const FilterDropdown: React.FC<{
|
||||
isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'
|
||||
}`}
|
||||
>
|
||||
{t('managePhases')}
|
||||
{t('manage')} {projectPhaseLabel || t('phasesText')}
|
||||
</button>
|
||||
)}
|
||||
{section.selectedValues[0] === 'status' && (
|
||||
@@ -994,6 +996,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ 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<ImprovedTaskFiltersProps> = ({ position, cla
|
||||
dispatch={dispatch}
|
||||
onManageStatus={() => setShowManageStatusModal(true)}
|
||||
onManagePhase={() => setShowManagePhaseModal(true)}
|
||||
projectPhaseLabel={projectPhaseLabel}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -312,7 +312,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(
|
||||
{groupTasks.length === 0 ? (
|
||||
<div className="task-group-empty">
|
||||
<div className="task-table-fixed-columns">
|
||||
<div style={{ width: '380px', padding: '20px 12px' }}>
|
||||
<div style={{ width: '380px', padding: '32px 12px' }}>
|
||||
<div className="text-center text-gray-500">
|
||||
<Text type="secondary">No tasks in this group</Text>
|
||||
<br />
|
||||
@@ -487,7 +487,7 @@ const TaskGroup: React.FC<TaskGroupProps> = 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;
|
||||
|
||||
@@ -35,8 +35,6 @@ const TaskPhaseDropdown: React.FC<TaskPhaseDropdownProps> = ({
|
||||
(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<TaskPhaseDropdownProps> = ({
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user