feat(task-list): enhance task creation and UI components
- Improved the on_quick_task function to handle empty task names and emit null when no task is created, ensuring better user feedback. - Updated SubtaskLoadingSkeleton and TaskRow components for improved styling and spacing, enhancing visual consistency. - Introduced AddTaskRow component for streamlined task addition, integrating socket communication for real-time updates. - Refactored TaskListV2 to optimize rendering and improve performance, including adjustments to column headers and task display. - Added custom column components for enhanced task management flexibility and user interaction.
This commit is contained in:
@@ -56,6 +56,8 @@ export async function on_quick_task(_io: Server, socket: Socket, data?: string)
|
|||||||
const q = `SELECT create_quick_task($1) AS task;`;
|
const q = `SELECT create_quick_task($1) AS task;`;
|
||||||
const body = JSON.parse(data as string);
|
const body = JSON.parse(data as string);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
body.name = (body.name || "").trim();
|
body.name = (body.name || "").trim();
|
||||||
body.priority_id = body.priority_id?.trim() || null;
|
body.priority_id = body.priority_id?.trim() || null;
|
||||||
body.status_id = body.status_id?.trim() || null;
|
body.status_id = body.status_id?.trim() || null;
|
||||||
@@ -111,10 +113,12 @@ export async function on_quick_task(_io: Server, socket: Socket, data?: string)
|
|||||||
|
|
||||||
notifyProjectUpdates(socket, d.task.id);
|
notifyProjectUpdates(socket, d.task.id);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Empty task name, emit null to indicate no task was created
|
||||||
|
socket.emit(SocketEvents.QUICK_TASK.toString(), null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log_error(error);
|
log_error(error);
|
||||||
}
|
|
||||||
|
|
||||||
socket.emit(SocketEvents.QUICK_TASK.toString(), null);
|
socket.emit(SocketEvents.QUICK_TASK.toString(), null);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -19,16 +19,16 @@ const SubtaskLoadingSkeleton: React.FC<SubtaskLoadingSkeletonProps> = ({ visible
|
|||||||
return <div style={baseStyle} />;
|
return <div style={baseStyle} />;
|
||||||
case 'taskKey':
|
case 'taskKey':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle} className="flex items-center">
|
<div style={baseStyle} className="flex items-center pl-3">
|
||||||
<div className="h-4 w-12 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
<div className="h-5 w-16 bg-gray-200 dark:bg-gray-700 rounded-md animate-pulse border border-gray-300 dark:border-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'title':
|
case 'title':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle} className="flex items-center">
|
<div style={baseStyle} className="flex items-center">
|
||||||
{/* Subtask indentation */}
|
{/* Subtask indentation - tighter spacing */}
|
||||||
<div className="w-8" />
|
<div className="w-4" />
|
||||||
<div className="w-8" />
|
<div className="w-2" />
|
||||||
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={`inline-flex w-max items-center px-4 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-b border-gray-200 dark:border-gray-700 rounded-t-md ${
|
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-b border-gray-200 dark:border-gray-700 rounded-t-md ${
|
||||||
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
@@ -94,11 +94,11 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
|||||||
}}
|
}}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
>
|
>
|
||||||
{/* Drag Handle Space */}
|
{/* Drag Handle Space - ultra minimal width */}
|
||||||
<div style={{ width: '32px' }} className="flex items-center justify-center">
|
<div style={{ width: '20px' }} className="flex items-center justify-center">
|
||||||
{/* Chevron button */}
|
{/* Chevron button */}
|
||||||
<button
|
<button
|
||||||
className="p-1 rounded-md hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
|
className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
|
||||||
style={{ backgroundColor: 'transparent', color: headerTextColor }}
|
style={{ backgroundColor: 'transparent', color: headerTextColor }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -112,13 +112,13 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
|||||||
transformOrigin: 'center'
|
transformOrigin: 'center'
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ChevronRightIcon className="h-3.5 w-3.5" style={{ color: headerTextColor }} />
|
<ChevronRightIcon className="h-3 w-3" style={{ color: headerTextColor }} />
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Select All Checkbox Space */}
|
{/* Select All Checkbox Space - ultra minimal width */}
|
||||||
<div style={{ width: '40px' }} className="flex items-center justify-center">
|
<div style={{ width: '28px' }} className="flex items-center justify-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isAllSelected}
|
checked={isAllSelected}
|
||||||
indeterminate={isPartiallySelected}
|
indeterminate={isPartiallySelected}
|
||||||
@@ -130,10 +130,8 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Group indicator and name */}
|
{/* Group indicator and name - no gap at all */}
|
||||||
<div className="ml-1 flex items-center gap-2 flex-1">
|
<div className="flex items-center flex-1 ml-1">
|
||||||
{/* Color indicator (removed as full header is colored) */}
|
|
||||||
|
|
||||||
{/* Group name and count */}
|
{/* Group name and count */}
|
||||||
<div className="flex items-center flex-1">
|
<div className="flex items-center flex-1">
|
||||||
<span className="text-sm font-semibold">
|
<span className="text-sm font-semibold">
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -277,8 +277,8 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
|
|
||||||
case 'taskKey':
|
case 'taskKey':
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center" style={baseStyle}>
|
<div className="flex items-center pl-3" style={baseStyle}>
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-white whitespace-nowrap badge badge-primary">
|
<span className="text-xs font-medium px-2 py-1 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 whitespace-nowrap border border-gray-200 dark:border-gray-600">
|
||||||
{task.task_key || 'N/A'}
|
{task.task_key || 'N/A'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -288,15 +288,15 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between group" style={baseStyle}>
|
<div className="flex items-center justify-between group" style={baseStyle}>
|
||||||
<div className="flex items-center flex-1">
|
<div className="flex items-center flex-1">
|
||||||
{/* Indentation for subtasks - increased padding */}
|
{/* Indentation for subtasks - tighter spacing */}
|
||||||
{isSubtask && <div className="w-8" />}
|
{isSubtask && <div className="w-4" />}
|
||||||
|
|
||||||
{/* Expand/Collapse button - only show for parent tasks */}
|
{/* Expand/Collapse button - only show for parent tasks */}
|
||||||
{!isSubtask && (
|
{!isSubtask && (
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleExpansion}
|
onClick={handleToggleExpansion}
|
||||||
className={`flex h-4 w-4 items-center justify-center rounded-sm text-xs mr-2 hover:border hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:scale-110 transition-all duration-300 ease-out ${
|
className={`flex h-4 w-4 items-center justify-center rounded-sm text-xs mr-1 hover:border hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:scale-110 transition-all duration-300 ease-out ${
|
||||||
task.sub_tasks_count && task.sub_tasks_count > 0
|
task.sub_tasks_count && Number(task.sub_tasks_count) > 0
|
||||||
? 'opacity-100'
|
? 'opacity-100'
|
||||||
: 'opacity-0 group-hover:opacity-100'
|
: 'opacity-0 group-hover:opacity-100'
|
||||||
}`}
|
}`}
|
||||||
@@ -314,7 +314,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Additional indentation for subtasks after the expand button space */}
|
{/* Additional indentation for subtasks after the expand button space */}
|
||||||
{isSubtask && <div className="w-4" />}
|
{isSubtask && <div className="w-2" />}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">
|
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||||
@@ -322,7 +322,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Subtask count indicator */}
|
{/* Subtask count indicator */}
|
||||||
{!isSubtask && task.sub_tasks_count && task.sub_tasks_count > 0 && (
|
{!isSubtask && task.sub_tasks_count && Number(task.sub_tasks_count) > 0 && (
|
||||||
<div className="flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
<div className="flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
||||||
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
|
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
|
||||||
{task.sub_tasks_count}
|
{task.sub_tasks_count}
|
||||||
@@ -640,7 +640,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={`flex items-center min-w-max px-4 py-2 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
className={`flex items-center min-w-max px-1 py-2 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
||||||
isDragging ? 'shadow-lg border border-blue-300' : ''
|
isDragging ? 'shadow-lg border border-blue-300' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { PlusOutlined } from '@ant-design/icons';
|
|||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
import { SocketEvents } from '@/shared/socket-events';
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
|
||||||
interface TaskRowWithSubtasksProps {
|
interface TaskRowWithSubtasksProps {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -45,8 +46,11 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
|||||||
const { t } = useTranslation('task-list-table');
|
const { t } = useTranslation('task-list-table');
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// Get session data for reporter_id and team_id
|
||||||
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
|
||||||
const handleAddSubtask = useCallback(() => {
|
const handleAddSubtask = useCallback(() => {
|
||||||
if (!subtaskName.trim()) return;
|
if (!subtaskName.trim() || !currentSession) return;
|
||||||
|
|
||||||
// Create optimistic subtask immediately for better UX
|
// Create optimistic subtask immediately for better UX
|
||||||
dispatch(createSubtask({
|
dispatch(createSubtask({
|
||||||
@@ -63,6 +67,8 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
|||||||
name: subtaskName.trim(),
|
name: subtaskName.trim(),
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
parent_task_id: parentTaskId,
|
parent_task_id: parentTaskId,
|
||||||
|
reporter_id: currentSession.id,
|
||||||
|
team_id: currentSession.team_id,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -70,7 +76,7 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
|||||||
setSubtaskName('');
|
setSubtaskName('');
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
onSubtaskAdded();
|
onSubtaskAdded();
|
||||||
}, [subtaskName, dispatch, parentTaskId, projectId, connected, socket, onSubtaskAdded]);
|
}, [subtaskName, dispatch, parentTaskId, projectId, connected, socket, currentSession, onSubtaskAdded]);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
setSubtaskName('');
|
setSubtaskName('');
|
||||||
@@ -91,8 +97,9 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center h-full" style={baseStyle}>
|
<div className="flex items-center h-full" style={baseStyle}>
|
||||||
<div className="flex items-center w-full h-full">
|
<div className="flex items-center w-full h-full">
|
||||||
{/* Match subtask indentation pattern - same as TaskRow for subtasks */}
|
{/* Match subtask indentation pattern - tighter spacing */}
|
||||||
<div className="w-8" />
|
<div className="w-4" />
|
||||||
|
<div className="w-2" />
|
||||||
|
|
||||||
{!isAdding ? (
|
{!isAdding ? (
|
||||||
<button
|
<button
|
||||||
@@ -128,7 +135,7 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
|||||||
}, [isAdding, subtaskName, handleAddSubtask, handleCancel, t]);
|
}, [isAdding, subtaskName, handleAddSubtask, handleCancel, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center min-w-max px-4 py-2 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[42px]">
|
<div className="flex items-center min-w-max px-1 py-2 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[42px]">
|
||||||
{visibleColumns.map((column) =>
|
{visibleColumns.map((column) =>
|
||||||
renderColumn(column.id, column.width)
|
renderColumn(column.id, column.width)
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
import React, { useState, useCallback, memo, useEffect } from 'react';
|
||||||
|
import { Input } from 'antd';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { addTaskToGroup } from '@/features/task-management/task-management.slice';
|
||||||
|
import { Task } from '@/types/task-management.types';
|
||||||
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
|
|
||||||
|
interface AddTaskRowProps {
|
||||||
|
groupId: string;
|
||||||
|
groupType: string;
|
||||||
|
groupValue: string;
|
||||||
|
projectId: string;
|
||||||
|
visibleColumns: Array<{
|
||||||
|
id: string;
|
||||||
|
width: string;
|
||||||
|
isSticky?: boolean;
|
||||||
|
}>;
|
||||||
|
onTaskAdded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
|
||||||
|
groupId,
|
||||||
|
groupType,
|
||||||
|
groupValue,
|
||||||
|
projectId,
|
||||||
|
visibleColumns,
|
||||||
|
onTaskAdded
|
||||||
|
}) => {
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [taskName, setTaskName] = useState('');
|
||||||
|
const { socket, connected } = useSocket();
|
||||||
|
const { t } = useTranslation('task-list-table');
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// Get session data for reporter_id and team_id
|
||||||
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
|
||||||
|
// Listen for task creation completion and add to Redux store immediately
|
||||||
|
useEffect(() => {
|
||||||
|
if (!socket) return;
|
||||||
|
|
||||||
|
const handleTaskCreated = (data: IProjectTask) => {
|
||||||
|
if (data) {
|
||||||
|
// Transform backend response to Task format for real-time addition
|
||||||
|
const task: Task = {
|
||||||
|
id: data.id || '',
|
||||||
|
task_key: data.task_key || '',
|
||||||
|
title: data.name || '',
|
||||||
|
description: data.description || '',
|
||||||
|
status: (data.status_category?.is_todo
|
||||||
|
? 'todo'
|
||||||
|
: data.status_category?.is_doing
|
||||||
|
? 'doing'
|
||||||
|
: data.status_category?.is_done
|
||||||
|
? 'done'
|
||||||
|
: 'todo') as 'todo' | 'doing' | 'done',
|
||||||
|
priority: (data.priority_value === 3
|
||||||
|
? 'critical'
|
||||||
|
: data.priority_value === 2
|
||||||
|
? 'high'
|
||||||
|
: data.priority_value === 1
|
||||||
|
? 'medium'
|
||||||
|
: 'low') as 'critical' | 'high' | 'medium' | 'low',
|
||||||
|
phase: data.phase_name || 'Development',
|
||||||
|
progress: data.complete_ratio || 0,
|
||||||
|
assignees: data.assignees?.map(a => a.team_member_id) || [],
|
||||||
|
assignee_names: data.names || [],
|
||||||
|
labels:
|
||||||
|
data.labels?.map(l => ({
|
||||||
|
id: l.id || '',
|
||||||
|
name: l.name || '',
|
||||||
|
color: l.color_code || '#1890ff',
|
||||||
|
end: l.end,
|
||||||
|
names: l.names,
|
||||||
|
})) || [],
|
||||||
|
dueDate: data.end_date,
|
||||||
|
startDate: data.start_date,
|
||||||
|
timeTracking: {
|
||||||
|
estimated: (data.total_hours || 0) + (data.total_minutes || 0) / 60,
|
||||||
|
logged: (data.time_spent?.hours || 0) + (data.time_spent?.minutes || 0) / 60,
|
||||||
|
},
|
||||||
|
created_at: data.created_at || new Date().toISOString(),
|
||||||
|
updated_at: data.updated_at || new Date().toISOString(),
|
||||||
|
order: data.sort_order || 0,
|
||||||
|
sub_tasks: [],
|
||||||
|
sub_tasks_count: 0,
|
||||||
|
show_sub_tasks: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add task to the correct group in Redux store for immediate UI update
|
||||||
|
dispatch(addTaskToGroup({ task, groupId }));
|
||||||
|
|
||||||
|
// Optional: Call onTaskAdded for any additional UI updates
|
||||||
|
onTaskAdded();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.on(SocketEvents.QUICK_TASK.toString(), handleTaskCreated);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
socket.off(SocketEvents.QUICK_TASK.toString(), handleTaskCreated);
|
||||||
|
};
|
||||||
|
}, [socket, onTaskAdded, dispatch, groupId]);
|
||||||
|
|
||||||
|
const handleAddTask = useCallback(() => {
|
||||||
|
if (!taskName.trim() || !currentSession) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: any = {
|
||||||
|
name: taskName.trim(),
|
||||||
|
project_id: projectId,
|
||||||
|
reporter_id: currentSession.id,
|
||||||
|
team_id: currentSession.team_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map grouping type to correct field name expected by backend
|
||||||
|
switch (groupType) {
|
||||||
|
case 'status':
|
||||||
|
body.status_id = groupValue;
|
||||||
|
break;
|
||||||
|
case 'priority':
|
||||||
|
body.priority_id = groupValue;
|
||||||
|
break;
|
||||||
|
case 'phase':
|
||||||
|
body.phase_id = groupValue;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// For any other grouping types, use the groupType as is
|
||||||
|
body[groupType] = groupValue;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (socket && connected) {
|
||||||
|
|
||||||
|
socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||||
|
setTaskName('');
|
||||||
|
setIsAdding(false);
|
||||||
|
// Task refresh will be handled by socket response listener
|
||||||
|
} else {
|
||||||
|
console.warn('Socket not connected, unable to create task');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating task:', error);
|
||||||
|
}
|
||||||
|
}, [taskName, projectId, groupType, groupValue, socket, connected, currentSession, onTaskAdded]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
setTaskName('');
|
||||||
|
setIsAdding(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderColumn = useCallback((columnId: string, width: string) => {
|
||||||
|
const baseStyle = { width };
|
||||||
|
|
||||||
|
switch (columnId) {
|
||||||
|
case 'dragHandle':
|
||||||
|
case 'checkbox':
|
||||||
|
case 'taskKey':
|
||||||
|
return <div style={baseStyle} />;
|
||||||
|
case 'title':
|
||||||
|
return (
|
||||||
|
<div className="flex items-center h-full" style={baseStyle}>
|
||||||
|
<div className="flex items-center w-full h-full">
|
||||||
|
<div className="w-4 mr-1" />
|
||||||
|
|
||||||
|
{!isAdding ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAdding(true)}
|
||||||
|
className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors h-full"
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-xs" />
|
||||||
|
{t('addTaskText')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={taskName}
|
||||||
|
onChange={(e) => setTaskName(e.target.value)}
|
||||||
|
onPressEnter={handleAddTask}
|
||||||
|
onBlur={handleCancel}
|
||||||
|
placeholder="Type task name and press Enter to save"
|
||||||
|
className="w-full h-full border-none shadow-none bg-transparent"
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
minHeight: '32px',
|
||||||
|
padding: '0',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <div style={baseStyle} />;
|
||||||
|
}
|
||||||
|
}, [isAdding, taskName, handleAddTask, handleCancel, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center min-w-max px-1 py-0.5 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[36px] border-b border-gray-200 dark:border-gray-700">
|
||||||
|
{visibleColumns.map((column) =>
|
||||||
|
renderColumn(column.id, column.width)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
AddTaskRow.displayName = 'AddTaskRow';
|
||||||
|
|
||||||
|
export default AddTaskRow;
|
||||||
@@ -0,0 +1,461 @@
|
|||||||
|
import React, { useState, useCallback, useMemo, memo } from 'react';
|
||||||
|
import { Button, Tooltip, Flex, Dropdown, DatePicker } from 'antd';
|
||||||
|
import { PlusOutlined, SettingOutlined, UsergroupAddOutlined } from '@ant-design/icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import {
|
||||||
|
setCustomColumnModalAttributes,
|
||||||
|
toggleCustomColumnModalOpen,
|
||||||
|
} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
||||||
|
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
// Add Custom Column Button Component
|
||||||
|
export const AddCustomColumnButton: React.FC = memo(() => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { t } = useTranslation('task-list-table');
|
||||||
|
|
||||||
|
const handleModalOpen = useCallback(() => {
|
||||||
|
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
|
||||||
|
dispatch(toggleCustomColumnModalOpen(true));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={t('customColumns.addCustomColumn')}>
|
||||||
|
<Button
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
onClick={handleModalOpen}
|
||||||
|
className="hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
AddCustomColumnButton.displayName = 'AddCustomColumnButton';
|
||||||
|
|
||||||
|
// Custom Column Header Component
|
||||||
|
export const CustomColumnHeader: React.FC<{
|
||||||
|
column: any;
|
||||||
|
onSettingsClick: (columnId: string) => void;
|
||||||
|
}> = ({ column, onSettingsClick }) => {
|
||||||
|
const { t } = useTranslation('task-list-table');
|
||||||
|
|
||||||
|
const displayName = column.name ||
|
||||||
|
column.label ||
|
||||||
|
column.custom_column_obj?.fieldTitle ||
|
||||||
|
column.custom_column_obj?.field_title ||
|
||||||
|
t('customColumns.customColumnHeader');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex align="center" justify="space-between" className="w-full">
|
||||||
|
<span title={displayName}>{displayName}</span>
|
||||||
|
<Tooltip title={t('customColumns.customColumnSettings')}>
|
||||||
|
<SettingOutlined
|
||||||
|
className="cursor-pointer hover:text-primary"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSettingsClick(column.key || column.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom Column Cell Component with Interactive Inputs
|
||||||
|
export const CustomColumnCell: React.FC<{
|
||||||
|
column: any;
|
||||||
|
task: any;
|
||||||
|
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||||
|
}> = memo(({ column, task, updateTaskCustomColumnValue }) => {
|
||||||
|
const { t } = useTranslation('task-list-table');
|
||||||
|
|
||||||
|
const customValue = task.custom_column_values?.[column.key];
|
||||||
|
const fieldType = column.custom_column_obj?.fieldType;
|
||||||
|
|
||||||
|
if (!fieldType || !column.custom_column) {
|
||||||
|
return <span className="text-gray-400 text-sm">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render different input types based on field type
|
||||||
|
switch (fieldType) {
|
||||||
|
case 'people':
|
||||||
|
return (
|
||||||
|
<PeopleCustomColumnCell
|
||||||
|
task={task}
|
||||||
|
columnKey={column.key}
|
||||||
|
customValue={customValue}
|
||||||
|
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'date':
|
||||||
|
return (
|
||||||
|
<DateCustomColumnCell
|
||||||
|
task={task}
|
||||||
|
columnKey={column.key}
|
||||||
|
customValue={customValue}
|
||||||
|
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'number':
|
||||||
|
return (
|
||||||
|
<NumberCustomColumnCell
|
||||||
|
task={task}
|
||||||
|
columnKey={column.key}
|
||||||
|
customValue={customValue}
|
||||||
|
columnObj={column.custom_column_obj}
|
||||||
|
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'selection':
|
||||||
|
return (
|
||||||
|
<SelectionCustomColumnCell
|
||||||
|
task={task}
|
||||||
|
columnKey={column.key}
|
||||||
|
customValue={customValue}
|
||||||
|
columnObj={column.custom_column_obj}
|
||||||
|
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <span className="text-sm text-gray-400">{t('customColumns.unsupportedField')}</span>;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
CustomColumnCell.displayName = 'CustomColumnCell';
|
||||||
|
|
||||||
|
// People Field Cell Component
|
||||||
|
export const PeopleCustomColumnCell: React.FC<{
|
||||||
|
task: any;
|
||||||
|
columnKey: string;
|
||||||
|
customValue: any;
|
||||||
|
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||||
|
}> = memo(({ task, columnKey, customValue, updateTaskCustomColumnValue }) => {
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { t } = useTranslation('task-list-table');
|
||||||
|
|
||||||
|
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
|
||||||
|
|
||||||
|
const selectedMemberIds = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return customValue ? JSON.parse(customValue) : [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [customValue]);
|
||||||
|
|
||||||
|
const filteredMembers = useMemo(() => {
|
||||||
|
return members?.data?.filter(member =>
|
||||||
|
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
) || [];
|
||||||
|
}, [members, searchQuery]);
|
||||||
|
|
||||||
|
const selectedMembers = useMemo(() => {
|
||||||
|
if (!members?.data || !selectedMemberIds.length) return [];
|
||||||
|
return members.data.filter(member => selectedMemberIds.includes(member.id));
|
||||||
|
}, [members, selectedMemberIds]);
|
||||||
|
|
||||||
|
const handleMemberSelection = (memberId: string) => {
|
||||||
|
const newSelectedIds = selectedMemberIds.includes(memberId)
|
||||||
|
? selectedMemberIds.filter((id: string) => id !== memberId)
|
||||||
|
: [...selectedMemberIds, memberId];
|
||||||
|
|
||||||
|
if (task.id) {
|
||||||
|
updateTaskCustomColumnValue(task.id, columnKey, JSON.stringify(newSelectedIds));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInviteProjectMember = () => {
|
||||||
|
dispatch(toggleProjectMemberDrawer());
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropdownContent = (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-2 w-80">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
placeholder={t('searchInputPlaceholder')}
|
||||||
|
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="max-h-60 overflow-y-auto">
|
||||||
|
{filteredMembers.length > 0 ? (
|
||||||
|
filteredMembers.map(member => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
onClick={() => member.id && handleMemberSelection(member.id)}
|
||||||
|
className="flex items-center gap-2 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md cursor-pointer"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={member.id ? selectedMemberIds.includes(member.id) : false}
|
||||||
|
onChange={() => member.id && handleMemberSelection(member.id)}
|
||||||
|
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||||
|
/>
|
||||||
|
<div className="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
{member.avatar_url ? (
|
||||||
|
<img src={member.avatar_url} alt={member.name} className="w-8 h-8 rounded-full" />
|
||||||
|
) : (
|
||||||
|
member.name?.charAt(0).toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{member.name}</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">{member.email}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-4 text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
{t('noMembersFound')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={handleInviteProjectMember}
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md"
|
||||||
|
>
|
||||||
|
<UsergroupAddOutlined className="w-4 h-4" />
|
||||||
|
{t('assigneeSelectorInviteButton')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{selectedMembers.length > 0 && (
|
||||||
|
<div className="flex -space-x-1">
|
||||||
|
{selectedMembers.slice(0, 3).map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xs font-medium text-gray-700 dark:text-gray-300 border-2 border-white dark:border-gray-800"
|
||||||
|
title={member.name}
|
||||||
|
>
|
||||||
|
{member.avatar_url ? (
|
||||||
|
<img src={member.avatar_url} alt={member.name} className="w-6 h-6 rounded-full" />
|
||||||
|
) : (
|
||||||
|
member.name?.charAt(0).toUpperCase()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{selectedMembers.length > 3 && (
|
||||||
|
<div className="w-6 h-6 rounded-full bg-gray-400 dark:bg-gray-500 flex items-center justify-center text-xs font-medium text-white border-2 border-white dark:border-gray-800">
|
||||||
|
+{selectedMembers.length - 3}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Dropdown
|
||||||
|
open={isDropdownOpen}
|
||||||
|
onOpenChange={setIsDropdownOpen}
|
||||||
|
dropdownRender={() => dropdownContent}
|
||||||
|
trigger={['click']}
|
||||||
|
placement="bottomLeft"
|
||||||
|
>
|
||||||
|
<button className="w-6 h-6 rounded-full border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center hover:border-blue-500 dark:hover:border-blue-400 transition-colors">
|
||||||
|
<PlusOutlined className="w-3 h-3 text-gray-400 dark:text-gray-500" />
|
||||||
|
</button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
PeopleCustomColumnCell.displayName = 'PeopleCustomColumnCell';
|
||||||
|
|
||||||
|
// Date Field Cell Component
|
||||||
|
export const DateCustomColumnCell: React.FC<{
|
||||||
|
task: any;
|
||||||
|
columnKey: string;
|
||||||
|
customValue: any;
|
||||||
|
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||||
|
}> = memo(({ task, columnKey, customValue, updateTaskCustomColumnValue }) => {
|
||||||
|
const dateValue = customValue ? dayjs(customValue) : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DatePicker
|
||||||
|
value={dateValue}
|
||||||
|
onChange={date => {
|
||||||
|
if (task.id) {
|
||||||
|
updateTaskCustomColumnValue(task.id, columnKey, date ? date.toISOString() : '');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder="Set Date"
|
||||||
|
format="MMM DD, YYYY"
|
||||||
|
suffixIcon={null}
|
||||||
|
className="w-full border-none bg-transparent hover:bg-gray-50 dark:hover:bg-gray-700 text-sm"
|
||||||
|
inputReadOnly
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DateCustomColumnCell.displayName = 'DateCustomColumnCell';
|
||||||
|
|
||||||
|
// Number Field Cell Component
|
||||||
|
export const NumberCustomColumnCell: React.FC<{
|
||||||
|
task: any;
|
||||||
|
columnKey: string;
|
||||||
|
customValue: any;
|
||||||
|
columnObj: any;
|
||||||
|
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||||
|
}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => {
|
||||||
|
const [inputValue, setInputValue] = useState(customValue || '');
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
|
||||||
|
const numberType = columnObj?.numberType || 'formatted';
|
||||||
|
const decimals = columnObj?.decimals || 0;
|
||||||
|
const label = columnObj?.label || '';
|
||||||
|
const labelPosition = columnObj?.labelPosition || 'left';
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
// Allow only numbers, decimal point, and minus sign
|
||||||
|
if (/^-?\d*\.?\d*$/.test(value) || value === '') {
|
||||||
|
setInputValue(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
if (task.id && inputValue !== customValue) {
|
||||||
|
updateTaskCustomColumnValue(task.id, columnKey, inputValue);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleBlur();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setInputValue(customValue || '');
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDisplayValue = () => {
|
||||||
|
if (isEditing) return inputValue;
|
||||||
|
|
||||||
|
if (!inputValue) return '';
|
||||||
|
|
||||||
|
const numValue = parseFloat(inputValue);
|
||||||
|
if (isNaN(numValue)) return inputValue;
|
||||||
|
|
||||||
|
switch (numberType) {
|
||||||
|
case 'formatted':
|
||||||
|
return numValue.toFixed(decimals);
|
||||||
|
case 'percentage':
|
||||||
|
return `${numValue.toFixed(decimals)}%`;
|
||||||
|
case 'withLabel':
|
||||||
|
return labelPosition === 'left' ? `${label} ${numValue.toFixed(decimals)}` : `${numValue.toFixed(decimals)} ${label}`;
|
||||||
|
default:
|
||||||
|
return inputValue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{numberType === 'withLabel' && labelPosition === 'left' && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">{label}</span>
|
||||||
|
)}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={getDisplayValue()}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={() => setIsEditing(true)}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
className="w-full bg-transparent border-none text-sm text-right focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-700 px-1 py-0.5 rounded"
|
||||||
|
placeholder="0"
|
||||||
|
/>
|
||||||
|
{numberType === 'withLabel' && labelPosition === 'right' && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">{label}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
NumberCustomColumnCell.displayName = 'NumberCustomColumnCell';
|
||||||
|
|
||||||
|
// Selection Field Cell Component
|
||||||
|
export const SelectionCustomColumnCell: React.FC<{
|
||||||
|
task: any;
|
||||||
|
columnKey: string;
|
||||||
|
customValue: any;
|
||||||
|
columnObj: any;
|
||||||
|
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||||
|
}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => {
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const selectionsList = columnObj?.selectionsList || [];
|
||||||
|
|
||||||
|
const selectedOption = selectionsList.find((option: any) => option.selection_name === customValue);
|
||||||
|
|
||||||
|
const dropdownContent = (
|
||||||
|
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-1 min-w-[150px]">
|
||||||
|
{selectionsList.map((option: any) => (
|
||||||
|
<div
|
||||||
|
key={option.selection_id}
|
||||||
|
onClick={() => {
|
||||||
|
if (task.id) {
|
||||||
|
updateTaskCustomColumnValue(task.id, columnKey, option.selection_name);
|
||||||
|
}
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-2 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md cursor-pointer"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: option.selection_color || '#6b7280' }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-900 dark:text-gray-100">{option.selection_name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{selectionsList.length === 0 && (
|
||||||
|
<div className="text-center py-2 text-gray-500 dark:text-gray-400 text-sm">
|
||||||
|
No options available
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
open={isDropdownOpen}
|
||||||
|
onOpenChange={setIsDropdownOpen}
|
||||||
|
dropdownRender={() => dropdownContent}
|
||||||
|
trigger={['click']}
|
||||||
|
placement="bottomLeft"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 p-1 rounded min-h-[24px]">
|
||||||
|
{selectedOption ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full"
|
||||||
|
style={{ backgroundColor: selectedOption.selection_color || '#6b7280' }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-gray-900 dark:text-gray-100">{selectedOption.selection_name}</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-gray-400 dark:text-gray-500">Select option</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
SelectionCustomColumnCell.displayName = 'SelectionCustomColumnCell';
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
||||||
|
|
||||||
|
export type ColumnStyle = {
|
||||||
|
width: string;
|
||||||
|
position?: 'static' | 'relative' | 'absolute' | 'sticky' | 'fixed';
|
||||||
|
left?: number;
|
||||||
|
backgroundColor?: string;
|
||||||
|
zIndex?: number;
|
||||||
|
flexShrink?: number;
|
||||||
|
minWidth?: string;
|
||||||
|
maxWidth?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Base column configuration
|
||||||
|
export const BASE_COLUMNS = [
|
||||||
|
{ id: 'dragHandle', label: '', width: '20px', isSticky: true, key: 'dragHandle' },
|
||||||
|
{ id: 'checkbox', label: '', width: '28px', isSticky: true, key: 'checkbox' },
|
||||||
|
{ id: 'taskKey', label: 'keyColumn', width: '100px', key: COLUMN_KEYS.KEY, minWidth: '100px', maxWidth: '150px' },
|
||||||
|
{ id: 'title', label: 'taskColumn', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME },
|
||||||
|
{ id: 'status', label: 'statusColumn', width: '120px', key: COLUMN_KEYS.STATUS },
|
||||||
|
{ id: 'assignees', label: 'assigneesColumn', width: '150px', key: COLUMN_KEYS.ASSIGNEES },
|
||||||
|
{ id: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY },
|
||||||
|
{ id: 'dueDate', label: 'dueDateColumn', width: '120px', key: COLUMN_KEYS.DUE_DATE },
|
||||||
|
{ id: 'progress', label: 'progressColumn', width: '120px', key: COLUMN_KEYS.PROGRESS },
|
||||||
|
{ id: 'labels', label: 'labelsColumn', width: 'auto', key: COLUMN_KEYS.LABELS },
|
||||||
|
{ id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE },
|
||||||
|
{ id: 'timeTracking', label: 'timeTrackingColumn', width: '120px', key: COLUMN_KEYS.TIME_TRACKING },
|
||||||
|
{ id: 'estimation', label: 'estimationColumn', width: '120px', key: COLUMN_KEYS.ESTIMATION },
|
||||||
|
{ id: 'startDate', label: 'startDateColumn', width: '120px', key: COLUMN_KEYS.START_DATE },
|
||||||
|
{ id: 'dueTime', label: 'dueTimeColumn', width: '120px', key: COLUMN_KEYS.DUE_TIME },
|
||||||
|
{ id: 'completedDate', label: 'completedDateColumn', width: '120px', key: COLUMN_KEYS.COMPLETED_DATE },
|
||||||
|
{ id: 'createdDate', label: 'createdDateColumn', width: '120px', key: COLUMN_KEYS.CREATED_DATE },
|
||||||
|
{ id: 'lastUpdated', label: 'lastUpdatedColumn', width: '120px', key: COLUMN_KEYS.LAST_UPDATED },
|
||||||
|
{ id: 'reporter', label: 'reporterColumn', width: '120px', key: COLUMN_KEYS.REPORTER },
|
||||||
|
];
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useCallback } from 'react';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { clearSelection } from '@/features/task-management/selection.slice';
|
||||||
|
|
||||||
|
export const useBulkActions = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
const handleClearSelection = useCallback(() => {
|
||||||
|
dispatch(clearSelection());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleBulkStatusChange = useCallback(async (statusId: string) => {
|
||||||
|
// TODO: Implement bulk status change
|
||||||
|
console.log('Bulk status change:', statusId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkPriorityChange = useCallback(async (priorityId: string) => {
|
||||||
|
// TODO: Implement bulk priority change
|
||||||
|
console.log('Bulk priority change:', priorityId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkPhaseChange = useCallback(async (phaseId: string) => {
|
||||||
|
// TODO: Implement bulk phase change
|
||||||
|
console.log('Bulk phase change:', phaseId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkAssignToMe = useCallback(async () => {
|
||||||
|
// TODO: Implement bulk assign to me
|
||||||
|
console.log('Bulk assign to me');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkAssignMembers = useCallback(async (memberIds: string[]) => {
|
||||||
|
// TODO: Implement bulk assign members
|
||||||
|
console.log('Bulk assign members:', memberIds);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkAddLabels = useCallback(async (labelIds: string[]) => {
|
||||||
|
// TODO: Implement bulk add labels
|
||||||
|
console.log('Bulk add labels:', labelIds);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkArchive = useCallback(async () => {
|
||||||
|
// TODO: Implement bulk archive
|
||||||
|
console.log('Bulk archive');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkDelete = useCallback(async () => {
|
||||||
|
// TODO: Implement bulk delete
|
||||||
|
console.log('Bulk delete');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkDuplicate = useCallback(async () => {
|
||||||
|
// TODO: Implement bulk duplicate
|
||||||
|
console.log('Bulk duplicate');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkExport = useCallback(async () => {
|
||||||
|
// TODO: Implement bulk export
|
||||||
|
console.log('Bulk export');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleBulkSetDueDate = useCallback(async (date: string) => {
|
||||||
|
// TODO: Implement bulk set due date
|
||||||
|
console.log('Bulk set due date:', date);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleClearSelection,
|
||||||
|
handleBulkStatusChange,
|
||||||
|
handleBulkPriorityChange,
|
||||||
|
handleBulkPhaseChange,
|
||||||
|
handleBulkAssignToMe,
|
||||||
|
handleBulkAssignMembers,
|
||||||
|
handleBulkAddLabels,
|
||||||
|
handleBulkArchive,
|
||||||
|
handleBulkDelete,
|
||||||
|
handleBulkDuplicate,
|
||||||
|
handleBulkExport,
|
||||||
|
handleBulkSetDueDate,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { DragEndEvent, DragOverEvent, DragStartEvent } from '@dnd-kit/core';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { reorderTasksInGroup, moveTaskBetweenGroups } from '@/features/task-management/task-management.slice';
|
||||||
|
import { Task, TaskGroup } from '@/types/task-management.types';
|
||||||
|
|
||||||
|
export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
|
setActiveId(event.active.id as string);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback(
|
||||||
|
(event: DragOverEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const activeId = active.id;
|
||||||
|
const overId = over.id;
|
||||||
|
|
||||||
|
// Find the active task and the item being dragged over
|
||||||
|
const activeTask = allTasks.find(task => task.id === activeId);
|
||||||
|
if (!activeTask) return;
|
||||||
|
|
||||||
|
// Check if we're dragging over a task or a group
|
||||||
|
const overTask = allTasks.find(task => task.id === overId);
|
||||||
|
const overGroup = groups.find(group => group.id === overId);
|
||||||
|
|
||||||
|
// Find the groups
|
||||||
|
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||||
|
let targetGroup = overGroup;
|
||||||
|
|
||||||
|
if (overTask) {
|
||||||
|
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeGroup || !targetGroup) return;
|
||||||
|
|
||||||
|
// If dragging to a different group, we need to handle cross-group movement
|
||||||
|
if (activeGroup.id !== targetGroup.id) {
|
||||||
|
console.log('Cross-group drag detected:', {
|
||||||
|
activeTask: activeTask.id,
|
||||||
|
fromGroup: activeGroup.id,
|
||||||
|
toGroup: targetGroup.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[allTasks, groups]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
setActiveId(null);
|
||||||
|
|
||||||
|
if (!over || active.id === over.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeId = active.id;
|
||||||
|
const overId = over.id;
|
||||||
|
|
||||||
|
// Find the active task
|
||||||
|
const activeTask = allTasks.find(task => task.id === activeId);
|
||||||
|
if (!activeTask) {
|
||||||
|
console.error('Active task not found:', activeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the groups
|
||||||
|
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||||
|
if (!activeGroup) {
|
||||||
|
console.error('Could not find active group for task:', activeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're dropping on a task or a group
|
||||||
|
const overTask = allTasks.find(task => task.id === overId);
|
||||||
|
const overGroup = groups.find(group => group.id === overId);
|
||||||
|
|
||||||
|
let targetGroup = overGroup;
|
||||||
|
let insertIndex = 0;
|
||||||
|
|
||||||
|
if (overTask) {
|
||||||
|
// Dropping on a task
|
||||||
|
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||||
|
if (targetGroup) {
|
||||||
|
insertIndex = targetGroup.taskIds.indexOf(overTask.id);
|
||||||
|
}
|
||||||
|
} else if (overGroup) {
|
||||||
|
// Dropping on a group (at the end)
|
||||||
|
targetGroup = overGroup;
|
||||||
|
insertIndex = targetGroup.taskIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetGroup) {
|
||||||
|
console.error('Could not find target group');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCrossGroup = activeGroup.id !== targetGroup.id;
|
||||||
|
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
|
||||||
|
|
||||||
|
console.log('Drag operation:', {
|
||||||
|
activeId,
|
||||||
|
overId,
|
||||||
|
activeTask: activeTask.name || activeTask.title,
|
||||||
|
activeGroup: activeGroup.id,
|
||||||
|
targetGroup: targetGroup.id,
|
||||||
|
activeIndex,
|
||||||
|
insertIndex,
|
||||||
|
isCrossGroup,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isCrossGroup) {
|
||||||
|
// Moving task between groups
|
||||||
|
console.log('Moving task between groups:', {
|
||||||
|
task: activeTask.name || activeTask.title,
|
||||||
|
from: activeGroup.title,
|
||||||
|
to: targetGroup.title,
|
||||||
|
newPosition: insertIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move task to the target group
|
||||||
|
dispatch(
|
||||||
|
moveTaskBetweenGroups({
|
||||||
|
taskId: activeId as string,
|
||||||
|
sourceGroupId: activeGroup.id,
|
||||||
|
targetGroupId: targetGroup.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reorder task within target group at drop position
|
||||||
|
dispatch(
|
||||||
|
reorderTasksInGroup({
|
||||||
|
sourceTaskId: activeId as string,
|
||||||
|
destinationTaskId: over.id as string,
|
||||||
|
sourceGroupId: activeGroup.id,
|
||||||
|
destinationGroupId: targetGroup.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Reordering within the same group
|
||||||
|
console.log('Reordering task within same group:', {
|
||||||
|
task: activeTask.name || activeTask.title,
|
||||||
|
group: activeGroup.title,
|
||||||
|
from: activeIndex,
|
||||||
|
to: insertIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeIndex !== insertIndex) {
|
||||||
|
// Reorder task within same group at drop position
|
||||||
|
dispatch(
|
||||||
|
reorderTasksInGroup({
|
||||||
|
sourceTaskId: activeId as string,
|
||||||
|
destinationTaskId: over.id as string,
|
||||||
|
sourceGroupId: activeGroup.id,
|
||||||
|
destinationGroupId: activeGroup.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[allTasks, groups, dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeId,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragEnd,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -18,43 +18,21 @@ import { Card, Spin, Empty, Alert } from 'antd';
|
|||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import {
|
import {
|
||||||
selectAllTasks,
|
selectAllTasks,
|
||||||
selectGroups,
|
|
||||||
selectGrouping,
|
|
||||||
selectLoading,
|
selectLoading,
|
||||||
selectError,
|
selectError,
|
||||||
selectSelectedPriorities,
|
|
||||||
selectSearch,
|
|
||||||
reorderTasks,
|
|
||||||
moveTaskToGroup,
|
|
||||||
moveTaskBetweenGroups,
|
|
||||||
optimisticTaskMove,
|
|
||||||
reorderTasksInGroup,
|
reorderTasksInGroup,
|
||||||
setLoading,
|
|
||||||
setError,
|
|
||||||
setSelectedPriorities,
|
|
||||||
setSearch,
|
|
||||||
resetTaskManagement,
|
|
||||||
toggleTaskExpansion,
|
toggleTaskExpansion,
|
||||||
addSubtaskToParent,
|
|
||||||
fetchTasksV3,
|
fetchTasksV3,
|
||||||
|
selectTaskGroupsV3,
|
||||||
|
fetchSubTasks,
|
||||||
} from '@/features/task-management/task-management.slice';
|
} from '@/features/task-management/task-management.slice';
|
||||||
import {
|
import {
|
||||||
selectCurrentGrouping,
|
selectCurrentGrouping,
|
||||||
selectCollapsedGroups,
|
|
||||||
selectIsGroupCollapsed,
|
|
||||||
toggleGroupCollapsed,
|
|
||||||
expandAllGroups,
|
|
||||||
collapseAllGroups,
|
|
||||||
} from '@/features/task-management/grouping.slice';
|
} from '@/features/task-management/grouping.slice';
|
||||||
import {
|
import {
|
||||||
selectSelectedTaskIds,
|
selectSelectedTaskIds,
|
||||||
selectLastSelectedTaskId,
|
|
||||||
selectIsTaskSelected,
|
|
||||||
selectTask,
|
|
||||||
deselectTask,
|
|
||||||
toggleTaskSelection,
|
|
||||||
selectRange,
|
|
||||||
clearSelection,
|
clearSelection,
|
||||||
|
selectTask,
|
||||||
} from '@/features/task-management/selection.slice';
|
} from '@/features/task-management/selection.slice';
|
||||||
import {
|
import {
|
||||||
selectTasks,
|
selectTasks,
|
||||||
@@ -89,18 +67,11 @@ import {
|
|||||||
IBulkTasksPriorityChangeRequest,
|
IBulkTasksPriorityChangeRequest,
|
||||||
IBulkTasksStatusChangeRequest,
|
IBulkTasksStatusChangeRequest,
|
||||||
} from '@/types/tasks/bulk-action-bar.types';
|
} from '@/types/tasks/bulk-action-bar.types';
|
||||||
import { ITaskStatus } from '@/types/tasks/taskStatus.types';
|
|
||||||
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
|
||||||
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
|
||||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
|
||||||
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||||
import alertService from '@/services/alerts/alertService';
|
import alertService from '@/services/alerts/alertService';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
||||||
import { performanceMonitor } from '@/utils/performance-monitor';
|
|
||||||
import debugPerformance from '@/utils/debug-performance';
|
|
||||||
|
|
||||||
// Import the improved TaskListFilters component synchronously to avoid suspense
|
// Import the improved TaskListFilters component synchronously to avoid suspense
|
||||||
import ImprovedTaskFilters from './improved-task-filters';
|
import ImprovedTaskFilters from './improved-task-filters';
|
||||||
@@ -173,18 +144,11 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
|
|
||||||
// Redux selectors using V3 API (pre-processed data, minimal loops)
|
// Redux selectors using V3 API (pre-processed data, minimal loops)
|
||||||
const tasks = useSelector(selectAllTasks);
|
const tasks = useSelector(selectAllTasks);
|
||||||
const groups = useSelector(selectGroups);
|
|
||||||
const grouping = useSelector(selectGrouping);
|
|
||||||
const loading = useSelector(selectLoading);
|
const loading = useSelector(selectLoading);
|
||||||
const error = useSelector(selectError);
|
const error = useSelector(selectError);
|
||||||
const selectedPriorities = useSelector(selectSelectedPriorities);
|
|
||||||
const searchQuery = useSelector(selectSearch);
|
|
||||||
const taskGroups = useSelector(selectTaskGroupsV3, shallowEqual);
|
const taskGroups = useSelector(selectTaskGroupsV3, shallowEqual);
|
||||||
const currentGrouping = useSelector(selectCurrentGrouping);
|
const currentGrouping = useSelector(selectCurrentGrouping);
|
||||||
const collapsedGroups = useSelector(selectCollapsedGroups);
|
|
||||||
const selectedTaskIds = useSelector(selectSelectedTaskIds);
|
const selectedTaskIds = useSelector(selectSelectedTaskIds);
|
||||||
const lastSelectedTaskId = useSelector(selectLastSelectedTaskId);
|
|
||||||
const selectedTasks = useSelector((state: RootState) => state.bulkActionReducer.selectedTasks);
|
|
||||||
|
|
||||||
// Bulk action selectors
|
// Bulk action selectors
|
||||||
const statusList = useSelector((state: RootState) => state.taskStatusReducer.status);
|
const statusList = useSelector((state: RootState) => state.taskStatusReducer.status);
|
||||||
@@ -202,9 +166,11 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const tasksById = useMemo(() => {
|
const tasksById = useMemo(() => {
|
||||||
const map: Record<string, Task> = {};
|
const map: Record<string, Task> = {};
|
||||||
// Cache all tasks for full functionality - performance optimizations are handled at the virtualization level
|
// Cache all tasks for full functionality - performance optimizations are handled at the virtualization level
|
||||||
tasks.forEach(task => {
|
if (Array.isArray(tasks)) {
|
||||||
|
tasks.forEach((task: Task) => {
|
||||||
map[task.id] = task;
|
map[task.id] = task;
|
||||||
});
|
});
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
@@ -262,14 +228,6 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
|
|
||||||
const hasAnyTasks = useMemo(() => totalTasks > 0, [totalTasks]);
|
const hasAnyTasks = useMemo(() => totalTasks > 0, [totalTasks]);
|
||||||
|
|
||||||
// Memoized handlers for better performance
|
|
||||||
const handleGroupingChange = useCallback(
|
|
||||||
(newGroupBy: 'status' | 'priority' | 'phase') => {
|
|
||||||
dispatch(setCurrentGrouping(newGroupBy));
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add isDragging state
|
// Add isDragging state
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
@@ -280,7 +238,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const taskId = active.id as string;
|
const taskId = active.id as string;
|
||||||
|
|
||||||
// Find the task and its group
|
// Find the task and its group
|
||||||
const activeTask = tasks.find(t => t.id === taskId) || null;
|
const activeTask = Array.isArray(tasks) ? tasks.find((t: Task) => t.id === taskId) || null : null;
|
||||||
let activeGroupId: string | null = null;
|
let activeGroupId: string | null = null;
|
||||||
|
|
||||||
if (activeTask) {
|
if (activeTask) {
|
||||||
@@ -312,7 +270,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const overId = over.id as string;
|
const overId = over.id as string;
|
||||||
|
|
||||||
// Check if we're hovering over a task or a group container
|
// Check if we're hovering over a task or a group container
|
||||||
const targetTask = tasks.find(t => t.id === overId);
|
const targetTask = Array.isArray(tasks) ? tasks.find((t: Task) => t.id === overId) : undefined;
|
||||||
let targetGroupId = overId;
|
let targetGroupId = overId;
|
||||||
|
|
||||||
if (targetTask) {
|
if (targetTask) {
|
||||||
@@ -362,7 +320,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
let targetIndex = -1;
|
let targetIndex = -1;
|
||||||
|
|
||||||
// Check if dropping on a task or a group
|
// Check if dropping on a task or a group
|
||||||
const targetTask = tasks.find(t => t.id === overId);
|
const targetTask = Array.isArray(tasks) ? tasks.find((t: Task) => t.id === overId) : undefined;
|
||||||
if (targetTask) {
|
if (targetTask) {
|
||||||
// Dropping on a task, find which group contains this task
|
// Dropping on a task, find which group contains this task
|
||||||
for (const group of taskGroups) {
|
for (const group of taskGroups) {
|
||||||
@@ -398,13 +356,10 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
// Use the new reorderTasksInGroup action that properly handles group arrays
|
// Use the new reorderTasksInGroup action that properly handles group arrays
|
||||||
dispatch(
|
dispatch(
|
||||||
reorderTasksInGroup({
|
reorderTasksInGroup({
|
||||||
taskId: activeTaskId,
|
sourceTaskId: activeTaskId,
|
||||||
fromGroupId: currentDragState.activeGroupId,
|
destinationTaskId: targetTask?.id || '',
|
||||||
toGroupId: targetGroupId,
|
sourceGroupId: currentDragState.activeGroupId,
|
||||||
fromIndex: sourceIndex,
|
destinationGroupId: targetGroupId,
|
||||||
toIndex: finalTargetIndex,
|
|
||||||
groupType: targetGroup.groupType,
|
|
||||||
groupValue: targetGroup.groupValue,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -448,10 +403,10 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const newSelectedIds = Array.from(currentSelectedIds);
|
const newSelectedIds = Array.from(currentSelectedIds);
|
||||||
|
|
||||||
// Map selected tasks to the required format
|
// Map selected tasks to the required format
|
||||||
const newSelectedTasks = tasks
|
const newSelectedTasks = Array.isArray(tasks) ? tasks
|
||||||
.filter((t) => newSelectedIds.includes(t.id))
|
.filter((t: Task) => newSelectedIds.includes(t.id))
|
||||||
.map(
|
.map(
|
||||||
(task): IProjectTask => ({
|
(task: Task): IProjectTask => ({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
name: task.title,
|
name: task.title,
|
||||||
task_key: task.task_key,
|
task_key: task.task_key,
|
||||||
@@ -463,11 +418,11 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
description: task.description,
|
description: task.description,
|
||||||
start_date: task.startDate,
|
start_date: task.startDate,
|
||||||
end_date: task.dueDate,
|
end_date: task.dueDate,
|
||||||
total_hours: task.timeTracking.estimated || 0,
|
total_hours: task.timeTracking?.estimated || 0,
|
||||||
total_minutes: task.timeTracking.logged || 0,
|
total_minutes: task.timeTracking?.logged || 0,
|
||||||
progress: task.progress,
|
progress: task.progress,
|
||||||
sub_tasks_count: task.sub_tasks_count || 0,
|
sub_tasks_count: task.sub_tasks_count || 0,
|
||||||
assignees: task.assignees.map((assigneeId) => ({
|
assignees: task.assignees?.map((assigneeId: string) => ({
|
||||||
id: assigneeId,
|
id: assigneeId,
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -477,15 +432,16 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
})),
|
})),
|
||||||
labels: task.labels,
|
labels: task.labels,
|
||||||
manual_progress: false,
|
manual_progress: false,
|
||||||
created_at: task.createdAt,
|
created_at: (task as any).createdAt || (task as any).created_at,
|
||||||
updated_at: task.updatedAt,
|
updated_at: (task as any).updatedAt || (task as any).updated_at,
|
||||||
sort_order: task.order,
|
sort_order: task.order,
|
||||||
})
|
})
|
||||||
);
|
) : [];
|
||||||
|
|
||||||
// Dispatch both actions to update the Redux state
|
// Dispatch both actions to update the Redux state
|
||||||
dispatch(selectTasks(newSelectedTasks));
|
dispatch(selectTasks(newSelectedTasks));
|
||||||
dispatch(selectTaskIds(newSelectedIds));
|
// Update selection state with the new task IDs
|
||||||
|
newSelectedIds.forEach(taskId => dispatch(selectTask(taskId)));
|
||||||
},
|
},
|
||||||
[dispatch, selectedTaskIds, tasks]
|
[dispatch, selectedTaskIds, tasks]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -615,9 +615,8 @@ export const useTaskSocketHandlers = () => {
|
|||||||
estimated: (data.total_hours || 0) + (data.total_minutes || 0) / 60,
|
estimated: (data.total_hours || 0) + (data.total_minutes || 0) / 60,
|
||||||
logged: (data.time_spent?.hours || 0) + (data.time_spent?.minutes || 0) / 60,
|
logged: (data.time_spent?.hours || 0) + (data.time_spent?.minutes || 0) / 60,
|
||||||
},
|
},
|
||||||
customFields: {},
|
created_at: data.created_at || new Date().toISOString(),
|
||||||
createdAt: data.created_at || new Date().toISOString(),
|
updated_at: data.updated_at || new Date().toISOString(),
|
||||||
updatedAt: data.updated_at || new Date().toISOString(),
|
|
||||||
order: data.sort_order || 0,
|
order: data.sort_order || 0,
|
||||||
parent_task_id: data.parent_task_id,
|
parent_task_id: data.parent_task_id,
|
||||||
is_sub_task: true,
|
is_sub_task: true,
|
||||||
@@ -634,7 +633,7 @@ export const useTaskSocketHandlers = () => {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Handle regular task creation - transform to Task format and add
|
// Handle regular task creation - transform to Task format and add
|
||||||
const task = {
|
const task: Task = {
|
||||||
id: data.id || '',
|
id: data.id || '',
|
||||||
task_key: data.task_key || '',
|
task_key: data.task_key || '',
|
||||||
title: data.name || '',
|
title: data.name || '',
|
||||||
@@ -666,14 +665,17 @@ export const useTaskSocketHandlers = () => {
|
|||||||
names: l.names,
|
names: l.names,
|
||||||
})) || [],
|
})) || [],
|
||||||
dueDate: data.end_date,
|
dueDate: data.end_date,
|
||||||
|
startDate: data.start_date,
|
||||||
timeTracking: {
|
timeTracking: {
|
||||||
estimated: (data.total_hours || 0) + (data.total_minutes || 0) / 60,
|
estimated: (data.total_hours || 0) + (data.total_minutes || 0) / 60,
|
||||||
logged: (data.time_spent?.hours || 0) + (data.time_spent?.minutes || 0) / 60,
|
logged: (data.time_spent?.hours || 0) + (data.time_spent?.minutes || 0) / 60,
|
||||||
},
|
},
|
||||||
customFields: {},
|
created_at: data.created_at || new Date().toISOString(),
|
||||||
createdAt: data.created_at || new Date().toISOString(),
|
updated_at: data.updated_at || new Date().toISOString(),
|
||||||
updatedAt: data.updated_at || new Date().toISOString(),
|
|
||||||
order: data.sort_order || 0,
|
order: data.sort_order || 0,
|
||||||
|
sub_tasks: [],
|
||||||
|
sub_tasks_count: 0,
|
||||||
|
show_sub_tasks: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Extract the group UUID from the backend response based on current grouping
|
// Extract the group UUID from the backend response based on current grouping
|
||||||
|
|||||||
Reference in New Issue
Block a user