- Improved the access check logic by incorporating team member validation in the SQL query, enhancing security and accuracy. - Removed unnecessary whitespace for cleaner code formatting. - Updated socket event emission for consistency in response structure.
493 lines
16 KiB
TypeScript
493 lines
16 KiB
TypeScript
import React, { useEffect, useState, useMemo } from 'react';
|
|
import { useSelector, useDispatch } from 'react-redux';
|
|
import { Card, Spin, Empty } from 'antd';
|
|
import {
|
|
DndContext,
|
|
DragOverlay,
|
|
DragStartEvent,
|
|
DragEndEvent,
|
|
DragOverEvent,
|
|
KeyboardSensor,
|
|
PointerSensor,
|
|
useSensor,
|
|
useSensors,
|
|
UniqueIdentifier,
|
|
getFirstCollision,
|
|
pointerWithin,
|
|
rectIntersection,
|
|
} from '@dnd-kit/core';
|
|
import {
|
|
SortableContext,
|
|
horizontalListSortingStrategy,
|
|
} from '@dnd-kit/sortable';
|
|
import { RootState } from '@/app/store';
|
|
import {
|
|
fetchEnhancedKanbanGroups,
|
|
reorderEnhancedKanbanTasks,
|
|
reorderEnhancedKanbanGroups,
|
|
setDragState,
|
|
reorderTasks,
|
|
reorderGroups,
|
|
fetchEnhancedKanbanTaskAssignees,
|
|
fetchEnhancedKanbanLabels,
|
|
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
|
import EnhancedKanbanGroup from './EnhancedKanbanGroup';
|
|
import './EnhancedKanbanBoard.css';
|
|
import { useSocket } from '@/socket/socketContext';
|
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
|
import { SocketEvents } from '@/shared/socket-events';
|
|
import logger from '@/utils/errorLogger';
|
|
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
|
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
|
|
import alertService from '@/services/alerts/alertService';
|
|
import { IGroupBy } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
|
import EnhancedKanbanCreateSection from './EnhancedKanbanCreateSection';
|
|
import ImprovedTaskFilters from '../task-management/improved-task-filters';
|
|
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
|
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
|
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
|
import { useAuthService } from '@/hooks/useAuth';
|
|
|
|
// Import the TaskListFilters component
|
|
const TaskListFilters = React.lazy(() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters'));
|
|
interface EnhancedKanbanBoardProps {
|
|
projectId: string;
|
|
className?: string;
|
|
}
|
|
|
|
const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, className = '' }) => {
|
|
const dispatch = useDispatch();
|
|
const {
|
|
taskGroups,
|
|
loadingGroups,
|
|
error,
|
|
dragState,
|
|
performanceMetrics
|
|
} = useSelector((state: RootState) => state.enhancedKanbanReducer);
|
|
const { socket } = useSocket();
|
|
const authService = useAuthService();
|
|
const teamId = authService.getCurrentSession()?.team_id;
|
|
const groupBy = useSelector((state: RootState) => state.enhancedKanbanReducer.groupBy);
|
|
const project = useAppSelector((state: RootState) => state.projectReducer.project);
|
|
const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer);
|
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
|
|
|
// Load filter data
|
|
useFilterDataLoader();
|
|
|
|
// Set up socket event handlers for real-time updates
|
|
useTaskSocketHandlers();
|
|
|
|
// Local state for drag overlay
|
|
const [activeTask, setActiveTask] = useState<any>(null);
|
|
const [activeGroup, setActiveGroup] = useState<any>(null);
|
|
const [overId, setOverId] = useState<UniqueIdentifier | null>(null);
|
|
|
|
// Sensors for drag and drop
|
|
const sensors = useSensors(
|
|
useSensor(PointerSensor, {
|
|
activationConstraint: {
|
|
distance: 8,
|
|
},
|
|
}),
|
|
useSensor(KeyboardSensor)
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (projectId) {
|
|
dispatch(fetchEnhancedKanbanGroups(projectId) as any);
|
|
// Load filter data for enhanced kanban
|
|
dispatch(fetchEnhancedKanbanTaskAssignees(projectId) as any);
|
|
dispatch(fetchEnhancedKanbanLabels(projectId) as any);
|
|
}
|
|
if (!statusCategories.length) {
|
|
dispatch(fetchStatusesCategories() as any);
|
|
}
|
|
}, [dispatch, projectId]);
|
|
|
|
// Get all task IDs for sortable context
|
|
const allTaskIds = useMemo(() =>
|
|
taskGroups.flatMap(group => group.tasks.map(task => task.id!)),
|
|
[taskGroups]
|
|
);
|
|
const allGroupIds = useMemo(() =>
|
|
taskGroups.map(group => group.id),
|
|
[taskGroups]
|
|
);
|
|
|
|
// Enhanced collision detection
|
|
const collisionDetectionStrategy = (args: any) => {
|
|
// First, let's see if we're colliding with any droppable areas
|
|
const pointerIntersections = pointerWithin(args);
|
|
const intersections = pointerIntersections.length > 0
|
|
? pointerIntersections
|
|
: rectIntersection(args);
|
|
|
|
let overId = getFirstCollision(intersections, 'id');
|
|
|
|
if (overId) {
|
|
// Check if we're over a task or a group
|
|
const overGroup = taskGroups.find(g => g.id === overId);
|
|
|
|
if (overGroup) {
|
|
// We're over a group, check if there are tasks in it
|
|
if (overGroup.tasks.length > 0) {
|
|
// Find the closest task within this group
|
|
const taskIntersections = pointerWithin({
|
|
...args,
|
|
droppableContainers: args.droppableContainers.filter(
|
|
(container: any) => container.data.current?.type === 'task'
|
|
),
|
|
});
|
|
|
|
if (taskIntersections.length > 0) {
|
|
overId = taskIntersections[0].id;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return overId ? [{ id: overId }] : [];
|
|
};
|
|
|
|
const handleDragStart = (event: DragStartEvent) => {
|
|
const { active } = event;
|
|
const activeId = active.id as string;
|
|
const activeData = active.data.current;
|
|
|
|
// Check if dragging a group or a task
|
|
if (activeData?.type === 'group') {
|
|
// Dragging a group
|
|
const foundGroup = taskGroups.find(g => g.id === activeId);
|
|
setActiveGroup(foundGroup);
|
|
setActiveTask(null);
|
|
|
|
dispatch(setDragState({
|
|
activeTaskId: null,
|
|
activeGroupId: activeId,
|
|
isDragging: true,
|
|
}));
|
|
} else {
|
|
// Dragging a task
|
|
let foundTask = null;
|
|
let foundGroup = null;
|
|
|
|
for (const group of taskGroups) {
|
|
const task = group.tasks.find(t => t.id === activeId);
|
|
if (task) {
|
|
foundTask = task;
|
|
foundGroup = group;
|
|
break;
|
|
}
|
|
}
|
|
|
|
setActiveTask(foundTask);
|
|
setActiveGroup(null);
|
|
|
|
dispatch(setDragState({
|
|
activeTaskId: activeId,
|
|
activeGroupId: foundGroup?.id || null,
|
|
isDragging: true,
|
|
}));
|
|
}
|
|
};
|
|
|
|
const handleDragOver = (event: DragOverEvent) => {
|
|
const { active, over } = event;
|
|
|
|
if (!over) {
|
|
setOverId(null);
|
|
dispatch(setDragState({ overId: null }));
|
|
return;
|
|
}
|
|
|
|
const activeId = active.id as string;
|
|
const overId = over.id as string;
|
|
|
|
setOverId(overId);
|
|
|
|
// Update over ID in Redux
|
|
dispatch(setDragState({ overId }));
|
|
};
|
|
|
|
const handleDragEnd = async (event: DragEndEvent) => {
|
|
const { active, over } = event;
|
|
const activeData = active.data.current;
|
|
|
|
// Reset local state
|
|
setActiveTask(null);
|
|
setActiveGroup(null);
|
|
setOverId(null);
|
|
|
|
// Reset Redux drag state
|
|
dispatch(setDragState({
|
|
activeTaskId: null,
|
|
activeGroupId: null,
|
|
overId: null,
|
|
isDragging: false,
|
|
}));
|
|
|
|
if (!over) return;
|
|
|
|
const activeId = active.id as string;
|
|
const overId = over.id as string;
|
|
|
|
// Handle group (column) reordering
|
|
if (activeData?.type === 'group') {
|
|
// Don't allow reordering if groupBy is phases
|
|
if (groupBy === IGroupBy.PHASE) {
|
|
return;
|
|
}
|
|
|
|
const fromIndex = taskGroups.findIndex(g => g.id === activeId);
|
|
const toIndex = taskGroups.findIndex(g => g.id === overId);
|
|
|
|
if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) {
|
|
// Create new array with reordered groups
|
|
const reorderedGroups = [...taskGroups];
|
|
const [movedGroup] = reorderedGroups.splice(fromIndex, 1);
|
|
reorderedGroups.splice(toIndex, 0, movedGroup);
|
|
|
|
// Synchronous UI update for immediate feedback
|
|
dispatch(reorderGroups({ fromIndex, toIndex, reorderedGroups }));
|
|
dispatch(reorderEnhancedKanbanGroups({ fromIndex, toIndex, reorderedGroups }) as any);
|
|
|
|
// Prepare column order for API
|
|
const columnOrder = reorderedGroups.map(group => group.id);
|
|
|
|
// Call API to update status order
|
|
try {
|
|
const requestBody: ITaskStatusCreateRequest = {
|
|
status_order: columnOrder
|
|
};
|
|
|
|
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
|
|
if (!response.done) {
|
|
// Revert the change if API call fails
|
|
const revertedGroups = [...reorderedGroups];
|
|
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
|
|
revertedGroups.splice(fromIndex, 0, movedBackGroup);
|
|
dispatch(reorderGroups({ fromIndex: toIndex, toIndex: fromIndex, reorderedGroups: revertedGroups }));
|
|
alertService.error('Failed to update column order', 'Please try again');
|
|
}
|
|
} catch (error) {
|
|
// Revert the change if API call fails
|
|
const revertedGroups = [...reorderedGroups];
|
|
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
|
|
revertedGroups.splice(fromIndex, 0, movedBackGroup);
|
|
dispatch(reorderGroups({ fromIndex: toIndex, toIndex: fromIndex, reorderedGroups: revertedGroups }));
|
|
alertService.error('Failed to update column order', 'Please try again');
|
|
logger.error('Failed to update column order', error);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Handle task reordering (within or between groups)
|
|
let sourceGroup = null;
|
|
let targetGroup = null;
|
|
let sourceIndex = -1;
|
|
let targetIndex = -1;
|
|
|
|
// Find source group and index
|
|
for (const group of taskGroups) {
|
|
const taskIndex = group.tasks.findIndex(t => t.id === activeId);
|
|
if (taskIndex !== -1) {
|
|
sourceGroup = group;
|
|
sourceIndex = taskIndex;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// Find target group and index
|
|
for (const group of taskGroups) {
|
|
const taskIndex = group.tasks.findIndex(t => t.id === overId);
|
|
if (taskIndex !== -1) {
|
|
targetGroup = group;
|
|
targetIndex = taskIndex;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// If dropping on a group (not a task)
|
|
if (!targetGroup) {
|
|
targetGroup = taskGroups.find(g => g.id === overId);
|
|
if (targetGroup) {
|
|
targetIndex = targetGroup.tasks.length; // Add to end of group
|
|
}
|
|
}
|
|
|
|
if (!sourceGroup || !targetGroup || sourceIndex === -1) return;
|
|
|
|
// Don't do anything if dropping in the same position
|
|
if (sourceGroup.id === targetGroup.id && sourceIndex === targetIndex) return;
|
|
|
|
// Create updated task arrays
|
|
const updatedSourceTasks = [...sourceGroup.tasks];
|
|
const [movedTask] = updatedSourceTasks.splice(sourceIndex, 1);
|
|
|
|
let updatedTargetTasks: any[];
|
|
if (sourceGroup.id === targetGroup.id) {
|
|
// Moving within the same group
|
|
updatedTargetTasks = updatedSourceTasks;
|
|
updatedTargetTasks.splice(targetIndex, 0, movedTask);
|
|
} else {
|
|
// Moving between different groups
|
|
updatedTargetTasks = [...targetGroup.tasks];
|
|
updatedTargetTasks.splice(targetIndex, 0, movedTask);
|
|
}
|
|
|
|
// Synchronous UI update
|
|
dispatch(reorderTasks({
|
|
activeGroupId: sourceGroup.id,
|
|
overGroupId: targetGroup.id,
|
|
fromIndex: sourceIndex,
|
|
toIndex: targetIndex,
|
|
task: movedTask,
|
|
updatedSourceTasks,
|
|
updatedTargetTasks,
|
|
}));
|
|
dispatch(reorderEnhancedKanbanTasks({
|
|
activeGroupId: sourceGroup.id,
|
|
overGroupId: targetGroup.id,
|
|
fromIndex: sourceIndex,
|
|
toIndex: targetIndex,
|
|
task: movedTask,
|
|
updatedSourceTasks,
|
|
updatedTargetTasks,
|
|
}) as any);
|
|
|
|
// --- Socket emit for task sort order ---
|
|
if (socket && projectId && movedTask) {
|
|
// Find sort_order for from and to
|
|
const fromSortOrder = movedTask.sort_order;
|
|
let toSortOrder = -1;
|
|
let toLastIndex = false;
|
|
if (targetIndex === targetGroup.tasks.length) {
|
|
// Dropping at the end
|
|
toSortOrder = -1;
|
|
toLastIndex = true;
|
|
} else if (targetGroup.tasks[targetIndex]) {
|
|
toSortOrder = typeof targetGroup.tasks[targetIndex].sort_order === 'number' ? targetGroup.tasks[targetIndex].sort_order! : -1;
|
|
toLastIndex = false;
|
|
} else if (targetGroup.tasks.length > 0) {
|
|
const lastSortOrder = targetGroup.tasks[targetGroup.tasks.length - 1].sort_order;
|
|
toSortOrder = typeof lastSortOrder === 'number' ? lastSortOrder! : -1;
|
|
toLastIndex = false;
|
|
}
|
|
const body = {
|
|
project_id: projectId,
|
|
from_index: fromSortOrder,
|
|
to_index: toSortOrder,
|
|
to_last_index: toLastIndex,
|
|
from_group: sourceGroup.id,
|
|
to_group: targetGroup.id,
|
|
group_by: groupBy || 'status',
|
|
task: movedTask,
|
|
team_id: teamId || project?.team_id || '',
|
|
};
|
|
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
|
|
}
|
|
};
|
|
|
|
if (error) {
|
|
return (
|
|
<Card className={className}>
|
|
<Empty description={`Error loading tasks: ${error}`} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* Task Filters */}
|
|
<div className="mb-4">
|
|
<React.Suspense fallback={<div>Loading filters...</div>}>
|
|
<ImprovedTaskFilters position="board" />
|
|
</React.Suspense>
|
|
</div>
|
|
<div className={`enhanced-kanban-board ${className}`}>
|
|
{/* Performance Monitor - only show for large datasets */}
|
|
{/* {performanceMetrics.totalTasks > 100 && <PerformanceMonitor />} */}
|
|
|
|
{loadingGroups ? (
|
|
<Card>
|
|
<div className="flex justify-center items-center py-8">
|
|
<Spin size="large" />
|
|
</div>
|
|
</Card>
|
|
) : taskGroups.length === 0 ? (
|
|
<Card>
|
|
<Empty description="No tasks found" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
|
</Card>
|
|
) : (
|
|
<DndContext
|
|
sensors={sensors}
|
|
collisionDetection={collisionDetectionStrategy}
|
|
onDragStart={handleDragStart}
|
|
onDragOver={handleDragOver}
|
|
onDragEnd={handleDragEnd}
|
|
>
|
|
<SortableContext items={allGroupIds} strategy={horizontalListSortingStrategy}>
|
|
<div className="kanban-groups-container">
|
|
{taskGroups.map(group => (
|
|
<EnhancedKanbanGroup
|
|
key={group.id}
|
|
group={group}
|
|
activeTaskId={dragState.activeTaskId}
|
|
overId={overId as string | null}
|
|
/>
|
|
))}
|
|
<EnhancedKanbanCreateSection />
|
|
</div>
|
|
</SortableContext>
|
|
|
|
<DragOverlay>
|
|
{activeTask && (
|
|
<div
|
|
style={{
|
|
background: themeMode === 'dark' ? '#23272f' : '#fff',
|
|
borderRadius: 8,
|
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
|
padding: '12px 20px',
|
|
minWidth: 180,
|
|
maxWidth: 340,
|
|
opacity: 0.95,
|
|
fontWeight: 600,
|
|
fontSize: 16,
|
|
color: themeMode === 'dark' ? '#fff' : '#23272f',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
}}
|
|
>
|
|
{activeTask.name}
|
|
</div>
|
|
)}
|
|
{activeGroup && (
|
|
<div
|
|
style={{
|
|
background: themeMode === 'dark' ? '#23272f' : '#fff',
|
|
borderRadius: 8,
|
|
boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
|
|
padding: '16px 24px',
|
|
minWidth: 220,
|
|
maxWidth: 320,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: 12,
|
|
opacity: 0.95,
|
|
}}
|
|
>
|
|
<h3 style={{ margin: 0, fontWeight: 600, fontSize: 18 }}>{activeGroup.name}</h3>
|
|
<span style={{ fontSize: 15, color: '#888' }}>({activeGroup.tasks.length})</span>
|
|
</div>
|
|
)}
|
|
</DragOverlay>
|
|
</DndContext>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default EnhancedKanbanBoard;
|