feat(task-management): optimize task components for performance and usability
- Refactored TaskGroup and TaskRow components to improve rendering efficiency by utilizing memoization and callbacks. - Moved color mappings for group statuses and priorities outside of components to prevent unnecessary re-creations. - Enhanced drag-and-drop functionality with optimistic updates and throttling for smoother user experience. - Updated task management slice to support new properties and batch updates for better performance. - Simplified selectors and improved error handling in the task management slice.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
@@ -23,7 +23,24 @@ interface TaskGroupProps {
|
|||||||
onToggleSubtasks?: (taskId: string) => void;
|
onToggleSubtasks?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TaskGroup: React.FC<TaskGroupProps> = ({
|
// Group color mapping - moved outside component for better performance
|
||||||
|
const GROUP_COLORS = {
|
||||||
|
status: {
|
||||||
|
todo: '#faad14',
|
||||||
|
doing: '#1890ff',
|
||||||
|
done: '#52c41a',
|
||||||
|
},
|
||||||
|
priority: {
|
||||||
|
critical: '#ff4d4f',
|
||||||
|
high: '#fa8c16',
|
||||||
|
medium: '#faad14',
|
||||||
|
low: '#52c41a',
|
||||||
|
},
|
||||||
|
phase: '#722ed1',
|
||||||
|
default: '#d9d9d9',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
||||||
group,
|
group,
|
||||||
projectId,
|
projectId,
|
||||||
currentGrouping,
|
currentGrouping,
|
||||||
@@ -53,57 +70,63 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
.filter((task): task is Task => task !== undefined);
|
.filter((task): task is Task => task !== undefined);
|
||||||
}, [group.taskIds, allTasks]);
|
}, [group.taskIds, allTasks]);
|
||||||
|
|
||||||
// Calculate group statistics
|
// Calculate group statistics - memoized
|
||||||
const completedTasks = useMemo(() => {
|
const { completedTasks, totalTasks, completionRate } = useMemo(() => {
|
||||||
return groupTasks.filter(task => task.progress === 100).length;
|
const completed = groupTasks.filter(task => task.progress === 100).length;
|
||||||
|
const total = groupTasks.length;
|
||||||
|
const rate = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
completedTasks: completed,
|
||||||
|
totalTasks: total,
|
||||||
|
completionRate: rate,
|
||||||
|
};
|
||||||
}, [groupTasks]);
|
}, [groupTasks]);
|
||||||
|
|
||||||
const totalTasks = groupTasks.length;
|
|
||||||
const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
|
||||||
|
|
||||||
// Get group color based on grouping type
|
// Get group color based on grouping type - memoized
|
||||||
const getGroupColor = () => {
|
const groupColor = useMemo(() => {
|
||||||
if (group.color) return group.color;
|
if (group.color) return group.color;
|
||||||
|
|
||||||
// Fallback colors based on group value
|
// Fallback colors based on group value
|
||||||
switch (currentGrouping) {
|
switch (currentGrouping) {
|
||||||
case 'status':
|
case 'status':
|
||||||
return group.groupValue === 'todo' ? '#faad14' :
|
return GROUP_COLORS.status[group.groupValue as keyof typeof GROUP_COLORS.status] || GROUP_COLORS.default;
|
||||||
group.groupValue === 'doing' ? '#1890ff' : '#52c41a';
|
|
||||||
case 'priority':
|
case 'priority':
|
||||||
return group.groupValue === 'critical' ? '#ff4d4f' :
|
return GROUP_COLORS.priority[group.groupValue as keyof typeof GROUP_COLORS.priority] || GROUP_COLORS.default;
|
||||||
group.groupValue === 'high' ? '#fa8c16' :
|
|
||||||
group.groupValue === 'medium' ? '#faad14' : '#52c41a';
|
|
||||||
case 'phase':
|
case 'phase':
|
||||||
return '#722ed1';
|
return GROUP_COLORS.phase;
|
||||||
default:
|
default:
|
||||||
return '#d9d9d9';
|
return GROUP_COLORS.default;
|
||||||
}
|
}
|
||||||
};
|
}, [group.color, group.groupValue, currentGrouping]);
|
||||||
|
|
||||||
const handleToggleCollapse = () => {
|
// Memoized event handlers
|
||||||
|
const handleToggleCollapse = useCallback(() => {
|
||||||
setIsCollapsed(!isCollapsed);
|
setIsCollapsed(!isCollapsed);
|
||||||
onToggleCollapse?.(group.id);
|
onToggleCollapse?.(group.id);
|
||||||
};
|
}, [isCollapsed, onToggleCollapse, group.id]);
|
||||||
|
|
||||||
const handleAddTask = () => {
|
const handleAddTask = useCallback(() => {
|
||||||
onAddTask?.(group.id);
|
onAddTask?.(group.id);
|
||||||
};
|
}, [onAddTask, group.id]);
|
||||||
|
|
||||||
|
// Memoized style object
|
||||||
|
const containerStyle = useMemo(() => ({
|
||||||
|
backgroundColor: isOver ? '#f0f8ff' : undefined,
|
||||||
|
}), [isOver]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={`task-group ${isOver ? 'drag-over' : ''}`}
|
className={`task-group ${isOver ? 'drag-over' : ''}`}
|
||||||
style={{
|
style={containerStyle}
|
||||||
backgroundColor: isOver ? '#f0f8ff' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Group Header Row */}
|
{/* Group Header Row */}
|
||||||
<div className="task-group-header">
|
<div className="task-group-header">
|
||||||
<div className="task-group-header-row">
|
<div className="task-group-header-row">
|
||||||
<div
|
<div
|
||||||
className="task-group-header-content"
|
className="task-group-header-content"
|
||||||
style={{ backgroundColor: getGroupColor() }}
|
style={{ backgroundColor: groupColor }}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
type="text"
|
||||||
@@ -123,7 +146,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
{!isCollapsed && totalTasks > 0 && (
|
{!isCollapsed && totalTasks > 0 && (
|
||||||
<div
|
<div
|
||||||
className="task-group-column-headers"
|
className="task-group-column-headers"
|
||||||
style={{ borderLeft: `4px solid ${getGroupColor()}` }}
|
style={{ borderLeft: `4px solid ${groupColor}` }}
|
||||||
>
|
>
|
||||||
<div className="task-group-column-headers-row">
|
<div className="task-group-column-headers-row">
|
||||||
<div className="task-table-fixed-columns">
|
<div className="task-table-fixed-columns">
|
||||||
@@ -170,7 +193,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div
|
<div
|
||||||
className="task-group-body"
|
className="task-group-body"
|
||||||
style={{ borderLeft: `4px solid ${getGroupColor()}` }}
|
style={{ borderLeft: `4px solid ${groupColor}` }}
|
||||||
>
|
>
|
||||||
{groupTasks.length === 0 ? (
|
{groupTasks.length === 0 ? (
|
||||||
<div className="task-group-empty">
|
<div className="task-group-empty">
|
||||||
@@ -428,6 +451,17 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}, (prevProps, nextProps) => {
|
||||||
|
// Simplified comparison for better performance
|
||||||
|
return (
|
||||||
|
prevProps.group.id === nextProps.group.id &&
|
||||||
|
prevProps.group.taskIds.length === nextProps.group.taskIds.length &&
|
||||||
|
prevProps.group.collapsed === nextProps.group.collapsed &&
|
||||||
|
prevProps.selectedTaskIds.length === nextProps.selectedTaskIds.length &&
|
||||||
|
prevProps.currentGrouping === nextProps.currentGrouping
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TaskGroup.displayName = 'TaskGroup';
|
||||||
|
|
||||||
export default TaskGroup;
|
export default TaskGroup;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, useMemo } from 'react';
|
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
taskManagementSelectors,
|
taskManagementSelectors,
|
||||||
reorderTasks,
|
reorderTasks,
|
||||||
moveTaskToGroup,
|
moveTaskToGroup,
|
||||||
|
optimisticTaskMove,
|
||||||
setLoading,
|
setLoading,
|
||||||
fetchTasks
|
fetchTasks
|
||||||
} from '@/features/task-management/task-management.slice';
|
} from '@/features/task-management/task-management.slice';
|
||||||
@@ -54,6 +55,27 @@ interface DragState {
|
|||||||
activeGroupId: string | null;
|
activeGroupId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Throttle utility for performance optimization
|
||||||
|
const throttle = <T extends (...args: any[]) => void>(func: T, delay: number): T => {
|
||||||
|
let timeoutId: NodeJS.Timeout | null = null;
|
||||||
|
let lastExecTime = 0;
|
||||||
|
|
||||||
|
return ((...args: any[]) => {
|
||||||
|
const currentTime = Date.now();
|
||||||
|
|
||||||
|
if (currentTime - lastExecTime > delay) {
|
||||||
|
func(...args);
|
||||||
|
lastExecTime = currentTime;
|
||||||
|
} else {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(() => {
|
||||||
|
func(...args);
|
||||||
|
lastExecTime = Date.now();
|
||||||
|
}, delay - (currentTime - lastExecTime));
|
||||||
|
}
|
||||||
|
}) as T;
|
||||||
|
};
|
||||||
|
|
||||||
const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = '' }) => {
|
const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = '' }) => {
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
const [dragState, setDragState] = useState<DragState>({
|
const [dragState, setDragState] = useState<DragState>({
|
||||||
@@ -61,6 +83,9 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
activeGroupId: null,
|
activeGroupId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Refs for performance optimization
|
||||||
|
const dragOverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
|
||||||
// Enable real-time socket updates for task changes
|
// Enable real-time socket updates for task changes
|
||||||
useTaskSocketHandlers();
|
useTaskSocketHandlers();
|
||||||
|
|
||||||
@@ -72,11 +97,11 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const loading = useSelector((state: RootState) => state.taskManagement.loading);
|
const loading = useSelector((state: RootState) => state.taskManagement.loading);
|
||||||
const error = useSelector((state: RootState) => state.taskManagement.error);
|
const error = useSelector((state: RootState) => state.taskManagement.error);
|
||||||
|
|
||||||
// Drag and Drop sensors
|
// Drag and Drop sensors - optimized for better performance
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
distance: 8,
|
distance: 3, // Reduced from 8 for more responsive dragging
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
@@ -92,23 +117,17 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
}
|
}
|
||||||
}, [dispatch, projectId, currentGrouping]);
|
}, [dispatch, projectId, currentGrouping]);
|
||||||
|
|
||||||
// Memoized calculations
|
// Memoized calculations - optimized
|
||||||
const allTaskIds = useMemo(() => {
|
const allTaskIds = useMemo(() => tasks.map(task => task.id), [tasks]);
|
||||||
return tasks.map(task => task.id);
|
const totalTasksCount = useMemo(() => tasks.length, [tasks]);
|
||||||
}, [tasks]);
|
|
||||||
|
|
||||||
const totalTasksCount = useMemo(() => {
|
|
||||||
return tasks.length;
|
|
||||||
}, [tasks]);
|
|
||||||
|
|
||||||
const hasSelection = selectedTaskIds.length > 0;
|
const hasSelection = selectedTaskIds.length > 0;
|
||||||
|
|
||||||
// Handlers
|
// Memoized handlers for better performance
|
||||||
const handleGroupingChange = (newGroupBy: typeof currentGrouping) => {
|
const handleGroupingChange = useCallback((newGroupBy: typeof currentGrouping) => {
|
||||||
dispatch(setCurrentGrouping(newGroupBy));
|
dispatch(setCurrentGrouping(newGroupBy));
|
||||||
};
|
}, [dispatch]);
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
const { active } = event;
|
const { active } = event;
|
||||||
const taskId = active.id as string;
|
const taskId = active.id as string;
|
||||||
|
|
||||||
@@ -131,28 +150,76 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
activeTask,
|
activeTask,
|
||||||
activeGroupId,
|
activeGroupId,
|
||||||
});
|
});
|
||||||
};
|
}, [tasks, currentGrouping]);
|
||||||
|
|
||||||
const handleDragOver = (event: DragOverEvent) => {
|
// Throttled drag over handler for better performance
|
||||||
// Handle drag over logic if needed for visual feedback
|
const handleDragOver = useCallback(throttle((event: DragOverEvent) => {
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!over || !dragState.activeTask) return;
|
||||||
|
|
||||||
|
const activeTaskId = active.id as string;
|
||||||
|
const overContainer = over.id as string;
|
||||||
|
|
||||||
|
// Clear any existing timeout
|
||||||
|
if (dragOverTimeoutRef.current) {
|
||||||
|
clearTimeout(dragOverTimeoutRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Optimistic update with throttling
|
||||||
|
dragOverTimeoutRef.current = setTimeout(() => {
|
||||||
|
// Only update if we're hovering over a different container
|
||||||
|
const targetTask = tasks.find(t => t.id === overContainer);
|
||||||
|
let targetGroupId = overContainer;
|
||||||
|
|
||||||
|
if (targetTask) {
|
||||||
|
if (currentGrouping === 'status') {
|
||||||
|
targetGroupId = `status-${targetTask.status}`;
|
||||||
|
} else if (currentGrouping === 'priority') {
|
||||||
|
targetGroupId = `priority-${targetTask.priority}`;
|
||||||
|
} else if (currentGrouping === 'phase') {
|
||||||
|
targetGroupId = `phase-${targetTask.phase}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetGroupId !== dragState.activeGroupId) {
|
||||||
|
// Perform optimistic update for visual feedback
|
||||||
|
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
||||||
|
if (targetGroup) {
|
||||||
|
dispatch(optimisticTaskMove({
|
||||||
|
taskId: activeTaskId,
|
||||||
|
newGroupId: targetGroupId,
|
||||||
|
newIndex: targetGroup.taskIds.length,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 50); // 50ms throttle for drag over events
|
||||||
|
}, 50), [dragState, tasks, taskGroups, currentGrouping, dispatch]);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
// Clear any pending drag over timeouts
|
||||||
|
if (dragOverTimeoutRef.current) {
|
||||||
|
clearTimeout(dragOverTimeoutRef.current);
|
||||||
|
dragOverTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset drag state immediately for better UX
|
||||||
|
const currentDragState = dragState;
|
||||||
setDragState({
|
setDragState({
|
||||||
activeTask: null,
|
activeTask: null,
|
||||||
activeGroupId: null,
|
activeGroupId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!over || !dragState.activeTask || !dragState.activeGroupId) {
|
if (!over || !currentDragState.activeTask || !currentDragState.activeGroupId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeTaskId = active.id as string;
|
const activeTaskId = active.id as string;
|
||||||
const overContainer = over.id as string;
|
const overContainer = over.id as string;
|
||||||
|
|
||||||
// Parse the group ID to get group type and value
|
// Parse the group ID to get group type and value - optimized
|
||||||
const parseGroupId = (groupId: string) => {
|
const parseGroupId = (groupId: string) => {
|
||||||
const [groupType, ...groupValueParts] = groupId.split('-');
|
const [groupType, ...groupValueParts] = groupId.split('-');
|
||||||
return {
|
return {
|
||||||
@@ -184,11 +251,11 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceGroupInfo = parseGroupId(dragState.activeGroupId);
|
const sourceGroupInfo = parseGroupId(currentDragState.activeGroupId);
|
||||||
const targetGroupInfo = parseGroupId(targetGroupId);
|
const targetGroupInfo = parseGroupId(targetGroupId);
|
||||||
|
|
||||||
// If moving between different groups, update the task's group property
|
// If moving between different groups, update the task's group property
|
||||||
if (dragState.activeGroupId !== targetGroupId) {
|
if (currentDragState.activeGroupId !== targetGroupId) {
|
||||||
dispatch(moveTaskToGroup({
|
dispatch(moveTaskToGroup({
|
||||||
taskId: activeTaskId,
|
taskId: activeTaskId,
|
||||||
groupType: targetGroupInfo.groupType,
|
groupType: targetGroupInfo.groupType,
|
||||||
@@ -197,37 +264,65 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle reordering within the same group or between groups
|
// Handle reordering within the same group or between groups
|
||||||
const sourceGroup = taskGroups.find(g => g.id === dragState.activeGroupId);
|
const sourceGroup = taskGroups.find(g => g.id === currentDragState.activeGroupId);
|
||||||
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
||||||
|
|
||||||
if (sourceGroup && targetGroup) {
|
if (sourceGroup && targetGroup && targetIndex !== -1) {
|
||||||
const sourceIndex = sourceGroup.taskIds.indexOf(activeTaskId);
|
const sourceIndex = sourceGroup.taskIds.indexOf(activeTaskId);
|
||||||
const finalTargetIndex = targetIndex === -1 ? targetGroup.taskIds.length : targetIndex;
|
const finalTargetIndex = targetIndex === -1 ? targetGroup.taskIds.length : targetIndex;
|
||||||
|
|
||||||
// Calculate new order values
|
// Only reorder if actually moving to a different position
|
||||||
const allTasksInTargetGroup = targetGroup.taskIds.map(id => tasks.find(t => t.id === id)!);
|
if (sourceGroup.id !== targetGroup.id || sourceIndex !== finalTargetIndex) {
|
||||||
const newOrder = allTasksInTargetGroup.map((task, index) => {
|
// Calculate new order values - simplified
|
||||||
if (index < finalTargetIndex) return task.order;
|
const allTasksInTargetGroup = targetGroup.taskIds.map(id => tasks.find(t => t.id === id)!);
|
||||||
if (index === finalTargetIndex) return dragState.activeTask!.order;
|
const newOrder = allTasksInTargetGroup.map((task, index) => {
|
||||||
return task.order + 1;
|
if (index < finalTargetIndex) return task.order;
|
||||||
});
|
if (index === finalTargetIndex) return currentDragState.activeTask!.order;
|
||||||
|
return task.order + 1;
|
||||||
|
});
|
||||||
|
|
||||||
// Dispatch reorder action
|
// Dispatch reorder action
|
||||||
dispatch(reorderTasks({
|
dispatch(reorderTasks({
|
||||||
taskIds: [activeTaskId, ...allTasksInTargetGroup.map(t => t.id)],
|
taskIds: [activeTaskId, ...allTasksInTargetGroup.map(t => t.id)],
|
||||||
newOrder: [dragState.activeTask!.order, ...newOrder]
|
newOrder: [currentDragState.activeTask!.order, ...newOrder]
|
||||||
}));
|
}));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
}, [dragState, tasks, taskGroups, currentGrouping, dispatch]);
|
||||||
|
|
||||||
const handleSelectTask = (taskId: string, selected: boolean) => {
|
const handleSelectTask = useCallback((taskId: string, selected: boolean) => {
|
||||||
dispatch(toggleTaskSelection(taskId));
|
dispatch(toggleTaskSelection(taskId));
|
||||||
};
|
}, [dispatch]);
|
||||||
|
|
||||||
const handleToggleSubtasks = (taskId: string) => {
|
const handleToggleSubtasks = useCallback((taskId: string) => {
|
||||||
// Implementation for toggling subtasks
|
// Implementation for toggling subtasks
|
||||||
console.log('Toggle subtasks for task:', taskId);
|
console.log('Toggle subtasks for task:', taskId);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
// Memoized DragOverlay content for better performance
|
||||||
|
const dragOverlayContent = useMemo(() => {
|
||||||
|
if (!dragState.activeTask || !dragState.activeGroupId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TaskRow
|
||||||
|
task={dragState.activeTask}
|
||||||
|
projectId={projectId}
|
||||||
|
groupId={dragState.activeGroupId}
|
||||||
|
currentGrouping={currentGrouping}
|
||||||
|
isSelected={false}
|
||||||
|
isDragOverlay
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [dragState.activeTask, dragState.activeGroupId, projectId, currentGrouping]);
|
||||||
|
|
||||||
|
// Cleanup effect
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (dragOverTimeoutRef.current) {
|
||||||
|
clearTimeout(dragOverTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
@@ -242,51 +337,51 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`task-list-board ${className}`}>
|
<div className={`task-list-board ${className}`}>
|
||||||
{/* Task Filters */}
|
<DndContext
|
||||||
<Card
|
sensors={sensors}
|
||||||
size="small"
|
collisionDetection={closestCorners}
|
||||||
className="mb-4"
|
onDragStart={handleDragStart}
|
||||||
styles={{ body: { padding: '12px 16px' } }}
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<React.Suspense fallback={<div>Loading filters...</div>}>
|
{/* Task Filters */}
|
||||||
<TaskListFilters position="list" />
|
<Card
|
||||||
</React.Suspense>
|
size="small"
|
||||||
</Card>
|
className="mb-4"
|
||||||
|
styles={{ body: { padding: '12px 16px' } }}
|
||||||
|
>
|
||||||
|
<React.Suspense fallback={<div>Loading filters...</div>}>
|
||||||
|
<TaskListFilters position="list" />
|
||||||
|
</React.Suspense>
|
||||||
|
</Card>
|
||||||
|
|
||||||
{/* Bulk Action Bar */}
|
{/* Bulk Action Bar */}
|
||||||
{hasSelection && (
|
{hasSelection && (
|
||||||
<BulkActionBar
|
<BulkActionBar
|
||||||
selectedTaskIds={selectedTaskIds}
|
selectedTaskIds={selectedTaskIds}
|
||||||
totalSelected={selectedTaskIds.length}
|
totalSelected={selectedTaskIds.length}
|
||||||
currentGrouping={currentGrouping as any}
|
currentGrouping={currentGrouping as any}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onClearSelection={() => dispatch(clearSelection())}
|
onClearSelection={() => dispatch(clearSelection())}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Task Groups Container */}
|
{/* Task Groups Container */}
|
||||||
<div className="task-groups-container">
|
<div className="task-groups-container">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex justify-center items-center py-8">
|
<div className="flex justify-center items-center py-8">
|
||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
) : taskGroups.length === 0 ? (
|
) : taskGroups.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<Empty
|
<Empty
|
||||||
description="No tasks found"
|
description="No tasks found"
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCorners}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<div className="task-groups">
|
<div className="task-groups">
|
||||||
{taskGroups.map((group) => (
|
{taskGroups.map((group) => (
|
||||||
<TaskGroup
|
<TaskGroup
|
||||||
@@ -300,22 +395,19 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<DragOverlay>
|
<DragOverlay
|
||||||
{dragState.activeTask ? (
|
adjustScale={false}
|
||||||
<TaskRow
|
dropAnimation={null}
|
||||||
task={dragState.activeTask}
|
style={{
|
||||||
projectId={projectId}
|
cursor: 'grabbing',
|
||||||
groupId={dragState.activeGroupId!}
|
}}
|
||||||
currentGrouping={currentGrouping}
|
>
|
||||||
isSelected={false}
|
{dragOverlayContent}
|
||||||
isDragOverlay
|
</DragOverlay>
|
||||||
/>
|
</DndContext>
|
||||||
) : null}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.task-groups-container {
|
.task-groups-container {
|
||||||
@@ -324,13 +416,52 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
overflow-x: visible;
|
overflow-x: visible;
|
||||||
padding: 8px 8px 8px 0;
|
padding: 8px 8px 8px 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: background-color 0.3s ease;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
|
/* GPU acceleration for smooth scrolling */
|
||||||
|
transform: translateZ(0);
|
||||||
|
will-change: scroll-position;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-groups {
|
.task-groups {
|
||||||
min-width: fit-content;
|
min-width: fit-content;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
/* GPU acceleration for drag operations */
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optimized drag overlay styles */
|
||||||
|
[data-dnd-overlay] {
|
||||||
|
/* GPU acceleration for smooth dragging */
|
||||||
|
transform: translateZ(0);
|
||||||
|
will-change: transform;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix drag overlay positioning */
|
||||||
|
[data-rbd-drag-handle-dragging-id] {
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DndKit drag overlay specific styles */
|
||||||
|
.dndkit-drag-overlay {
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateZ(0);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure drag overlay follows cursor properly */
|
||||||
|
[data-dnd-context] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix for scrollable containers affecting drag overlay */
|
||||||
|
.task-groups-container [data-dnd-overlay] {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
transform: translateZ(0);
|
||||||
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode support */
|
/* Dark mode support */
|
||||||
@@ -370,6 +501,20 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
--task-drag-over-bg: #1a2332;
|
--task-drag-over-bg: #1a2332;
|
||||||
--task-drag-over-border: #40a9ff;
|
--task-drag-over-border: #40a9ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Performance optimizations */
|
||||||
|
.task-group {
|
||||||
|
contain: layout style paint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row {
|
||||||
|
contain: layout style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce layout thrashing */
|
||||||
|
.task-table-cell {
|
||||||
|
contain: layout;
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,20 @@ interface TaskRowProps {
|
|||||||
onToggleSubtasks?: (taskId: string) => void;
|
onToggleSubtasks?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Priority and status colors - moved outside component to avoid recreation
|
||||||
|
const PRIORITY_COLORS = {
|
||||||
|
critical: '#ff4d4f',
|
||||||
|
high: '#ff7a45',
|
||||||
|
medium: '#faad14',
|
||||||
|
low: '#52c41a',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
todo: '#f0f0f0',
|
||||||
|
doing: '#1890ff',
|
||||||
|
done: '#52c41a',
|
||||||
|
} as const;
|
||||||
|
|
||||||
const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||||
task,
|
task,
|
||||||
projectId,
|
projectId,
|
||||||
@@ -53,12 +67,9 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Get theme from Redux store
|
// Get theme from Redux store
|
||||||
const themeMode = useSelector((state: RootState) => state.themeReducer?.mode || 'light');
|
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
||||||
|
|
||||||
// Memoize derived values for performance
|
|
||||||
const isDarkMode = useMemo(() => themeMode === 'dark', [themeMode]);
|
|
||||||
|
|
||||||
// Memoize style calculations
|
// Memoize style calculations - simplified
|
||||||
const style = useMemo(() => ({
|
const style = useMemo(() => ({
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
@@ -74,7 +85,67 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
onToggleSubtasks?.(task.id);
|
onToggleSubtasks?.(task.id);
|
||||||
}, [onToggleSubtasks, task.id]);
|
}, [onToggleSubtasks, task.id]);
|
||||||
|
|
||||||
// Format due date - memoized for performance
|
// Memoize assignees for AvatarGroup to prevent unnecessary re-renders
|
||||||
|
const avatarGroupMembers = useMemo(() => {
|
||||||
|
return task.assignee_names || [];
|
||||||
|
}, [task.assignee_names]);
|
||||||
|
|
||||||
|
// Simplified class name calculations
|
||||||
|
const containerClasses = useMemo(() => {
|
||||||
|
const baseClasses = 'border-b transition-all duration-300';
|
||||||
|
const themeClasses = isDarkMode
|
||||||
|
? 'border-gray-700 bg-gray-900 hover:bg-gray-800'
|
||||||
|
: 'border-gray-200 bg-white hover:bg-gray-50';
|
||||||
|
const selectedClasses = isSelected
|
||||||
|
? (isDarkMode ? 'bg-blue-900/20 border-l-4 border-l-blue-500' : 'bg-blue-50 border-l-4 border-l-blue-500')
|
||||||
|
: '';
|
||||||
|
const overlayClasses = isDragOverlay
|
||||||
|
? `rounded shadow-lg border-2 ${isDarkMode ? 'bg-gray-900 border-gray-600 shadow-2xl' : 'bg-white border-gray-300 shadow-2xl'}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `${baseClasses} ${themeClasses} ${selectedClasses} ${overlayClasses}`;
|
||||||
|
}, [isDarkMode, isSelected, isDragOverlay]);
|
||||||
|
|
||||||
|
const fixedColumnsClasses = useMemo(() =>
|
||||||
|
`flex sticky left-0 z-10 border-r-2 shadow-sm ${isDarkMode ? 'bg-gray-900 border-gray-700' : 'bg-white border-gray-200'}`,
|
||||||
|
[isDarkMode]
|
||||||
|
);
|
||||||
|
|
||||||
|
const taskNameClasses = useMemo(() => {
|
||||||
|
const baseClasses = 'text-sm font-medium flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300';
|
||||||
|
const themeClasses = isDarkMode ? 'text-gray-100' : 'text-gray-900';
|
||||||
|
const completedClasses = task.progress === 100
|
||||||
|
? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `${baseClasses} ${themeClasses} ${completedClasses}`;
|
||||||
|
}, [isDarkMode, task.progress]);
|
||||||
|
|
||||||
|
// Get colors - using constants for better performance
|
||||||
|
const getPriorityColor = useCallback((priority: string) =>
|
||||||
|
PRIORITY_COLORS[priority as keyof typeof PRIORITY_COLORS] || '#d9d9d9', []);
|
||||||
|
|
||||||
|
const getStatusColor = useCallback((status: string) =>
|
||||||
|
STATUS_COLORS[status as keyof typeof STATUS_COLORS] || '#d9d9d9', []);
|
||||||
|
|
||||||
|
// Create adapter for LabelsSelector - memoized
|
||||||
|
const taskAdapter = useMemo(() => ({
|
||||||
|
id: task.id,
|
||||||
|
name: task.title,
|
||||||
|
parent_task_id: null,
|
||||||
|
all_labels: task.labels?.map(label => ({
|
||||||
|
id: label.id,
|
||||||
|
name: label.name,
|
||||||
|
color_code: label.color
|
||||||
|
})) || [],
|
||||||
|
labels: task.labels?.map(label => ({
|
||||||
|
id: label.id,
|
||||||
|
name: label.name,
|
||||||
|
color_code: label.color
|
||||||
|
})) || [],
|
||||||
|
} as any), [task.id, task.title, task.labels]);
|
||||||
|
|
||||||
|
// Memoize due date calculation
|
||||||
const dueDate = useMemo(() => {
|
const dueDate = useMemo(() => {
|
||||||
if (!task.dueDate) return null;
|
if (!task.dueDate) return null;
|
||||||
const date = new Date(task.dueDate);
|
const date = new Date(task.dueDate);
|
||||||
@@ -93,278 +164,187 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
}
|
}
|
||||||
}, [task.dueDate]);
|
}, [task.dueDate]);
|
||||||
|
|
||||||
// Memoize assignees for AvatarGroup to prevent unnecessary re-renders
|
|
||||||
const avatarGroupMembers = useMemo(() => {
|
|
||||||
return task.assignees?.map(assigneeId => ({
|
|
||||||
id: assigneeId,
|
|
||||||
team_member_id: assigneeId,
|
|
||||||
name: assigneeId // TODO: Map to actual user names
|
|
||||||
})) || [];
|
|
||||||
}, [task.assignees]);
|
|
||||||
|
|
||||||
// Memoize class names for better performance
|
|
||||||
const containerClassName = useMemo(() => `
|
|
||||||
border-b transition-all duration-300
|
|
||||||
${isDarkMode
|
|
||||||
? `border-gray-700 bg-gray-900 hover:bg-gray-800 ${isSelected ? 'bg-blue-900/20' : ''}`
|
|
||||||
: `border-gray-200 bg-white hover:bg-gray-50 ${isSelected ? 'bg-blue-50' : ''}`
|
|
||||||
}
|
|
||||||
${isSelected ? 'border-l-4 border-l-blue-500' : ''}
|
|
||||||
${isDragOverlay
|
|
||||||
? `rounded shadow-lg ${isDarkMode ? 'bg-gray-900 border border-gray-600' : 'bg-white border border-gray-300'}`
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
`, [isDarkMode, isSelected, isDragOverlay]);
|
|
||||||
|
|
||||||
const fixedColumnsClassName = useMemo(() => `
|
|
||||||
flex sticky left-0 z-10 border-r-2 shadow-sm ${isDarkMode ? 'bg-gray-900 border-gray-700' : 'bg-white border-gray-200'}
|
|
||||||
`, [isDarkMode]);
|
|
||||||
|
|
||||||
const taskNameClassName = useMemo(() => `
|
|
||||||
text-sm font-medium flex-1
|
|
||||||
overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300
|
|
||||||
${isDarkMode ? 'text-gray-100' : 'text-gray-900'}
|
|
||||||
${task.progress === 100
|
|
||||||
? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`
|
|
||||||
: ''
|
|
||||||
}
|
|
||||||
`, [isDarkMode, task.progress]);
|
|
||||||
|
|
||||||
// Get priority color
|
|
||||||
const getPriorityColor = (priority: string) => {
|
|
||||||
const colors = {
|
|
||||||
critical: '#ff4d4f',
|
|
||||||
high: '#ff7a45',
|
|
||||||
medium: '#faad14',
|
|
||||||
low: '#52c41a',
|
|
||||||
};
|
|
||||||
return colors[priority as keyof typeof colors] || '#d9d9d9';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Get status color
|
|
||||||
const getStatusColor = (status: string) => {
|
|
||||||
const colors = {
|
|
||||||
todo: '#f0f0f0',
|
|
||||||
doing: '#1890ff',
|
|
||||||
done: '#52c41a',
|
|
||||||
};
|
|
||||||
return colors[status as keyof typeof colors] || '#d9d9d9';
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create adapter for LabelsSelector to work with new Task type
|
|
||||||
const taskAdapter = useMemo(() => {
|
|
||||||
// Convert new Task type to IProjectTask for compatibility
|
|
||||||
return {
|
|
||||||
id: task.id,
|
|
||||||
name: task.title,
|
|
||||||
parent_task_id: null, // TODO: Add parent task support
|
|
||||||
all_labels: task.labels?.map(label => ({
|
|
||||||
id: label.id,
|
|
||||||
name: label.name,
|
|
||||||
color_code: label.color
|
|
||||||
})) || [],
|
|
||||||
labels: task.labels?.map(label => ({
|
|
||||||
id: label.id,
|
|
||||||
name: label.name,
|
|
||||||
color_code: label.color
|
|
||||||
})) || [],
|
|
||||||
} as any; // Type assertion for compatibility
|
|
||||||
}, [task.id, task.title, task.labels]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
<div
|
ref={setNodeRef}
|
||||||
ref={setNodeRef}
|
style={style}
|
||||||
style={style}
|
className={containerClasses}
|
||||||
className={containerClassName}
|
>
|
||||||
>
|
<div className="flex h-10 max-h-10 overflow-visible relative min-w-[1200px]">
|
||||||
<div className="flex h-10 max-h-10 overflow-visible relative min-w-[1200px]">
|
{/* Fixed Columns */}
|
||||||
{/* Fixed Columns */}
|
<div className={fixedColumnsClasses}>
|
||||||
<div className={fixedColumnsClassName}>
|
{/* Drag Handle */}
|
||||||
{/* Drag Handle */}
|
<div className={`w-10 flex items-center justify-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
<div className={`w-10 flex items-center justify-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
<Button
|
||||||
<Button
|
variant="text"
|
||||||
variant="text"
|
size="small"
|
||||||
size="small"
|
icon={<HolderOutlined />}
|
||||||
icon={<HolderOutlined />}
|
className="opacity-40 hover:opacity-100 cursor-grab active:cursor-grabbing"
|
||||||
className="opacity-40 hover:opacity-100 cursor-grab active:cursor-grabbing"
|
isDarkMode={isDarkMode}
|
||||||
isDarkMode={isDarkMode}
|
{...attributes}
|
||||||
{...attributes}
|
{...listeners}
|
||||||
{...listeners}
|
/>
|
||||||
>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Selection Checkbox */}
|
|
||||||
<div className={`w-10 flex items-center justify-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
|
||||||
<Checkbox
|
|
||||||
checked={isSelected}
|
|
||||||
onChange={handleSelectChange}
|
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Task Key */}
|
|
||||||
<div className={`w-20 flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
|
||||||
<Tag
|
|
||||||
backgroundColor={isDarkMode ? "#374151" : "#f0f0f0"}
|
|
||||||
color={isDarkMode ? "#d1d5db" : "#666"}
|
|
||||||
className="truncate whitespace-nowrap max-w-full"
|
|
||||||
>
|
|
||||||
{task.task_key}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Task Name */}
|
|
||||||
<div className="w-[475px] flex items-center px-2">
|
|
||||||
<div className="flex-1 min-w-0 flex flex-col justify-center h-full overflow-hidden">
|
|
||||||
<div className="flex items-center gap-2 h-5 overflow-hidden">
|
|
||||||
<span className={taskNameClassName}>
|
|
||||||
{task.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Columns */}
|
{/* Selection Checkbox */}
|
||||||
<div className="flex flex-1 min-w-0">
|
<div className={`w-10 flex items-center justify-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
{/* Progress */}
|
<Checkbox
|
||||||
<div className={`w-[90px] flex items-center justify-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
checked={isSelected}
|
||||||
{task.progress !== undefined && task.progress >= 0 && (
|
onChange={handleSelectChange}
|
||||||
<Progress
|
isDarkMode={isDarkMode}
|
||||||
type="circle"
|
/>
|
||||||
percent={task.progress}
|
</div>
|
||||||
size={24}
|
|
||||||
strokeColor={task.progress === 100 ? '#52c41a' : '#1890ff'}
|
|
||||||
strokeWidth={2}
|
|
||||||
showInfo={true}
|
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Members */}
|
{/* Task Key */}
|
||||||
<div className={`w-[150px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
<div className={`w-20 flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
<div className="flex items-center gap-2">
|
<Tag
|
||||||
{avatarGroupMembers.length > 0 && (
|
backgroundColor={isDarkMode ? "#374151" : "#f0f0f0"}
|
||||||
<AvatarGroup
|
color={isDarkMode ? "#d1d5db" : "#666"}
|
||||||
members={avatarGroupMembers}
|
className="truncate whitespace-nowrap max-w-full"
|
||||||
size={24}
|
>
|
||||||
maxCount={3}
|
{task.task_key}
|
||||||
isDarkMode={isDarkMode}
|
</Tag>
|
||||||
/>
|
</div>
|
||||||
)}
|
|
||||||
<button
|
|
||||||
className={`
|
|
||||||
w-6 h-6 rounded-full border border-dashed flex items-center justify-center
|
|
||||||
transition-colors duration-200
|
|
||||||
${isDarkMode
|
|
||||||
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
|
|
||||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
onClick={() => {
|
|
||||||
// TODO: Implement assignee selector functionality
|
|
||||||
console.log('Add assignee clicked for task:', task.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-xs">+</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Labels */}
|
{/* Task Name */}
|
||||||
<div className={`w-[200px] max-w-[200px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
<div className="w-[475px] flex items-center px-2">
|
||||||
<div className="flex items-center gap-1 flex-wrap h-full w-full overflow-visible relative">
|
<div className="flex-1 min-w-0 flex flex-col justify-center h-full overflow-hidden">
|
||||||
{task.labels?.map((label, index) => (
|
<div className="flex items-center gap-2 h-5 overflow-hidden">
|
||||||
label.end && label.names && label.name ? (
|
<span className={taskNameClasses}>
|
||||||
<CustomNumberLabel
|
{task.title}
|
||||||
key={`${label.id}-${index}`}
|
|
||||||
labelList={label.names}
|
|
||||||
namesString={label.name}
|
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<CustomColordLabel
|
|
||||||
key={`${label.id}-${index}`}
|
|
||||||
label={label}
|
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
))}
|
|
||||||
<LabelsSelector
|
|
||||||
task={taskAdapter}
|
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status */}
|
|
||||||
<div className={`w-[100px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
|
||||||
<Tag
|
|
||||||
backgroundColor={getStatusColor(task.status)}
|
|
||||||
color="white"
|
|
||||||
className="text-xs font-medium uppercase"
|
|
||||||
>
|
|
||||||
{task.status}
|
|
||||||
</Tag>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Priority */}
|
|
||||||
<div className={`w-[100px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div
|
|
||||||
className="w-2 h-2 rounded-full"
|
|
||||||
style={{ backgroundColor: getPriorityColor(task.priority) }}
|
|
||||||
/>
|
|
||||||
<span className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'}`}>
|
|
||||||
{task.priority}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* Time Tracking */}
|
</div>
|
||||||
<div className={`w-[120px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
|
||||||
<div className="flex items-center gap-2 h-full overflow-hidden">
|
{/* Scrollable Columns */}
|
||||||
{task.timeTracking?.logged && task.timeTracking.logged > 0 && (
|
<div className="flex flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-1">
|
{/* Progress */}
|
||||||
<ClockCircleOutlined className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`} />
|
<div className={`w-[90px] flex items-center justify-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
<span className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'}`}>
|
{task.progress !== undefined && task.progress >= 0 && (
|
||||||
{typeof task.timeTracking.logged === 'number'
|
<Progress
|
||||||
? `${task.timeTracking.logged}h`
|
type="circle"
|
||||||
: task.timeTracking.logged
|
percent={task.progress}
|
||||||
}
|
size={24}
|
||||||
</span>
|
strokeColor={task.progress === 100 ? '#52c41a' : '#1890ff'}
|
||||||
</div>
|
strokeWidth={2}
|
||||||
)}
|
showInfo={true}
|
||||||
</div>
|
isDarkMode={isDarkMode}
|
||||||
</div>
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Members */}
|
||||||
|
<div className={`w-[150px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{avatarGroupMembers.length > 0 && (
|
||||||
|
<AvatarGroup
|
||||||
|
members={avatarGroupMembers}
|
||||||
|
size={24}
|
||||||
|
maxCount={3}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
className={`
|
||||||
|
w-6 h-6 rounded-full border border-dashed flex items-center justify-center
|
||||||
|
transition-colors duration-200
|
||||||
|
${isDarkMode
|
||||||
|
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
|
||||||
|
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
// TODO: Implement assignee selector functionality
|
||||||
|
console.log('Add assignee clicked for task:', task.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-xs">+</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
<div className={`w-[200px] max-w-[200px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
|
<div className="flex items-center gap-1 flex-wrap h-full w-full overflow-visible relative">
|
||||||
|
{task.labels?.map((label, index) => (
|
||||||
|
label.end && label.names && label.name ? (
|
||||||
|
<CustomNumberLabel
|
||||||
|
key={`${label.id}-${index}`}
|
||||||
|
labelList={label.names}
|
||||||
|
namesString={label.name}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<CustomColordLabel
|
||||||
|
key={`${label.id}-${index}`}
|
||||||
|
label={label}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
<LabelsSelector
|
||||||
|
task={taskAdapter}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status */}
|
||||||
|
<div className={`w-[100px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
|
<Tag
|
||||||
|
backgroundColor={getStatusColor(task.status)}
|
||||||
|
color="white"
|
||||||
|
className="text-xs font-medium uppercase"
|
||||||
|
>
|
||||||
|
{task.status}
|
||||||
|
</Tag>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Priority */}
|
||||||
|
<div className={`w-[100px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: getPriorityColor(task.priority) }}
|
||||||
|
/>
|
||||||
|
<span className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||||
|
{task.priority}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Tracking */}
|
||||||
|
<div className={`w-[120px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
|
<div className="flex items-center gap-2 h-full overflow-hidden">
|
||||||
|
{task.timeTracking?.logged && task.timeTracking.logged > 0 && (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ClockCircleOutlined className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`} />
|
||||||
|
<span className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||||
|
{typeof task.timeTracking.logged === 'number'
|
||||||
|
? `${task.timeTracking.logged}h`
|
||||||
|
: task.timeTracking.logged
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}, (prevProps, nextProps) => {
|
}, (prevProps, nextProps) => {
|
||||||
// Custom comparison function for React.memo
|
// Simplified comparison for better performance
|
||||||
// Only re-render if these specific props change
|
|
||||||
const labelsEqual = prevProps.task.labels.length === nextProps.task.labels.length &&
|
|
||||||
prevProps.task.labels.every((label, index) =>
|
|
||||||
label.id === nextProps.task.labels[index]?.id &&
|
|
||||||
label.name === nextProps.task.labels[index]?.name &&
|
|
||||||
label.color === nextProps.task.labels[index]?.color &&
|
|
||||||
label.end === nextProps.task.labels[index]?.end &&
|
|
||||||
JSON.stringify(label.names) === JSON.stringify(nextProps.task.labels[index]?.names)
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
prevProps.task.id === nextProps.task.id &&
|
prevProps.task.id === nextProps.task.id &&
|
||||||
prevProps.task.assignees === nextProps.task.assignees &&
|
|
||||||
prevProps.task.title === nextProps.task.title &&
|
prevProps.task.title === nextProps.task.title &&
|
||||||
prevProps.task.progress === nextProps.task.progress &&
|
prevProps.task.progress === nextProps.task.progress &&
|
||||||
prevProps.task.status === nextProps.task.status &&
|
prevProps.task.status === nextProps.task.status &&
|
||||||
prevProps.task.priority === nextProps.task.priority &&
|
prevProps.task.priority === nextProps.task.priority &&
|
||||||
labelsEqual &&
|
prevProps.task.labels?.length === nextProps.task.labels?.length &&
|
||||||
|
prevProps.task.assignee_names?.length === nextProps.task.assignee_names?.length &&
|
||||||
prevProps.isSelected === nextProps.isSelected &&
|
prevProps.isSelected === nextProps.isSelected &&
|
||||||
prevProps.isDragOverlay === nextProps.isDragOverlay &&
|
prevProps.isDragOverlay === nextProps.isDragOverlay &&
|
||||||
prevProps.groupId === nextProps.groupId
|
prevProps.groupId === nextProps.groupId
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export const fetchTasks = createAsyncThunk(
|
|||||||
phase: task.phase_name || 'Development',
|
phase: task.phase_name || 'Development',
|
||||||
progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0,
|
progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0,
|
||||||
assignees: task.assignees?.map((a: any) => a.team_member_id) || [],
|
assignees: task.assignees?.map((a: any) => a.team_member_id) || [],
|
||||||
|
assignee_names: task.assignee_names || task.names || [],
|
||||||
labels: task.labels?.map((l: any) => ({
|
labels: task.labels?.map((l: any) => ({
|
||||||
id: l.id || l.label_id,
|
id: l.id || l.label_id,
|
||||||
name: l.name,
|
name: l.name,
|
||||||
@@ -147,13 +148,19 @@ const taskManagementSlice = createSlice({
|
|||||||
tasksAdapter.removeMany(state, action.payload);
|
tasksAdapter.removeMany(state, action.payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Drag and drop operations
|
// Optimized drag and drop operations
|
||||||
reorderTasks: (state, action: PayloadAction<{ taskIds: string[]; newOrder: number[] }>) => {
|
reorderTasks: (state, action: PayloadAction<{ taskIds: string[]; newOrder: number[] }>) => {
|
||||||
const { taskIds, newOrder } = action.payload;
|
const { taskIds, newOrder } = action.payload;
|
||||||
|
|
||||||
|
// Batch update for better performance
|
||||||
const updates = taskIds.map((id, index) => ({
|
const updates = taskIds.map((id, index) => ({
|
||||||
id,
|
id,
|
||||||
changes: { order: newOrder[index] },
|
changes: {
|
||||||
|
order: newOrder[index],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
tasksAdapter.updateMany(state, updates);
|
tasksAdapter.updateMany(state, updates);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -175,6 +182,34 @@ const taskManagementSlice = createSlice({
|
|||||||
tasksAdapter.updateOne(state, { id: taskId, changes });
|
tasksAdapter.updateOne(state, { id: taskId, changes });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Optimistic update for drag operations - reduces perceived lag
|
||||||
|
optimisticTaskMove: (state, action: PayloadAction<{ taskId: string; newGroupId: string; newIndex: number }>) => {
|
||||||
|
const { taskId, newGroupId, newIndex } = action.payload;
|
||||||
|
const task = state.entities[taskId];
|
||||||
|
|
||||||
|
if (task) {
|
||||||
|
// Parse group ID to determine new values
|
||||||
|
const [groupType, ...groupValueParts] = newGroupId.split('-');
|
||||||
|
const groupValue = groupValueParts.join('-');
|
||||||
|
|
||||||
|
const changes: Partial<Task> = {
|
||||||
|
order: newIndex,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update group-specific field
|
||||||
|
if (groupType === 'status') {
|
||||||
|
changes.status = groupValue as Task['status'];
|
||||||
|
} else if (groupType === 'priority') {
|
||||||
|
changes.priority = groupValue as Task['priority'];
|
||||||
|
} else if (groupType === 'phase') {
|
||||||
|
changes.phase = groupValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tasksAdapter.updateOne(state, { id: taskId, changes });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Loading states
|
// Loading states
|
||||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
state.loading = action.payload;
|
state.loading = action.payload;
|
||||||
@@ -198,7 +233,7 @@ const taskManagementSlice = createSlice({
|
|||||||
})
|
})
|
||||||
.addCase(fetchTasks.rejected, (state, action) => {
|
.addCase(fetchTasks.rejected, (state, action) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.error = action.payload as string;
|
state.error = action.payload as string || 'Failed to fetch tasks';
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -212,16 +247,19 @@ export const {
|
|||||||
bulkDeleteTasks,
|
bulkDeleteTasks,
|
||||||
reorderTasks,
|
reorderTasks,
|
||||||
moveTaskToGroup,
|
moveTaskToGroup,
|
||||||
|
optimisticTaskMove,
|
||||||
setLoading,
|
setLoading,
|
||||||
setError,
|
setError,
|
||||||
} = taskManagementSlice.actions;
|
} = taskManagementSlice.actions;
|
||||||
|
|
||||||
|
export default taskManagementSlice.reducer;
|
||||||
|
|
||||||
// Selectors
|
// Selectors
|
||||||
export const taskManagementSelectors = tasksAdapter.getSelectors<RootState>(
|
export const taskManagementSelectors = tasksAdapter.getSelectors<RootState>(
|
||||||
(state) => state.taskManagement
|
(state) => state.taskManagement
|
||||||
);
|
);
|
||||||
|
|
||||||
// Additional selectors
|
// Enhanced selectors for better performance
|
||||||
export const selectTasksByStatus = (state: RootState, status: string) =>
|
export const selectTasksByStatus = (state: RootState, status: string) =>
|
||||||
taskManagementSelectors.selectAll(state).filter(task => task.status === status);
|
taskManagementSelectors.selectAll(state).filter(task => task.status === status);
|
||||||
|
|
||||||
@@ -232,6 +270,4 @@ export const selectTasksByPhase = (state: RootState, phase: string) =>
|
|||||||
taskManagementSelectors.selectAll(state).filter(task => task.phase === phase);
|
taskManagementSelectors.selectAll(state).filter(task => task.phase === phase);
|
||||||
|
|
||||||
export const selectTasksLoading = (state: RootState) => state.taskManagement.loading;
|
export const selectTasksLoading = (state: RootState) => state.taskManagement.loading;
|
||||||
export const selectTasksError = (state: RootState) => state.taskManagement.error;
|
export const selectTasksError = (state: RootState) => state.taskManagement.error;
|
||||||
|
|
||||||
export default taskManagementSlice.reducer;
|
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { InlineMember } from './teamMembers/inlineMember.types';
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
task_key: string;
|
task_key: string;
|
||||||
@@ -8,6 +10,7 @@ export interface Task {
|
|||||||
phase: string; // Custom phases like 'planning', 'development', 'testing', 'deployment'
|
phase: string; // Custom phases like 'planning', 'development', 'testing', 'deployment'
|
||||||
progress: number; // 0-100
|
progress: number; // 0-100
|
||||||
assignees: string[];
|
assignees: string[];
|
||||||
|
assignee_names?: InlineMember[];
|
||||||
labels: Label[];
|
labels: Label[];
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
timeTracking: {
|
timeTracking: {
|
||||||
|
|||||||
Reference in New Issue
Block a user