diff --git a/worklenz-frontend/src/components/kanban-board-management-v2/SortableKanbanGroup.tsx b/worklenz-frontend/src/components/kanban-board-management-v2/SortableKanbanGroup.tsx new file mode 100644 index 00000000..d1f41391 --- /dev/null +++ b/worklenz-frontend/src/components/kanban-board-management-v2/SortableKanbanGroup.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import KanbanGroup from './kanbanGroup'; +import { ITaskListGroup } from '@/types/tasks/taskList.types'; +import { IGroupBy } from '@/features/tasks/tasks.slice'; + +interface SortableKanbanGroupProps { + group: ITaskListGroup; + projectId: string; + currentGrouping: IGroupBy; + selectedTaskIds: string[]; + onAddTask?: (groupId: string) => void; + onToggleCollapse?: (groupId: string) => void; + onSelectTask?: (taskId: string, selected: boolean) => void; + onToggleSubtasks?: (taskId: string) => void; + activeTaskId?: string | null; +} + +const SortableKanbanGroup: React.FC = (props) => { + const { group, activeTaskId } = props; + const { + setNodeRef, + attributes, + listeners, + transform, + transition, + isDragging, + } = useSortable({ + id: group.id, + data: { type: 'group', groupId: group.id }, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + zIndex: isDragging ? 10 : undefined, + }; + + return ( +
+ +
+ ); +}; + +export default SortableKanbanGroup; \ No newline at end of file diff --git a/worklenz-frontend/src/components/kanban-board-management-v2/kanbanGroup.tsx b/worklenz-frontend/src/components/kanban-board-management-v2/kanbanGroup.tsx new file mode 100644 index 00000000..6b5fe9f1 --- /dev/null +++ b/worklenz-frontend/src/components/kanban-board-management-v2/kanbanGroup.tsx @@ -0,0 +1,228 @@ +import React, { useState } from 'react'; +import { useDroppable } from '@dnd-kit/core'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { Button, Typography } from 'antd'; +import { PlusOutlined, MenuOutlined } from '@ant-design/icons'; +import { ITaskListGroup } from '@/types/tasks/taskList.types'; +import { IGroupBy } from '@/features/tasks/tasks.slice'; +import KanbanTaskCard from './kanbanTaskCard'; + +const { Text } = Typography; + +interface TaskGroupProps { + group: ITaskListGroup; + projectId: string; + currentGrouping: IGroupBy; + selectedTaskIds: string[]; + onAddTask?: (groupId: string) => void; + onToggleCollapse?: (groupId: string) => void; + onSelectTask?: (taskId: string, selected: boolean) => void; + onToggleSubtasks?: (taskId: string) => void; + dragHandleProps?: any; + activeTaskId?: string | null; +} + +const KanbanGroup: React.FC = ({ + group, + projectId, + currentGrouping, + selectedTaskIds, + onAddTask, + onToggleCollapse, + onSelectTask, + onToggleSubtasks, + dragHandleProps, + activeTaskId, +}) => { + const [isCollapsed, setIsCollapsed] = useState(false); + const { setNodeRef, isOver } = useDroppable({ + id: group.id, + data: { + type: 'group', + groupId: group.id, + }, + }); + + // Get task IDs for sortable context + const taskIds = group.tasks.map(task => task.id!); + + // Get group color based on grouping type + const getGroupColor = () => { + if (group.color_code) return group.color_code; + switch (currentGrouping) { + case 'status': + return group.id === 'todo' ? '#faad14' : group.id === 'doing' ? '#1890ff' : '#52c41a'; + case 'priority': + return group.id === 'critical' + ? '#ff4d4f' + : group.id === 'high' + ? '#fa8c16' + : group.id === 'medium' + ? '#faad14' + : '#52c41a'; + case 'phase': + return '#722ed1'; + default: + return '#d9d9d9'; + } + }; + + const handleAddTask = () => { + onAddTask?.(group.id); + }; + + return ( +
+ {/* Group Header */} +
+ {/* Drag handle for column */} +
+ + {/* Tasks as Cards */} + +
+ {group.tasks.length === 0 ? ( +
+ No tasks in this group +
+ ) : ( + group.tasks.map((task, index) => ( + task.id === activeTaskId ? ( +
+ ) : ( + + ) + )) + )} +
+ + + {/* Add Task Button */} +
+ +
+ + +
+ ); +}; + +export default KanbanGroup; diff --git a/worklenz-frontend/src/components/kanban-board-management-v2/kanbanTaskCard.tsx b/worklenz-frontend/src/components/kanban-board-management-v2/kanbanTaskCard.tsx new file mode 100644 index 00000000..db4ff780 --- /dev/null +++ b/worklenz-frontend/src/components/kanban-board-management-v2/kanbanTaskCard.tsx @@ -0,0 +1,401 @@ +import React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { Avatar, Tag, Progress, Typography, Button, Tooltip, Space } from 'antd'; +import { + HolderOutlined, + MessageOutlined, + PaperClipOutlined, + ClockCircleOutlined, +} from '@ant-design/icons'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { IGroupBy } from '@/features/tasks/tasks.slice'; + +const { Text } = Typography; + +interface TaskRowProps { + task: IProjectTask; + projectId: string; + groupId: string; + currentGrouping: IGroupBy; + isSelected: boolean; + isDragOverlay?: boolean; + index?: number; + onSelect?: (taskId: string, selected: boolean) => void; + onToggleSubtasks?: (taskId: string) => void; +} + +const KanbanTaskCard: React.FC = ({ + task, + projectId, + groupId, + currentGrouping, + isSelected, + isDragOverlay = false, + index, + onSelect, + onToggleSubtasks, +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: task.id!, + data: { + type: 'task', + taskId: task.id, + groupId, + }, + disabled: isDragOverlay, + }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + // Format due date + const formatDueDate = (dateString?: string) => { + if (!dateString) return null; + const date = new Date(dateString); + const now = new Date(); + const diffTime = date.getTime() - now.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + if (diffDays < 0) { + return { text: `${Math.abs(diffDays)}d overdue`, color: 'error' }; + } else if (diffDays === 0) { + return { text: 'Due today', color: 'warning' }; + } else if (diffDays <= 3) { + return { text: `Due in ${diffDays}d`, color: 'warning' }; + } else { + return { text: `Due ${date.toLocaleDateString()}`, color: 'default' }; + } + }; + const dueDate = formatDueDate(task.end_date); + + return ( +
+
+ + )} +
+
+ + {/* Task Key and Status */} +
+ {task.task_key && ( + {task.task_key} + )} + {task.status_name && ( + + {task.status_name} + + )} + {task.priority_name && ( + + {task.priority_name} + + )} +
+ {/* Progress and Due Date */} +
+ {typeof task.complete_ratio === 'number' && ( + + )} + {dueDate && ( + + + {dueDate.text} + + )} +
+ {/* Assignees and Labels */} +
+ {task.assignees && task.assignees.length > 0 && ( + + {task.assignees.map((assignee) => ( + + {assignee.name?.charAt(0)?.toUpperCase()} + + ))} + + )} + {task.labels && task.labels.length > 0 && ( +
+ {task.labels.slice(0, 2).map((label) => ( + + {label.name} + + ))} + {task.labels.length > 2 && ( + + +{task.labels.length - 2} + + )} +
+ )} +
+ {/* Indicators */} +
+ {task.time_spent_string && ( + + {task.time_spent_string} + + )} + {task.comments_count && task.comments_count > 0 && ( + + {task.comments_count} + + )} + {task.attachments_count && task.attachments_count > 0 && ( + + {task.attachments_count} + + )} +
+
+
+ {/* Subtasks */} + {task.show_sub_tasks && task.sub_tasks && task.sub_tasks.length > 0 && ( +
+ {task.sub_tasks.map((subtask) => ( + + ))} +
+ )} + +
+ ); +}; + +export default KanbanTaskCard; \ No newline at end of file diff --git a/worklenz-frontend/src/components/kanban-board-management-v2/kanbanTaskListBoard.tsx b/worklenz-frontend/src/components/kanban-board-management-v2/kanbanTaskListBoard.tsx new file mode 100644 index 00000000..a6361a54 --- /dev/null +++ b/worklenz-frontend/src/components/kanban-board-management-v2/kanbanTaskListBoard.tsx @@ -0,0 +1,412 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { + DndContext, + DragOverlay, + DragStartEvent, + DragEndEvent, + DragOverEvent, + closestCorners, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + horizontalListSortingStrategy, + SortableContext, + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable'; +import { Card, Spin, Empty, Flex } from 'antd'; +import { RootState } from '@/app/store'; +import { + IGroupBy, + setGroup, + fetchTaskGroups, + reorderTasks, +} from '@/features/tasks/tasks.slice'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { AppDispatch } from '@/app/store'; +import BoardSectionCard from '@/pages/projects/projectView/board/board-section/board-section-card/board-section-card'; +import BoardCreateSectionCard from '@/pages/projects/projectView/board/board-section/board-section-card/board-create-section-card'; +import { useAuthService } from '@/hooks/useAuth'; +import useIsProjectManager from '@/hooks/useIsProjectManager'; +import BoardViewTaskCard from '@/pages/projects/projectView/board/board-section/board-task-card/board-view-task-card'; +import TaskGroup from '../task-management/TaskGroup'; +import TaskRow from '../task-management/TaskRow'; +import KanbanGroup from './kanbanGroup'; +import KanbanTaskCard from './kanbanTaskCard'; +import SortableKanbanGroup from './SortableKanbanGroup'; + + +// Import the TaskListFilters component +const TaskListFilters = React.lazy(() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters')); + +interface TaskListBoardProps { + projectId: string; + className?: string; +} + +interface DragState { + activeTask: IProjectTask | null; + activeGroupId: string | null; +} + +const KanbanTaskListBoard: React.FC = ({ projectId, className = '' }) => { + const dispatch = useDispatch(); + const [dragState, setDragState] = useState({ + activeTask: null, + activeGroupId: null, + }); + // New state for active/over ids + const [activeTaskId, setActiveTaskId] = useState(null); + const [overId, setOverId] = useState(null); + + // Redux selectors + + const { taskGroups, groupBy, loadingGroups, error, search, archived } = useSelector((state: RootState) => state.boardReducer); + + // Selection state + const [selectedTaskIds, setSelectedTaskIds] = useState([]); + + // Drag and Drop sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + const isOwnerorAdmin = useAuthService().isOwnerOrAdmin(); + const isProjectManager = useIsProjectManager(); + + // Fetch task groups when component mounts or dependencies change + useEffect(() => { + if (projectId) { + dispatch(fetchTaskGroups(projectId)); + } + }, [dispatch, projectId, groupBy, search, archived]); + + // Memoized calculations + const allTaskIds = useMemo(() => { + return taskGroups.flatMap(group => group.tasks.map(task => task.id!)); + }, [taskGroups]); + + const totalTasksCount = useMemo(() => { + return taskGroups.reduce((sum, group) => sum + group.tasks.length, 0); + }, [taskGroups]); + + const hasSelection = selectedTaskIds.length > 0; + + // // Handlers + // const handleGroupingChange = (newGroupBy: IGroupBy) => { + // dispatch(setGroup(newGroupBy)); + // }; + + const handleDragStart = (event: DragStartEvent) => { + const { active } = event; + const taskId = active.id as string; + setActiveTaskId(taskId); + setOverId(null); + // Find the task and its group + let activeTask: IProjectTask | null = null; + let activeGroupId: string | null = null; + for (const group of taskGroups) { + const task = group.tasks.find(t => t.id === taskId); + if (task) { + activeTask = task; + activeGroupId = group.id; + break; + } + } + setDragState({ + activeTask, + activeGroupId, + }); + }; + + const handleDragOver = (event: DragOverEvent) => { + setOverId(event.over?.id as string || null); + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + setActiveTaskId(null); + setOverId(null); + setDragState({ + activeTask: null, + activeGroupId: null, + }); + if (!over || !dragState.activeTask || !dragState.activeGroupId) { + return; + } + const activeTaskId = active.id as string; + const overIdVal = over.id as string; + // Find the group and index for drop + let targetGroupId = overIdVal; + let targetIndex = -1; + let isOverTask = false; + // Check if over is a group or a task + const overGroup = taskGroups.find(g => g.id === overIdVal); + if (!overGroup) { + // Dropping on a task, find which group it belongs to + for (const group of taskGroups) { + const taskIndex = group.tasks.findIndex(t => t.id === overIdVal); + if (taskIndex !== -1) { + targetGroupId = group.id; + targetIndex = taskIndex; + isOverTask = true; + break; + } + } + } + const sourceGroup = taskGroups.find(g => g.id === dragState.activeGroupId); + const targetGroup = taskGroups.find(g => g.id === targetGroupId); + if (!sourceGroup || !targetGroup) return; + const sourceIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId); + if (sourceIndex === -1) return; + // Calculate new positions + let finalTargetIndex = targetIndex; + if (!isOverTask || finalTargetIndex === -1) { + finalTargetIndex = targetGroup.tasks.length; + } + // If moving within the same group and after itself, adjust index + if (sourceGroup.id === targetGroup.id && sourceIndex < finalTargetIndex) { + finalTargetIndex--; + } + // Create updated task arrays + const updatedSourceTasks = [...sourceGroup.tasks]; + const [movedTask] = updatedSourceTasks.splice(sourceIndex, 1); + let updatedTargetTasks: IProjectTask[]; + if (sourceGroup.id === targetGroup.id) { + updatedTargetTasks = updatedSourceTasks; + updatedTargetTasks.splice(finalTargetIndex, 0, movedTask); + } else { + updatedTargetTasks = [...targetGroup.tasks]; + updatedTargetTasks.splice(finalTargetIndex, 0, movedTask); + } + // Dispatch the reorder action + dispatch(reorderTasks({ + activeGroupId: sourceGroup.id, + overGroupId: targetGroup.id, + fromIndex: sourceIndex, + toIndex: finalTargetIndex, + task: movedTask, + updatedSourceTasks, + updatedTargetTasks, + })); + }; + + + + const handleSelectTask = (taskId: string, selected: boolean) => { + setSelectedTaskIds(prev => { + if (selected) { + return [...prev, taskId]; + } else { + return prev.filter(id => id !== taskId); + } + }); + }; + + const handleToggleSubtasks = (taskId: string) => { + // Implementation for toggling subtasks + console.log('Toggle subtasks for task:', taskId); + }; + + if (error) { + return ( + + + + ); + } + + return ( +
+ {/* Task Filters */} + + Loading filters...
}> + + + + + + {/* Task Groups Container */} +
+ {loadingGroups ? ( + +
+ +
+
+ ) : taskGroups.length === 0 ? ( + + + + ) : ( + + g.id)} + strategy={horizontalListSortingStrategy} + > +
+ {taskGroups.map((group) => ( + + ))} +
+
+ + {dragState.activeTask ? ( + + ) : null} + +
+ )} +
+ + +
+ ); +}; + +export default KanbanTaskListBoard; \ No newline at end of file diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index 3957b42e..63416132 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -6,6 +6,7 @@ import ProjectViewUpdates from '@/pages/projects/project-view-1/updates/project- import ProjectViewTaskList from '@/pages/projects/projectView/taskList/project-view-task-list'; import ProjectViewBoard from '@/pages/projects/projectView/board/project-view-board'; import ProjectViewEnhancedTasks from '@/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks'; +import ProjectViewEnhancedBoard from '@/pages/projects/projectView/enhancedBoard/project-view-enhanced-board'; // type of a tab items type TabItems = { @@ -37,6 +38,13 @@ export const tabItems: TabItems[] = [ key: 'board', label: 'Board', isPinned: true, + element: React.createElement(ProjectViewEnhancedBoard), + }, + { + index: 3, + key: 'board-v1', + label: 'Board v1', + isPinned: true, element: React.createElement(ProjectViewBoard), }, // { diff --git a/worklenz-frontend/src/pages/projects/projectView/enhancedBoard/project-view-enhanced-board.tsx b/worklenz-frontend/src/pages/projects/projectView/enhancedBoard/project-view-enhanced-board.tsx new file mode 100644 index 00000000..c10cd838 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/enhancedBoard/project-view-enhanced-board.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import TaskListBoard from '@/components/task-management/TaskListBoard'; +import KanbanTaskListBoard from '@/components/kanban-board-management-v2/kanbanTaskListBoard'; + +const ProjectViewEnhancedBoard: React.FC = () => { + const { project } = useAppSelector(state => state.projectReducer); + + if (!project?.id) { + return ( +
+ Project not found +
+ ); + } + + return ( +
+ +
+ ); +}; + +export default ProjectViewEnhancedBoard; \ No newline at end of file