From ad7eb505b51c59665b037de219b4b60b1053dd29 Mon Sep 17 00:00:00 2001 From: Chamika J <75464293+chamikaJ@users.noreply.github.com> Date: Tue, 5 Aug 2025 16:02:07 +0530 Subject: [PATCH] feat(gantt): enhance Gantt chart functionality with task progress tracking - Updated GanttController to include task counts by status (todo, doing, done) and total tasks for each project phase. - Implemented progress percentage calculations for each phase based on task counts. - Enhanced ProjectViewGantt component to fetch task priorities and manage task data more effectively. - Improved GanttChart and GanttTaskList components for better rendering of tasks and phases, including drag-and-drop functionality. - Refactored GanttTimeline to optimize header generation based on view mode and date range. - Updated GanttToolbar for improved user interaction with task management features. --- .../src/controllers/gantt-controller.ts | 81 +- .../projectView/gantt/ProjectViewGantt.tsx | 37 +- .../components/gantt-chart/GanttChart.tsx | 416 ++--- .../gantt-task-list/GanttTaskList.tsx | 1359 +++++++++-------- .../gantt-timeline/GanttTimeline.tsx | 428 +++--- .../components/gantt-toolbar/GanttToolbar.tsx | 110 +- .../gantt/constants/gantt-constants.ts | 2 +- .../gantt/context/gantt-context.tsx | 2 +- .../projectView/gantt/gantt-styles.css | 13 +- .../gantt/hooks/useGanttDimensions.ts | 17 +- .../gantt/services/gantt-api.service.ts | 167 +- .../projectView/gantt/types/gantt-types.ts | 13 +- .../gantt/utils/timeline-calculator.ts | 87 +- .../src/types/advanced-gantt.types.ts | 23 +- .../src/utils/gantt-performance.ts | 198 +-- 15 files changed, 1643 insertions(+), 1310 deletions(-) diff --git a/worklenz-backend/src/controllers/gantt-controller.ts b/worklenz-backend/src/controllers/gantt-controller.ts index 155af606..5da7b94d 100644 --- a/worklenz-backend/src/controllers/gantt-controller.ts +++ b/worklenz-backend/src/controllers/gantt-controller.ts @@ -194,19 +194,80 @@ export default class GanttController extends WorklenzControllerBase { const q = ` SELECT - id, - name, - color_code, - start_date, - end_date, - sort_index - FROM project_phases - WHERE project_id = $1 - ORDER BY sort_index, created_at; + pp.id, + pp.name, + pp.color_code, + pp.start_date, + pp.end_date, + pp.sort_index, + -- Calculate task counts by status category for progress + COALESCE( + (SELECT COUNT(*) + FROM tasks t + JOIN task_phase tp ON t.id = tp.task_id + JOIN task_statuses ts ON t.status_id = ts.id + JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + WHERE tp.phase_id = pp.id + AND t.archived = FALSE + AND stsc.is_todo = TRUE), 0 + ) as todo_count, + COALESCE( + (SELECT COUNT(*) + FROM tasks t + JOIN task_phase tp ON t.id = tp.task_id + JOIN task_statuses ts ON t.status_id = ts.id + JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + WHERE tp.phase_id = pp.id + AND t.archived = FALSE + AND stsc.is_doing = TRUE), 0 + ) as doing_count, + COALESCE( + (SELECT COUNT(*) + FROM tasks t + JOIN task_phase tp ON t.id = tp.task_id + JOIN task_statuses ts ON t.status_id = ts.id + JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id + WHERE tp.phase_id = pp.id + AND t.archived = FALSE + AND stsc.is_done = TRUE), 0 + ) as done_count, + COALESCE( + (SELECT COUNT(*) + FROM tasks t + JOIN task_phase tp ON t.id = tp.task_id + WHERE tp.phase_id = pp.id + AND t.archived = FALSE), 0 + ) as total_count + FROM project_phases pp + WHERE pp.project_id = $1 + ORDER BY pp.sort_index, pp.created_at; `; const result = await db.query(q, [projectId]); - return res.status(200).send(new ServerResponse(true, result.rows)); + + // Calculate progress percentages for each phase + const phasesWithProgress = result.rows.map(phase => { + const total = parseInt(phase.total_count) || 0; + const todoCount = parseInt(phase.todo_count) || 0; + const doingCount = parseInt(phase.doing_count) || 0; + const doneCount = parseInt(phase.done_count) || 0; + + return { + id: phase.id, + name: phase.name, + color_code: phase.color_code, + start_date: phase.start_date, + end_date: phase.end_date, + sort_index: phase.sort_index, + // Calculate progress percentages + todo_progress: total > 0 ? Math.round((todoCount / total) * 100) : 0, + doing_progress: total > 0 ? Math.round((doingCount / total) * 100) : 0, + done_progress: total > 0 ? Math.round((doneCount / total) * 100) : 0, + total_tasks: total + }; + }); + + return res.status(200).send(new ServerResponse(true, phasesWithProgress)); } @HandleExceptions() diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/ProjectViewGantt.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/ProjectViewGantt.tsx index 28944fb0..e5ac8ec0 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/ProjectViewGantt.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/ProjectViewGantt.tsx @@ -1,4 +1,4 @@ -import React, { useState, useCallback, useRef, useMemo } from 'react'; +import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; import { Spin, message } from '@/shared/antd-imports'; import { useParams } from 'react-router-dom'; import GanttTimeline from './components/gantt-timeline/GanttTimeline'; @@ -20,7 +20,9 @@ import { setShowTaskDrawer, setSelectedTaskId, setTaskFormViewModel, + fetchTask, } from '@features/task-drawer/task-drawer.slice'; +import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice'; import { DEFAULT_TASK_NAME } from '@/shared/constants'; import './gantt-styles.css'; @@ -55,25 +57,30 @@ const ProjectViewGantt: React.FC = React.memo(() => { if (tasksResponse?.body && phasesResponse?.body) { const transformedTasks = transformToGanttTasks(tasksResponse.body, phasesResponse.body); const result: any[] = []; - + transformedTasks.forEach(task => { // Always show phase milestones if (task.type === 'milestone' || task.is_milestone) { result.push(task); - + // If this phase is expanded, show its children tasks - const phaseId = task.id === 'phase-unmapped' ? 'unmapped' : task.phase_id; - if (expandedTasks.has(phaseId) && task.children) { + const phaseId = + task.id === 'phase-unmapped' + ? 'unmapped' + : task.phase_id || task.id.replace('phase-', ''); + const isExpanded = expandedTasks.has(phaseId); + + if (isExpanded && task.children) { task.children.forEach((child: any) => { result.push({ ...child, - phase_id: task.phase_id // Ensure child has correct phase_id + phase_id: task.phase_id, // Ensure child has correct phase_id }); }); } } }); - + return result; } return []; @@ -96,6 +103,11 @@ const ProjectViewGantt: React.FC = React.memo(() => { const loading = tasksLoading || phasesLoading; + // Load priorities for task drawer functionality + useEffect(() => { + dispatch(fetchPriorities()); + }, [dispatch]); + const handleViewModeChange = useCallback((mode: GanttViewMode) => { setViewMode(mode); }, []); @@ -156,8 +168,13 @@ const ProjectViewGantt: React.FC = React.memo(() => { dispatch(setSelectedTaskId(taskId)); dispatch(setTaskFormViewModel(null)); // Clear form view model for existing task dispatch(setShowTaskDrawer(true)); + + // Fetch the complete task data including priorities + if (projectId) { + dispatch(fetchTask({ taskId, projectId })); + } }, - [dispatch] + [dispatch, projectId] ); const handleClosePhaseModal = useCallback(() => { @@ -172,14 +189,12 @@ const ProjectViewGantt: React.FC = React.memo(() => { const handleCreateQuickTask = useCallback( (taskName: string, phaseId?: string) => { - // Refresh the Gantt data after task creation + // Refresh the Gantt data after task creation to show the new task refetchTasks(); - message.success(`Task "${taskName}" created successfully!`); }, [refetchTasks] ); - // Handle errors if (tasksError || phasesError) { message.error('Failed to load Gantt chart data'); diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-chart/GanttChart.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-chart/GanttChart.tsx index c053f039..beac759b 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-chart/GanttChart.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-chart/GanttChart.tsx @@ -6,12 +6,12 @@ import { useGanttDimensions } from '../../hooks/useGanttDimensions'; const addAlphaToHex = (hex: string, alpha: number): string => { // Remove # if present const cleanHex = hex.replace('#', ''); - + // Convert hex to RGB const r = parseInt(cleanHex.substring(0, 2), 16); const g = parseInt(cleanHex.substring(2, 4), 16); const b = parseInt(cleanHex.substring(4, 6), 16); - + // Return rgba string return `rgba(${r}, ${g}, ${b}, ${alpha})`; }; @@ -32,7 +32,7 @@ interface GridColumnProps { } const GridColumn: React.FC = memo(({ index, columnWidth }) => ( -
= memo(({ task, viewMode, columnWidth, columnsCount, dateRange }) => { - const renderMilestone = () => { - if (!task.start_date || !dateRange) return null; - - // Calculate position for milestone diamond - const totalDays = Math.ceil((dateRange.end.getTime() - dateRange.start.getTime()) / (1000 * 60 * 60 * 24)); - const daysFromStart = Math.floor((task.start_date.getTime() - dateRange.start.getTime()) / (1000 * 60 * 60 * 24)); - const left = Math.max(0, (daysFromStart / totalDays) * (columnsCount * columnWidth)); - - return ( -
- ); - }; +const TaskBarRow: React.FC = memo( + ({ task, viewMode, columnWidth, columnsCount, dateRange }) => { + const renderMilestone = () => { + if (!task.start_date || !dateRange) return null; + + // Calculate position for milestone diamond based on view mode + const totalTimeSpan = dateRange.end.getTime() - dateRange.start.getTime(); + const timeFromStart = task.start_date.getTime() - dateRange.start.getTime(); + const left = Math.max(0, (timeFromStart / totalTimeSpan) * (columnsCount * columnWidth)); + + return ( +
+ ); + }; + + const renderTaskBar = () => { + if (!task.start_date || !task.end_date || !dateRange) return null; + + // Calculate position and width for task bar based on time ratios + const totalTimeSpan = dateRange.end.getTime() - dateRange.start.getTime(); + const timeFromStart = task.start_date.getTime() - dateRange.start.getTime(); + const taskDuration = task.end_date.getTime() - task.start_date.getTime(); + + const totalWidth = columnsCount * columnWidth; + const left = Math.max(0, (timeFromStart / totalTimeSpan) * totalWidth); + const width = Math.max(10, (taskDuration / totalTimeSpan) * totalWidth); + + return ( +
+
{task.name}
+ {task.progress > 0 && ( +
+ )} +
+ ); + }; + + const isPhase = task.type === 'milestone' || task.is_milestone; - const renderTaskBar = () => { - if (!task.start_date || !task.end_date || !dateRange) return null; - - // Calculate position and width for task bar - const totalDays = Math.ceil((dateRange.end.getTime() - dateRange.start.getTime()) / (1000 * 60 * 60 * 24)); - const daysFromStart = Math.floor((task.start_date.getTime() - dateRange.start.getTime()) / (1000 * 60 * 60 * 24)); - const taskDuration = Math.ceil((task.end_date.getTime() - task.start_date.getTime()) / (1000 * 60 * 60 * 24)); - - const left = Math.max(0, (daysFromStart / totalDays) * (columnsCount * columnWidth)); - const width = Math.max(10, (taskDuration / totalDays) * (columnsCount * columnWidth)); - return ( -
-
{task.name}
- {task.progress > 0 && ( -
- )} + {isPhase ? renderMilestone() : renderTaskBar()}
); - }; - - const isPhase = task.type === 'milestone' || task.is_milestone; - - return ( -
- {isPhase ? renderMilestone() : renderTaskBar()} -
- ); -}); + } +); TaskBarRow.displayName = 'TaskBarRow'; -const GanttChart = forwardRef(({ tasks, viewMode, onScroll, containerRef, dateRange, phases, expandedTasks }, ref) => { - const columnsCount = useMemo(() => { - if (!dateRange) { - // Default counts if no date range +const GanttChart = forwardRef( + ({ tasks, viewMode, onScroll, containerRef, dateRange, phases, expandedTasks }, ref) => { + const columnsCount = useMemo(() => { + if (!dateRange) { + // Default counts if no date range + switch (viewMode) { + case 'day': + return 30; + case 'week': + return 12; + case 'month': + return 12; + case 'quarter': + return 8; + case 'year': + return 5; + default: + return 12; + } + } + + const { start, end } = dateRange; + const diffTime = Math.abs(end.getTime() - start.getTime()); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + switch (viewMode) { - case 'day': return 30; - case 'week': return 12; - case 'month': return 12; - case 'quarter': return 8; - case 'year': return 5; - default: return 12; + case 'day': + return diffDays; + case 'week': + return Math.ceil(diffDays / 7); + case 'month': + const startYear = start.getFullYear(); + const startMonth = start.getMonth(); + const endYear = end.getFullYear(); + const endMonth = end.getMonth(); + return (endYear - startYear) * 12 + (endMonth - startMonth) + 1; + case 'quarter': + const qStartYear = start.getFullYear(); + const qStartQuarter = Math.ceil((start.getMonth() + 1) / 3); + const qEndYear = end.getFullYear(); + const qEndQuarter = Math.ceil((end.getMonth() + 1) / 3); + return (qEndYear - qStartYear) * 4 + (qEndQuarter - qStartQuarter) + 1; + case 'year': + return end.getFullYear() - start.getFullYear() + 1; + default: + return 12; } - } - - const { start, end } = dateRange; - const diffTime = Math.abs(end.getTime() - start.getTime()); - const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); - - switch (viewMode) { - case 'day': - return diffDays; - case 'week': - return Math.ceil(diffDays / 7); - case 'month': - const startYear = start.getFullYear(); - const startMonth = start.getMonth(); - const endYear = end.getFullYear(); - const endMonth = end.getMonth(); - return (endYear - startYear) * 12 + (endMonth - startMonth) + 1; - case 'quarter': - const qStartYear = start.getFullYear(); - const qStartQuarter = Math.ceil((start.getMonth() + 1) / 3); - const qEndYear = end.getFullYear(); - const qEndQuarter = Math.ceil((end.getMonth() + 1) / 3); - return (qEndYear - qStartYear) * 4 + (qEndQuarter - qStartQuarter) + 1; - case 'year': - return end.getFullYear() - start.getFullYear() + 1; - default: - return 12; - } - }, [viewMode, dateRange]); + }, [viewMode, dateRange]); - const { actualColumnWidth, totalWidth, shouldScroll } = useGanttDimensions( - viewMode, - containerRef, - columnsCount - ); + const { actualColumnWidth, totalWidth, shouldScroll } = useGanttDimensions( + viewMode, + containerRef, + columnsCount + ); - const gridColumns = useMemo(() => - Array.from({ length: columnsCount }).map((_, index) => index) - , [columnsCount]); + const gridColumns = useMemo( + () => Array.from({ length: columnsCount }).map((_, index) => index), + [columnsCount] + ); - // Flatten tasks to match the same hierarchy as task list - // This should be synchronized with the task list component's expand/collapse state - const flattenedTasks = useMemo(() => { - const result: Array = []; - - const processTask = (task: GanttTask) => { - result.push(task); - - // Check if this is an expanded phase with no children - const isPhase = task.type === 'milestone' || task.is_milestone; - const isEmpty = isPhase && (!task.children || task.children.length === 0); - const isExpanded = expandedTasks ? expandedTasks.has(task.id) : (task.expanded !== false); - - if (isEmpty && isExpanded) { - // Add an empty row for the "Add Task" button - result.push({ id: `${task.id}-empty`, isEmptyRow: true }); - } else if (task.children && isExpanded) { - task.children.forEach(child => processTask(child)); - } - }; - - tasks.forEach(processTask); - return result; - }, [tasks, expandedTasks]); + // Flatten tasks to match the same hierarchy as task list + // This should be synchronized with the task list component's expand/collapse state + const flattenedTasks = useMemo(() => { + const result: Array = []; + const processedIds = new Set(); // Track processed task IDs to prevent duplicates - return ( -
-
{ + const isPhase = task.type === 'milestone' || task.is_milestone; + const phaseId = isPhase + ? task.id === 'phase-unmapped' + ? 'unmapped' + : task.phase_id || task.id.replace('phase-', '') + : task.id; + const isExpanded = expandedTasks ? expandedTasks.has(phaseId) : task.expanded !== false; + + // Avoid processing the same task multiple times + if (processedIds.has(task.id)) { + return; + } + processedIds.add(task.id); + + // Set the correct level for nested tasks + const taskWithLevel = { ...task, level }; + result.push(taskWithLevel); + + if (isPhase && isExpanded) { + // Add children if they exist + if (task.children && task.children.length > 0) { + task.children.forEach(child => processTask(child, level + 1)); + } + // Add an empty row for the "Add Task" button at the end (only if not already processed) + const addTaskId = `add-task-${task.id}-timeline`; + if (!processedIds.has(addTaskId)) { + processedIds.add(addTaskId); + result.push({ id: addTaskId, isEmptyRow: true }); + } + } else if (!isPhase && task.children && expandedTasks && expandedTasks.has(task.id)) { + task.children.forEach(child => processTask(child, level + 1)); + } + }; + + tasks.forEach(task => processTask(task, 0)); + return result; + }, [tasks, expandedTasks]); + + return ( +
-
- {/* Grid columns for timeline */} - {gridColumns.map(index => ( - - ))} -
-
- {flattenedTasks.map(item => { - if ('isEmptyRow' in item && item.isEmptyRow) { - // Render empty row without "Add Task" button +
+ {/* Grid columns for timeline */} + {gridColumns.map(index => ( + + ))} +
+
+ {flattenedTasks.map(item => { + if ('isEmptyRow' in item && item.isEmptyRow) { + // Render empty row without "Add Task" button + return ( +
+ ); + } return ( -
); - } - return ( - - ); - })} - {flattenedTasks.length === 0 && ( -
- No tasks to display -
- )} + })} + {flattenedTasks.length === 0 && ( +
+ No tasks to display +
+ )} +
-
- ); -}); + ); + } +); GanttChart.displayName = 'GanttChart'; -export default memo(GanttChart); \ No newline at end of file +export default memo(GanttChart); diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-task-list/GanttTaskList.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-task-list/GanttTaskList.tsx index b6dc7e5f..7d72740d 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-task-list/GanttTaskList.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-task-list/GanttTaskList.tsx @@ -1,8 +1,21 @@ import React, { memo, useCallback, useState, forwardRef, useRef, useEffect, useMemo } from 'react'; -import { RightOutlined, DownOutlined, PlusOutlined, HolderOutlined, CalendarOutlined } from '@ant-design/icons'; +import { + RightOutlined, + DownOutlined, + PlusOutlined, + HolderOutlined, + CalendarOutlined, +} from '@ant-design/icons'; import { Button, Tooltip, Input, DatePicker, Space, message } from 'antd'; import dayjs, { Dayjs } from 'dayjs'; -import { DndContext, DragEndEvent, DragOverEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; +import { + DndContext, + DragEndEvent, + DragOverEvent, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { GanttTask, GanttViewMode } from '../../types/gantt-types'; @@ -17,12 +30,12 @@ import { useUpdatePhaseMutation } from '../../services/gantt-api.service'; const addAlphaToHex = (hex: string, alpha: number): string => { // Remove # if present const cleanHex = hex.replace('#', ''); - + // Convert hex to RGB const r = parseInt(cleanHex.substring(0, 2), 16); const g = parseInt(cleanHex.substring(2, 4), 16); const b = parseInt(cleanHex.substring(4, 6), 16); - + // Return rgba string return `rgba(${r}, ${g}, ${b}, ${alpha})`; }; @@ -60,15 +73,10 @@ interface SortableTaskRowProps extends TaskRowProps { } // Sortable wrapper for phase milestones -const SortableTaskRow: React.FC = memo((props) => { - const { - attributes, - listeners, - setNodeRef, - transform, - transition, - isDragging, - } = useSortable({ id: props.id }); +const SortableTaskRow: React.FC = memo(props => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: props.id, + }); const style = { transform: CSS.Transform.toString(transform), @@ -78,8 +86,8 @@ const SortableTaskRow: React.FC = memo((props) => { return (
- = memo((props) => { SortableTaskRow.displayName = 'SortableTaskRow'; -const TaskRow: React.FC = memo(({ - task, - projectId, - onToggle, - onTaskClick, - expandedTasks, - onCreateTask, - onCreateQuickTask, - isDraggable = false, - activeId, - overId, - dragAttributes, - dragListeners -}) => { - const [showInlineInput, setShowInlineInput] = useState(false); - const [taskName, setTaskName] = useState(''); - const [showDatePickers, setShowDatePickers] = useState(false); - const datePickerRef = useRef(null); - const { socket, connected } = useSocket(); - const dispatch = useAppDispatch(); - const [updatePhase] = useUpdatePhaseMutation(); - const formatDateRange = useCallback(() => { - if (!task.start_date || !task.end_date) { - return Not scheduled; - } - - const start = new Date(task.start_date).toLocaleDateString(); - const end = new Date(task.end_date).toLocaleDateString(); - return `${start} - ${end}`; - }, [task.start_date, task.end_date]); +const TaskRow: React.FC = memo( + ({ + task, + projectId, + onToggle, + onTaskClick, + expandedTasks, + onCreateTask, + onCreateQuickTask, + isDraggable = false, + activeId, + overId, + dragAttributes, + dragListeners, + }) => { + const [showInlineInput, setShowInlineInput] = useState(false); + const [taskName, setTaskName] = useState(''); + const [showDatePickers, setShowDatePickers] = useState(false); + const datePickerRef = useRef(null); + const { socket, connected } = useSocket(); + const dispatch = useAppDispatch(); + const [updatePhase] = useUpdatePhaseMutation(); + const formatDateRange = useCallback(() => { + if (!task.start_date || !task.end_date) { + return Not scheduled; + } - const isPhase = task.type === 'milestone' || task.is_milestone; - const hasChildren = task.children && task.children.length > 0; - // For phases, use phase_id for expansion state, for tasks use task.id - const phaseId = isPhase ? (task.id === 'phase-unmapped' ? 'unmapped' : task.phase_id || task.id.replace('phase-', '')) : task.id; - const isExpanded = expandedTasks.has(phaseId); - const indentLevel = (task.level || 0) * 20; + const start = new Date(task.start_date).toLocaleDateString(); + const end = new Date(task.end_date).toLocaleDateString(); + return `${start} - ${end}`; + }, [task.start_date, task.end_date]); - const handleToggle = useCallback(() => { - // For phases, always allow toggle (regardless of having children) - // Use the standard onToggle handler which will call handleTaskToggle in GanttTaskList - if (isPhase && onToggle) { - onToggle(phaseId); - } else if (hasChildren && onToggle) { - onToggle(task.id); - } - }, [isPhase, hasChildren, onToggle, task.id, phaseId]); + const isPhase = task.type === 'milestone' || task.is_milestone; + const hasChildren = task.children && task.children.length > 0; + // For phases, use phase_id for expansion state, for tasks use task.id + const phaseId = isPhase + ? task.id === 'phase-unmapped' + ? 'unmapped' + : task.phase_id || task.id.replace('phase-', '') + : task.id; + const isExpanded = expandedTasks.has(phaseId); + const indentLevel = (task.level || 0) * 20; - const getTaskIcon = () => { - // No icon for phases - return null; - }; + const handleToggle = useCallback(() => { + // For phases, always allow toggle (regardless of having children) + // Use the standard onToggle handler which will call handleTaskToggle in GanttTaskList + if (isPhase && onToggle) { + onToggle(phaseId); + } else if (hasChildren && onToggle) { + onToggle(task.id); + } + }, [isPhase, hasChildren, onToggle, task.id, phaseId]); - const getExpandIcon = () => { - // All phases should be expandable (with or without children) - if (isPhase) { - return ( - - ); - } - - return
; - }; - - const handleCreateTask = () => { - if (onCreateTask) { - // For phase milestones, pass the phase ID - const phaseId = task.type === 'milestone' && task.phase_id ? task.phase_id : undefined; - onCreateTask(phaseId); - } - }; - - // Handle inline task creation - const handleQuickTaskCreation = useCallback((taskName: string) => { - if (!connected || !socket || !projectId) return; - - const currentSession = JSON.parse(localStorage.getItem('session') || '{}'); - const phaseId = task.type === 'milestone' && task.phase_id ? task.phase_id : undefined; - - const requestBody = { - project_id: projectId, - name: taskName.trim(), - reporter_id: currentSession.id, - team_id: currentSession.team_id, - phase_id: phaseId, + const getTaskIcon = () => { + // No icon for phases + return null; }; - socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(requestBody)); - - // Handle the response and update UI - socket.once(SocketEvents.QUICK_TASK.toString(), (response: any) => { - if (response) { - // The task will be automatically added to the task management slice - // via global socket handlers, but we need to refresh the Gantt data - onCreateQuickTask?.(taskName, phaseId); + const getExpandIcon = () => { + // All phases should be expandable (with or without children) + if (isPhase) { + return ( + + ); } - }); - // Reset input state - setTaskName(''); - setShowInlineInput(false); - }, [connected, socket, projectId, task.type, task.phase_id, onCreateQuickTask]); + return
; + }; - const handleKeyPress = useCallback((e: React.KeyboardEvent) => { - if (e.key === 'Enter' && taskName.trim()) { - handleQuickTaskCreation(taskName); - } else if (e.key === 'Escape') { + const handleCreateTask = () => { + if (onCreateTask) { + // For phase milestones, pass the phase ID + const phaseId = task.type === 'milestone' && task.phase_id ? task.phase_id : undefined; + onCreateTask(phaseId); + } + }; + + // Handle inline task creation + const handleQuickTaskCreation = useCallback( + (taskName: string) => { + if (!connected || !socket || !projectId) return; + + const currentSession = JSON.parse(localStorage.getItem('session') || '{}'); + const phaseId = task.type === 'milestone' && task.phase_id ? task.phase_id : undefined; + + const requestBody = { + project_id: projectId, + name: taskName.trim(), + reporter_id: currentSession.id, + team_id: currentSession.team_id, + phase_id: phaseId, + }; + + socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(requestBody)); + + // Handle the response and update UI + socket.once(SocketEvents.QUICK_TASK.toString(), (response: any) => { + if (response) { + // The task will be automatically added to the task management slice + // via global socket handlers, but we need to refresh the Gantt data + onCreateQuickTask?.(taskName, phaseId); + } + }); + + // Reset input state + setTaskName(''); + setShowInlineInput(false); + }, + [connected, socket, projectId, task.type, task.phase_id, onCreateQuickTask] + ); + + const handleKeyPress = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && taskName.trim()) { + handleQuickTaskCreation(taskName); + } else if (e.key === 'Escape') { + setTaskName(''); + setShowInlineInput(false); + } + }, + [taskName, handleQuickTaskCreation] + ); + + const handleShowInlineInput = useCallback(() => { + setShowInlineInput(true); + }, []); + + const handlePhaseDateUpdate = useCallback( + async (startDate: Date, endDate: Date) => { + if (!projectId || !task.phase_id) return; + + try { + await updatePhase({ + project_id: projectId, + phase_id: task.phase_id, + start_date: startDate.toISOString(), + end_date: endDate.toISOString(), + }).unwrap(); + + message.success('Phase dates updated successfully'); + setShowDatePickers(false); + } catch (error) { + console.error('Failed to update phase dates:', error); + message.error('Failed to update phase dates'); + } + }, + [projectId, task.phase_id, updatePhase] + ); + + const isEmpty = isPhase && (!task.children || task.children.length === 0); + + // Calculate phase completion percentage + const phaseCompletion = useMemo(() => { + if (!isPhase || !task.children || task.children.length === 0) { + return 0; + } + const totalTasks = task.children.length; + const completedTasks = task.children.filter(child => child.progress === 100).length; + return Math.round((completedTasks / totalTasks) * 100); + }, [isPhase, task.children]); + + const handleTaskClick = useCallback(() => { + if (!isPhase && onTaskClick) { + onTaskClick(task.id); + } + }, [isPhase, onTaskClick, task.id]); + + // Handle click outside to close date picker + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) { + setShowDatePickers(false); + } + }; + + if (showDatePickers) { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + } + }, [showDatePickers]); + + return ( + <> +
+
+
+ {/* Drag handle for phases */} + {isPhase && isDraggable && ( + + )} + + {getExpandIcon()} + +
+ {getTaskIcon()} +
+ + {task.name} + + {isPhase && ( +
+ + {task.children?.length || 0} tasks + + {!showDatePickers && ( + + )} + {showDatePickers && isPhase && ( +
+ { + if (dates && dates[0] && dates[1]) { + handlePhaseDateUpdate(dates[0].toDate(), dates[1].toDate()); + } + }} + onOpenChange={open => { + if (!open) { + setShowDatePickers(false); + } + }} + className="text-xs" + style={{ width: 180 }} + format="MMM D, YYYY" + placeholder={['Start date', 'End date']} + autoFocus + /> +
+ )} +
+ )} +
+
+
+ + {/* Phase completion percentage on the right side */} + {isPhase && task.children && task.children.length > 0 && ( +
+ + {phaseCompletion}% + +
+ )} +
+
+ + ); + } +); + +TaskRow.displayName = 'TaskRow'; + +// Add Task Row Component +interface AddTaskRowProps { + task: GanttTask; + projectId: string; + onCreateQuickTask?: (taskName: string, phaseId?: string) => void; +} + +const AddTaskRow: React.FC = memo(({ task, projectId, onCreateQuickTask }) => { + const [showInlineInput, setShowInlineInput] = useState(false); + const [taskName, setTaskName] = useState(''); + const { socket, connected } = useSocket(); + const authService = useAuthService(); + + // Handle inline task creation + const handleQuickTaskCreation = useCallback( + (taskName: string) => { + if (!connected || !socket || !projectId) return; + + const currentSession = authService.getCurrentSession(); + if (!currentSession) { + console.error('No current session found'); + return; + } + + // Get the correct phase ID + let phaseId: string | null | undefined = task.parent_phase_id; + if (phaseId === 'unmapped') { + phaseId = null; // Unmapped tasks have no phase + } + + const requestBody = { + project_id: projectId, + name: taskName.trim(), + reporter_id: currentSession.id, + team_id: currentSession.team_id, + phase_id: phaseId, + }; + + socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(requestBody)); + + // Handle the response and update UI + socket.once(SocketEvents.QUICK_TASK.toString(), (response: any) => { + if (response) { + // Immediately refresh the Gantt data to show the new task + onCreateQuickTask?.(taskName, phaseId); + } + }); + + // Reset input state setTaskName(''); setShowInlineInput(false); - } - }, [taskName, handleQuickTaskCreation]); + }, + [connected, socket, projectId, task.parent_phase_id, onCreateQuickTask, authService] + ); + + const handleKeyPress = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && taskName.trim()) { + handleQuickTaskCreation(taskName); + } else if (e.key === 'Escape') { + setTaskName(''); + setShowInlineInput(false); + } + }, + [taskName, handleQuickTaskCreation] + ); const handleShowInlineInput = useCallback(() => { setShowInlineInput(true); }, []); - const handlePhaseDateUpdate = useCallback(async (startDate: Date, endDate: Date) => { - if (!projectId || !task.phase_id) return; - - try { - await updatePhase({ - project_id: projectId, - phase_id: task.phase_id, - start_date: startDate.toISOString(), - end_date: endDate.toISOString(), - }).unwrap(); - - message.success('Phase dates updated successfully'); - setShowDatePickers(false); - } catch (error) { - console.error('Failed to update phase dates:', error); - message.error('Failed to update phase dates'); - } - }, [projectId, task.phase_id, updatePhase]); - - const isEmpty = isPhase && (!task.children || task.children.length === 0); - - // Calculate phase completion percentage - const phaseCompletion = useMemo(() => { - if (!isPhase || !task.children || task.children.length === 0) { - return 0; - } - const totalTasks = task.children.length; - const completedTasks = task.children.filter(child => child.progress === 100).length; - return Math.round((completedTasks / totalTasks) * 100); - }, [isPhase, task.children]); - - const handleTaskClick = useCallback(() => { - if (!isPhase && onTaskClick) { - onTaskClick(task.id); - } - }, [isPhase, onTaskClick, task.id]); - - // Handle click outside to close date picker - useEffect(() => { - const handleClickOutside = (event: MouseEvent) => { - if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) { - setShowDatePickers(false); - } - }; - - if (showDatePickers) { - document.addEventListener('mousedown', handleClickOutside); - return () => { - document.removeEventListener('mousedown', handleClickOutside); - }; - } - }, [showDatePickers]); - return ( - <> -
+
-
-
- {/* Drag handle for phases */} - {isPhase && isDraggable && ( - - )} - - {getExpandIcon()} - -
- {getTaskIcon()} -
- - {task.name} - - {isPhase && ( -
- - {task.children?.length || 0} tasks - - {!showDatePickers && ( - - )} - {showDatePickers && isPhase && ( -
- { - if (dates && dates[0] && dates[1]) { - handlePhaseDateUpdate(dates[0].toDate(), dates[1].toDate()); - } - }} - onOpenChange={(open) => { - if (!open) { - setShowDatePickers(false); - } - }} - className="text-xs" - style={{ width: 180 }} - format="MMM D, YYYY" - placeholder={['Start date', 'End date']} - autoFocus - /> -
- )} -
- )} -
-
-
- - {/* Phase completion percentage on the right side */} - {isPhase && task.children && task.children.length > 0 && ( -
- - {phaseCompletion}% - -
- )} - -
-
- - {/* Inline task creation for all expanded phases */} - {isPhase && isExpanded && ( -
-
- {showInlineInput ? ( - setTaskName(e.target.value)} - onKeyDown={handleKeyPress} - onBlur={() => { - if (!taskName.trim()) { - setShowInlineInput(false); - } - }} - autoFocus - className="text-xs dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100" - /> - ) : ( - - )} -
-
- )} - - ); -}); - -TaskRow.displayName = 'TaskRow'; - -const GanttTaskList = forwardRef(({ - tasks, - projectId, - viewMode, - onTaskToggle, - onTaskClick, - onCreateTask, - onCreateQuickTask, - onPhaseReorder, - onScroll, - expandedTasks: expandedTasksProp, - onExpandedTasksChange -}, ref) => { - const [localExpandedTasks, setLocalExpandedTasks] = useState>( - () => new Set(tasks.filter(t => t.expanded).map(t => t.id)) - ); - - const expandedTasks = expandedTasksProp || localExpandedTasks; - - // Drag and drop state - const [activeId, setActiveId] = useState(null); - const [overId, setOverId] = useState(null); - - // Socket and auth - const { socket, connected } = useSocket(); - const currentSession = useAuthService().getCurrentSession(); - - // DnD sensors - const sensors = useSensors( - useSensor(PointerSensor, { - activationConstraint: { - distance: 8, - }, - }) - ); - - const handleTaskToggle = useCallback((taskId: string) => { - const updateExpanded = (prev: Set) => { - const newSet = new Set(prev); - if (newSet.has(taskId)) { - newSet.delete(taskId); - } else { - newSet.add(taskId); - } - return newSet; - }; - - if (onExpandedTasksChange) { - onExpandedTasksChange(updateExpanded(expandedTasks)); - } else { - setLocalExpandedTasks(updateExpanded); - } - - onTaskToggle?.(taskId); - }, [expandedTasks, onExpandedTasksChange, onTaskToggle]); - - // Flatten tasks based on expand/collapse state - const flattenTasks = useCallback((taskList: GanttTask[]): GanttTask[] => { - const result: GanttTask[] = []; - - const processTask = (task: GanttTask) => { - result.push(task); - - if (task.children && expandedTasks.has(task.id)) { - task.children.forEach(child => processTask(child)); - } - }; - - taskList.forEach(processTask); - return result; - }, [expandedTasks]); - - const visibleTasks = flattenTasks(tasks); - - // Emit task sort change via socket for moving tasks between phases - const emitTaskPhaseChange = useCallback( - (taskId: string, fromPhaseId: string | null, toPhaseId: string | null, sortOrder: number) => { - if (!socket || !connected || !projectId) return; - - const task = visibleTasks.find(t => t.id === taskId); - if (!task || task.type === 'milestone' || task.is_milestone) return; - - const teamId = currentSession?.team_id || ''; - - const socketData = { - project_id: projectId, - group_by: 'phase', - task_updates: [{ - task_id: taskId, - sort_order: sortOrder, - phase_id: toPhaseId - }], - from_group: fromPhaseId || 'unmapped', - to_group: toPhaseId || 'unmapped', - task: { - id: task.id, - project_id: projectId, - status: '', - priority: '', - }, - team_id: teamId, - }; - - socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData); - }, - [socket, connected, projectId, visibleTasks, currentSession] - ); - - const handleDragStart = useCallback((event: any) => { - setActiveId(event.active.id as string); - }, []); - - const handleDragOver = useCallback((event: DragOverEvent) => { - const { active, over } = event; - - if (!over) { - setOverId(null); - return; - } - - setOverId(over.id as string); - }, []); - - const handleDragEnd = useCallback((event: DragEndEvent) => { - const { active, over } = event; - setActiveId(null); - setOverId(null); - - if (!over || active.id === over.id) return; - - const activeTask = visibleTasks.find(t => t.id === active.id); - const overTask = visibleTasks.find(t => t.id === over.id); - - // Handle phase reordering (existing functionality) - if (activeTask && (activeTask.type === 'milestone' || activeTask.is_milestone) && onPhaseReorder) { - const phases = tasks.filter(task => task.type === 'milestone' || task.is_milestone); - const oldIndex = phases.findIndex(phase => phase.id === active.id); - const newIndex = phases.findIndex(phase => phase.id === over.id); - - if (oldIndex !== -1 && newIndex !== -1) { - onPhaseReorder(oldIndex, newIndex); - } - return; - } - - // Handle task moving between phases - if (activeTask && !(activeTask.type === 'milestone' || activeTask.is_milestone)) { - let targetPhaseId: string | null = null; - - // If dropped on a phase, move to that phase - if (overTask && (overTask.type === 'milestone' || overTask.is_milestone)) { - targetPhaseId = overTask.phase_id || overTask.id.replace('phase-', ''); - if (overTask.id === 'phase-unmapped') { - targetPhaseId = null; - } - } else if (overTask) { - // If dropped on another task, move to that task's phase - targetPhaseId = overTask.phase_id; - } - - // Find current phase - const currentPhaseId = activeTask.phase_id; - - // Only emit if phase actually changed - if (currentPhaseId !== targetPhaseId) { - emitTaskPhaseChange(activeTask.id, currentPhaseId, targetPhaseId, 0); - } - } - }, [tasks, visibleTasks, onPhaseReorder, emitTaskPhaseChange]); - - // Separate phases and tasks for drag and drop (exclude unmapped phase) - const phases = visibleTasks.filter(task => - (task.type === 'milestone' || task.is_milestone) && task.id !== 'phase-unmapped' - ); - const regularTasks = visibleTasks.filter(task => - !(task.type === 'milestone' || task.is_milestone) - ); - - // All draggable items (phases + tasks) - const allDraggableItems = [...phases.map(p => p.id), ...regularTasks.map(t => t.id)]; - const phasesSet = new Set(phases.map(p => p.id)); - - // Determine if the timeline has dual headers - const hasDualHeaders = ['month', 'week', 'day'].includes(viewMode); - const headerHeight = hasDualHeaders ? 'h-20' : 'h-10'; - - return ( -
-
-
- Task Name -
-
-
- {visibleTasks.length === 0 && ( -
- No tasks available -
- )} - - - - {visibleTasks.map((task, index) => { - const isPhase = task.type === 'milestone' || task.is_milestone; - const isUnmappedPhase = task.id === 'phase-unmapped'; - - if (isPhase && !isUnmappedPhase) { - return ( - - ); - } else if (isUnmappedPhase) { - return ( - - ); - } else { - // Regular tasks - make them draggable too - return ( - - ); + {showInlineInput ? ( + setTaskName(e.target.value)} + onKeyDown={handleKeyPress} + onBlur={() => { + if (!taskName.trim()) { + setShowInlineInput(false); } - })} - - + }} + autoFocus + className="text-xs dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100" + /> + ) : ( + + )}
); }); +AddTaskRow.displayName = 'AddTaskRow'; + +const GanttTaskList = forwardRef( + ( + { + tasks, + projectId, + viewMode, + onTaskToggle, + onTaskClick, + onCreateTask, + onCreateQuickTask, + onPhaseReorder, + onScroll, + expandedTasks: expandedTasksProp, + onExpandedTasksChange, + }, + ref + ) => { + const [localExpandedTasks, setLocalExpandedTasks] = useState>( + () => new Set(tasks.filter(t => t.expanded).map(t => t.id)) + ); + + const expandedTasks = expandedTasksProp || localExpandedTasks; + + // Drag and drop state + const [activeId, setActiveId] = useState(null); + const [overId, setOverId] = useState(null); + + // Socket and auth + const { socket, connected } = useSocket(); + const currentSession = useAuthService().getCurrentSession(); + + // DnD sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }) + ); + + const handleTaskToggle = useCallback( + (taskId: string) => { + const updateExpanded = (prev: Set) => { + const newSet = new Set(prev); + if (newSet.has(taskId)) { + newSet.delete(taskId); + } else { + newSet.add(taskId); + } + return newSet; + }; + + if (onExpandedTasksChange) { + onExpandedTasksChange(updateExpanded(expandedTasks)); + } else { + setLocalExpandedTasks(updateExpanded); + } + + onTaskToggle?.(taskId); + }, + [expandedTasks, onExpandedTasksChange, onTaskToggle] + ); + + // Flatten tasks based on expand/collapse state + const flattenTasks = useCallback( + (taskList: GanttTask[]): GanttTask[] => { + const result: GanttTask[] = []; + const processedIds = new Set(); // Track processed task IDs to prevent duplicates + + const processTask = (task: GanttTask, level: number = 0) => { + const isPhase = task.type === 'milestone' || task.is_milestone; + const phaseId = isPhase + ? task.id === 'phase-unmapped' + ? 'unmapped' + : task.phase_id || task.id.replace('phase-', '') + : task.id; + const isExpanded = expandedTasks.has(phaseId); + + // Avoid processing the same task multiple times + if (processedIds.has(task.id)) { + return; + } + processedIds.add(task.id); + + // Set the correct level for nested tasks + const taskWithLevel = { ...task, level }; + result.push(taskWithLevel); + + if (isPhase && isExpanded) { + // Add children if they exist + if (task.children && task.children.length > 0) { + task.children.forEach(child => processTask(child, level + 1)); + } + // Add a special "add task" row at the end (only if not already processed) + const addTaskId = `add-task-${task.id}`; + if (!processedIds.has(addTaskId)) { + processedIds.add(addTaskId); + result.push({ + id: addTaskId, + name: 'Add Task', + type: 'add-task-button' as any, + phase_id: task.phase_id, + parent_phase_id: phaseId, + level: level + 1, + start_date: null, + end_date: null, + progress: 0, + } as GanttTask); + } + } else if (!isPhase && task.children && expandedTasks.has(task.id)) { + task.children.forEach(child => processTask(child, level + 1)); + } + }; + + taskList.forEach(task => processTask(task, 0)); + return result; + }, + [expandedTasks] + ); + + const visibleTasks = flattenTasks(tasks); + + // Emit task sort change via socket for moving tasks between phases + const emitTaskPhaseChange = useCallback( + (taskId: string, fromPhaseId: string | null, toPhaseId: string | null, sortOrder: number) => { + if (!socket || !connected || !projectId) return; + + const task = visibleTasks.find(t => t.id === taskId); + if (!task || task.type === 'milestone' || task.is_milestone) return; + + const teamId = currentSession?.team_id || ''; + + const socketData = { + project_id: projectId, + group_by: 'phase', + task_updates: [ + { + task_id: taskId, + sort_order: sortOrder, + phase_id: toPhaseId, + }, + ], + from_group: fromPhaseId || 'unmapped', + to_group: toPhaseId || 'unmapped', + task: { + id: task.id, + project_id: projectId, + status: '', + priority: '', + }, + team_id: teamId, + }; + + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData); + }, + [socket, connected, projectId, visibleTasks, currentSession] + ); + + const handleDragStart = useCallback((event: any) => { + setActiveId(event.active.id as string); + }, []); + + const handleDragOver = useCallback((event: DragOverEvent) => { + const { active, over } = event; + + if (!over) { + setOverId(null); + return; + } + + setOverId(over.id as string); + }, []); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + setActiveId(null); + setOverId(null); + + if (!over || active.id === over.id) return; + + const activeTask = visibleTasks.find(t => t.id === active.id); + const overTask = visibleTasks.find(t => t.id === over.id); + + // Handle phase reordering (existing functionality) + if ( + activeTask && + (activeTask.type === 'milestone' || activeTask.is_milestone) && + onPhaseReorder + ) { + const phases = tasks.filter(task => task.type === 'milestone' || task.is_milestone); + const oldIndex = phases.findIndex(phase => phase.id === active.id); + const newIndex = phases.findIndex(phase => phase.id === over.id); + + if (oldIndex !== -1 && newIndex !== -1) { + onPhaseReorder(oldIndex, newIndex); + } + return; + } + + // Handle task moving between phases + if (activeTask && !(activeTask.type === 'milestone' || activeTask.is_milestone)) { + let targetPhaseId: string | null = null; + + // If dropped on a phase, move to that phase + if (overTask && (overTask.type === 'milestone' || overTask.is_milestone)) { + targetPhaseId = overTask.phase_id || overTask.id.replace('phase-', ''); + if (overTask.id === 'phase-unmapped') { + targetPhaseId = null; + } + } else if (overTask) { + // If dropped on another task, move to that task's phase + targetPhaseId = overTask.phase_id; + } + + // Find current phase + const currentPhaseId = activeTask.phase_id; + + // Only emit if phase actually changed + if (currentPhaseId !== targetPhaseId) { + emitTaskPhaseChange(activeTask.id, currentPhaseId, targetPhaseId, 0); + } + } + }, + [tasks, visibleTasks, onPhaseReorder, emitTaskPhaseChange] + ); + + // Separate phases and tasks for drag and drop (exclude unmapped phase) + const phases = visibleTasks.filter( + task => (task.type === 'milestone' || task.is_milestone) && task.id !== 'phase-unmapped' + ); + const regularTasks = visibleTasks.filter( + task => !(task.type === 'milestone' || task.is_milestone) + ); + + // All draggable items (phases + tasks) + const allDraggableItems = [...phases.map(p => p.id), ...regularTasks.map(t => t.id)]; + const phasesSet = new Set(phases.map(p => p.id)); + + // Determine if the timeline has dual headers + const hasDualHeaders = ['month', 'week', 'day'].includes(viewMode); + const headerHeight = hasDualHeaders ? 'h-20' : 'h-10'; + + return ( +
+
+
Task Name
+
+
+ {visibleTasks.length === 0 && ( +
+ No tasks available +
+ )} + + + + {visibleTasks.map((task, index) => { + const isPhase = task.type === 'milestone' || task.is_milestone; + const isUnmappedPhase = task.id === 'phase-unmapped'; + const isAddTaskButton = task.type === 'add-task-button'; + + if (isAddTaskButton) { + return ( + + ); + } else if (isPhase && !isUnmappedPhase) { + return ( + + ); + } else if (isUnmappedPhase) { + return ( + + ); + } else { + // Regular tasks - make them draggable too + return ( + + ); + } + })} + + +
+
+ ); + } +); + GanttTaskList.displayName = 'GanttTaskList'; -export default memo(GanttTaskList); \ No newline at end of file +export default memo(GanttTaskList); diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-timeline/GanttTimeline.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-timeline/GanttTimeline.tsx index a1cf396e..04f6f961 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-timeline/GanttTimeline.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-timeline/GanttTimeline.tsx @@ -9,231 +9,241 @@ interface GanttTimelineProps { dateRange?: { start: Date; end: Date }; } -const GanttTimeline = forwardRef(({ viewMode, containerRef, dateRange }, ref) => { - const { topHeaders, bottomHeaders } = useMemo(() => { - if (!dateRange) { - return { topHeaders: [], bottomHeaders: [] }; - } - - const { start, end } = dateRange; - const topHeaders: Array<{ label: string; key: string; span: number }> = []; - const bottomHeaders: Array<{ label: string; key: string }> = []; - - switch (viewMode) { - case 'month': - // Top: Years, Bottom: Months - const startYear = start.getFullYear(); - const startMonth = start.getMonth(); - const endYear = end.getFullYear(); - const endMonth = end.getMonth(); - - // Generate bottom headers (months) - let currentYear = startYear; - let currentMonth = startMonth; - - while (currentYear < endYear || (currentYear === endYear && currentMonth <= endMonth)) { - const date = new Date(currentYear, currentMonth, 1); - bottomHeaders.push({ - label: date.toLocaleDateString('en-US', { month: 'short' }), - key: `month-${currentYear}-${currentMonth}`, - }); - - currentMonth++; - if (currentMonth > 11) { - currentMonth = 0; - currentYear++; - } - } - - // Generate top headers (years) - for (let year = startYear; year <= endYear; year++) { - const monthsInYear = bottomHeaders.filter(h => h.key.includes(`-${year}-`)).length; - if (monthsInYear > 0) { - topHeaders.push({ - label: `${year}`, - key: `year-${year}`, - span: monthsInYear +const GanttTimeline = forwardRef( + ({ viewMode, containerRef, dateRange }, ref) => { + const { topHeaders, bottomHeaders } = useMemo(() => { + if (!dateRange) { + return { topHeaders: [], bottomHeaders: [] }; + } + + const { start, end } = dateRange; + const topHeaders: Array<{ label: string; key: string; span: number }> = []; + const bottomHeaders: Array<{ label: string; key: string }> = []; + + switch (viewMode) { + case 'month': + // Top: Years, Bottom: Months + const startYear = start.getFullYear(); + const startMonth = start.getMonth(); + const endYear = end.getFullYear(); + const endMonth = end.getMonth(); + + // Generate bottom headers (months) + let currentYear = startYear; + let currentMonth = startMonth; + + while (currentYear < endYear || (currentYear === endYear && currentMonth <= endMonth)) { + const date = new Date(currentYear, currentMonth, 1); + bottomHeaders.push({ + label: date.toLocaleDateString('en-US', { month: 'short' }), + key: `month-${currentYear}-${currentMonth}`, }); - } - } - break; - - case 'week': - // Top: Months, Bottom: Weeks - const weekStart = new Date(start); - const weekEnd = new Date(end); - weekStart.setDate(weekStart.getDate() - weekStart.getDay()); - - const weekDates: Date[] = []; - const tempDate = new Date(weekStart); - while (tempDate <= weekEnd) { - weekDates.push(new Date(tempDate)); - tempDate.setDate(tempDate.getDate() + 7); - } - - // Generate bottom headers (weeks) - weekDates.forEach(date => { - const weekNum = TimelineUtils.getWeekNumber(date); - bottomHeaders.push({ - label: `W${weekNum}`, - key: `week-${date.getFullYear()}-${weekNum}`, - }); - }); - - // Generate top headers (months) - const monthGroups = new Map(); - weekDates.forEach(date => { - const monthKey = `${date.getFullYear()}-${date.getMonth()}`; - monthGroups.set(monthKey, (monthGroups.get(monthKey) || 0) + 1); - }); - - monthGroups.forEach((count, monthKey) => { - const [year, month] = monthKey.split('-').map(Number); - const date = new Date(year, month, 1); - topHeaders.push({ - label: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }), - key: `month-${monthKey}`, - span: count - }); - }); - break; - - case 'day': - // Top: Months, Bottom: Days - const dayStart = new Date(start); - const dayEnd = new Date(end); - - const dayDates: Date[] = []; - const tempDayDate = new Date(dayStart); - while (tempDayDate <= dayEnd) { - dayDates.push(new Date(tempDayDate)); - tempDayDate.setDate(tempDayDate.getDate() + 1); - } - - // Generate bottom headers (days) - dayDates.forEach(date => { - bottomHeaders.push({ - label: date.getDate().toString(), - key: `day-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`, - }); - }); - - // Generate top headers (months) - const dayMonthGroups = new Map(); - dayDates.forEach(date => { - const monthKey = `${date.getFullYear()}-${date.getMonth()}`; - dayMonthGroups.set(monthKey, (dayMonthGroups.get(monthKey) || 0) + 1); - }); - - dayMonthGroups.forEach((count, monthKey) => { - const [year, month] = monthKey.split('-').map(Number); - const date = new Date(year, month, 1); - topHeaders.push({ - label: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }), - key: `month-${monthKey}`, - span: count - }); - }); - break; - - default: - // Fallback to single row for other view modes - const result = []; - switch (viewMode) { - case 'quarter': - const qStartYear = start.getFullYear(); - const qStartQuarter = Math.ceil((start.getMonth() + 1) / 3); - const qEndYear = end.getFullYear(); - const qEndQuarter = Math.ceil((end.getMonth() + 1) / 3); - - let qYear = qStartYear; - let qQuarter = qStartQuarter; - - while (qYear < qEndYear || (qYear === qEndYear && qQuarter <= qEndQuarter)) { - result.push({ - label: `Q${qQuarter} ${qYear}`, - key: `quarter-${qYear}-${qQuarter}`, - }); - - qQuarter++; - if (qQuarter > 4) { - qQuarter = 1; - qYear++; - } + + currentMonth++; + if (currentMonth > 11) { + currentMonth = 0; + currentYear++; } - break; - case 'year': - const yearStart = start.getFullYear(); - const yearEnd = end.getFullYear(); - - for (let year = yearStart; year <= yearEnd; year++) { - result.push({ + } + + // Generate top headers (years) + for (let year = startYear; year <= endYear; year++) { + const monthsInYear = bottomHeaders.filter(h => h.key.includes(`-${year}-`)).length; + if (monthsInYear > 0) { + topHeaders.push({ label: `${year}`, key: `year-${year}`, + span: monthsInYear, }); } - break; - } - - result.forEach(item => { - bottomHeaders.push(item); - }); - break; - } - - return { topHeaders, bottomHeaders }; - }, [viewMode, dateRange]); + } + break; - const { actualColumnWidth, totalWidth, shouldScroll } = useGanttDimensions( - viewMode, - containerRef, - bottomHeaders.length - ); + case 'week': + // Top: Months, Bottom: Weeks + const weekStart = new Date(start); + const weekEnd = new Date(end); + weekStart.setDate(weekStart.getDate() - weekStart.getDay()); - const hasTopHeaders = topHeaders.length > 0; + const weekDates: Date[] = []; + const tempDate = new Date(weekStart); + while (tempDate <= weekEnd) { + weekDates.push(new Date(tempDate)); + tempDate.setDate(tempDate.getDate() + 7); + } - return ( -
- {hasTopHeaders && ( -
- {topHeaders.map(header => ( -
{ + const weekNum = TimelineUtils.getWeekNumber(date); + bottomHeaders.push({ + label: `W${weekNum}`, + key: `week-${date.getFullYear()}-${weekNum}`, + }); + }); + + // Generate top headers (months) + const monthGroups = new Map(); + weekDates.forEach(date => { + const monthKey = `${date.getFullYear()}-${date.getMonth()}`; + monthGroups.set(monthKey, (monthGroups.get(monthKey) || 0) + 1); + }); + + monthGroups.forEach((count, monthKey) => { + const [year, month] = monthKey.split('-').map(Number); + const date = new Date(year, month, 1); + topHeaders.push({ + label: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }), + key: `month-${monthKey}`, + span: count, + }); + }); + break; + + case 'day': + // Top: Months, Bottom: Days + const dayStart = new Date(start); + const dayEnd = new Date(end); + + const dayDates: Date[] = []; + const tempDayDate = new Date(dayStart); + while (tempDayDate <= dayEnd) { + dayDates.push(new Date(tempDayDate)); + tempDayDate.setDate(tempDayDate.getDate() + 1); + } + + // Generate bottom headers (days) + dayDates.forEach(date => { + bottomHeaders.push({ + label: date.getDate().toString(), + key: `day-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`, + }); + }); + + // Generate top headers (months) + const dayMonthGroups = new Map(); + dayDates.forEach(date => { + const monthKey = `${date.getFullYear()}-${date.getMonth()}`; + dayMonthGroups.set(monthKey, (dayMonthGroups.get(monthKey) || 0) + 1); + }); + + dayMonthGroups.forEach((count, monthKey) => { + const [year, month] = monthKey.split('-').map(Number); + const date = new Date(year, month, 1); + topHeaders.push({ + label: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }), + key: `month-${monthKey}`, + span: count, + }); + }); + break; + + default: + // Fallback to single row for other view modes + const result = []; + switch (viewMode) { + case 'quarter': + const qStartYear = start.getFullYear(); + const qStartQuarter = Math.ceil((start.getMonth() + 1) / 3); + const qEndYear = end.getFullYear(); + const qEndQuarter = Math.ceil((end.getMonth() + 1) / 3); + + let qYear = qStartYear; + let qQuarter = qStartQuarter; + + while (qYear < qEndYear || (qYear === qEndYear && qQuarter <= qEndQuarter)) { + result.push({ + label: `Q${qQuarter} ${qYear}`, + key: `quarter-${qYear}-${qQuarter}`, + }); + + qQuarter++; + if (qQuarter > 4) { + qQuarter = 1; + qYear++; + } + } + break; + case 'year': + const yearStart = start.getFullYear(); + const yearEnd = end.getFullYear(); + + for (let year = yearStart; year <= yearEnd; year++) { + result.push({ + label: `${year}`, + key: `year-${year}`, + }); + } + break; + } + + result.forEach(item => { + bottomHeaders.push(item); + }); + break; + } + + return { topHeaders, bottomHeaders }; + }, [viewMode, dateRange]); + + const { actualColumnWidth, totalWidth, shouldScroll } = useGanttDimensions( + viewMode, + containerRef, + bottomHeaders.length + ); + + const hasTopHeaders = topHeaders.length > 0; + + return ( +
+ {hasTopHeaders && ( +
+ {topHeaders.map(header => ( +
+ {header.label} +
+ ))} +
+ )} +
+ {bottomHeaders.map(header => ( +
{header.label}
))}
- )} -
- {bottomHeaders.map(header => ( -
- {header.label} -
- ))}
-
- ); -}); + ); + } +); GanttTimeline.displayName = 'GanttTimeline'; -export default memo(GanttTimeline); \ No newline at end of file +export default memo(GanttTimeline); diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-toolbar/GanttToolbar.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-toolbar/GanttToolbar.tsx index 4a7adcb3..d1a74db9 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-toolbar/GanttToolbar.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-toolbar/GanttToolbar.tsx @@ -1,6 +1,12 @@ import React, { memo } from 'react'; import { Select, Button, Space, Divider } from 'antd'; -import { ZoomInOutlined, ZoomOutOutlined, FullscreenOutlined, PlusOutlined, FlagOutlined } from '@ant-design/icons'; +import { + ZoomInOutlined, + ZoomOutOutlined, + FullscreenOutlined, + PlusOutlined, + FlagOutlined, +} from '@ant-design/icons'; import { GanttViewMode } from '../../types/gantt-types'; const { Option } = Select; @@ -13,58 +19,56 @@ interface GanttToolbarProps { onCreateTask?: () => void; } -const GanttToolbar: React.FC = memo(({ viewMode, onViewModeChange, dateRange, onCreatePhase, onCreateTask }) => { - return ( -
- - - - - - -
- ); -}); +const GanttToolbar: React.FC = memo( + ({ viewMode, onViewModeChange, dateRange, onCreatePhase, onCreateTask }) => { + return ( +
+ + + + + + +
+ ); + } +); GanttToolbar.displayName = 'GanttToolbar'; -export default GanttToolbar; \ No newline at end of file +export default GanttToolbar; diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/constants/gantt-constants.ts b/worklenz-frontend/src/pages/projects/projectView/gantt/constants/gantt-constants.ts index 0a2926aa..c50fcef3 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/constants/gantt-constants.ts +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/constants/gantt-constants.ts @@ -15,4 +15,4 @@ export const getColumnWidth = (viewMode: string): number => { default: return 80; } -}; \ No newline at end of file +}; diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/context/gantt-context.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/context/gantt-context.tsx index 20eaa4f9..2737b721 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/context/gantt-context.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/context/gantt-context.tsx @@ -16,4 +16,4 @@ export const useGanttContext = () => { throw new Error('useGanttContext must be used within a GanttProvider'); } return context; -}; \ No newline at end of file +}; diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/gantt-styles.css b/worklenz-frontend/src/pages/projects/projectView/gantt/gantt-styles.css index b007e583..b9b4cdac 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/gantt-styles.css +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/gantt-styles.css @@ -5,8 +5,8 @@ /* Hide scrollbar for IE, Edge and Firefox */ .scrollbar-hide { - -ms-overflow-style: none; /* IE and Edge */ - scrollbar-width: none; /* Firefox */ + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ } /* Gantt task list specific styles */ @@ -35,7 +35,6 @@ display: none; } - /* Gantt chart scrollbar - show both vertical and horizontal */ .gantt-chart-scroll::-webkit-scrollbar { width: 8px; @@ -108,7 +107,7 @@ } .gantt-phase-row::before { - content: ''; + content: ""; position: absolute; left: 0; top: 0; @@ -141,7 +140,9 @@ /* Phase expansion transitions */ .gantt-phase-children { overflow: hidden; - transition: max-height 0.3s ease-in-out, opacity 0.2s ease-in-out; + transition: + max-height 0.3s ease-in-out, + opacity 0.2s ease-in-out; } .gantt-phase-children.collapsed { @@ -194,4 +195,4 @@ opacity: 1; transform: translateY(0); } -} \ No newline at end of file +} diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/hooks/useGanttDimensions.ts b/worklenz-frontend/src/pages/projects/projectView/gantt/hooks/useGanttDimensions.ts index 27d8a18c..c05a6e8e 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/hooks/useGanttDimensions.ts +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/hooks/useGanttDimensions.ts @@ -23,15 +23,16 @@ export const useGanttDimensions = ( const baseColumnWidth = getColumnWidth(viewMode); const minTotalWidth = columnsCount * baseColumnWidth; - + // For day/week views with many columns, always use base width to enable scrolling // For month/quarter/year views, stretch to fill container if wider const shouldStretch = viewMode !== 'day' && viewMode !== 'week'; - - const actualColumnWidth = shouldStretch && containerWidth > minTotalWidth - ? containerWidth / columnsCount - : baseColumnWidth; - + + const actualColumnWidth = + shouldStretch && containerWidth > minTotalWidth + ? containerWidth / columnsCount + : baseColumnWidth; + const totalWidth = columnsCount * actualColumnWidth; return { @@ -39,6 +40,6 @@ export const useGanttDimensions = ( actualColumnWidth, totalWidth, columnsCount, - shouldScroll: totalWidth > containerWidth + shouldScroll: totalWidth > containerWidth, }; -}; \ No newline at end of file +}; diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/services/gantt-api.service.ts b/worklenz-frontend/src/pages/projects/projectView/gantt/services/gantt-api.service.ts index 571381a1..0573acd0 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/services/gantt-api.service.ts +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/services/gantt-api.service.ts @@ -21,7 +21,11 @@ export interface RoadmapTasksResponse { priority_name: string; priority_value: number; priority_color: string; - phase_id: string | null; + phases: Array<{ + phase_id: string; + phase_name: string; + phase_color: string; + }>; assignees: Array<{ team_member_id: string; assignee_name: string; @@ -41,7 +45,7 @@ export interface RoadmapTasksResponse { progress: number; roadmap_sort_order: number; parent_task_id: string; - phase_id: string | null; + phase_id?: string | null; // Keep this for subtasks compatibility }>; } @@ -52,6 +56,10 @@ export interface ProjectPhaseResponse { start_date: string | null; end_date: string | null; sort_index: number; + todo_progress: number; + doing_progress: number; + done_progress: number; + total_tasks: number; } export interface UpdateTaskDatesRequest { @@ -108,10 +116,7 @@ export const ganttApi = createApi({ }), tagTypes: ['GanttTasks', 'GanttPhases'], endpoints: builder => ({ - getRoadmapTasks: builder.query< - IServerResponse, - { projectId: string } - >({ + getRoadmapTasks: builder.query, { projectId: string }>({ query: ({ projectId }) => { const params = new URLSearchParams({ project_id: projectId, @@ -124,40 +129,31 @@ export const ganttApi = createApi({ ], }), - getProjectPhases: builder.query< - IServerResponse, - { projectId: string } - >({ - query: ({ projectId }) => { - const params = new URLSearchParams({ - project_id: projectId, - }); - return `${rootUrl}/project-phases?${params.toString()}`; - }, - providesTags: (result, error, { projectId }) => [ - { type: 'GanttPhases', id: projectId }, - { type: 'GanttPhases', id: 'LIST' }, - ], - }), + getProjectPhases: builder.query, { projectId: string }>( + { + query: ({ projectId }) => { + const params = new URLSearchParams({ + project_id: projectId, + }); + return `${rootUrl}/project-phases?${params.toString()}`; + }, + providesTags: (result, error, { projectId }) => [ + { type: 'GanttPhases', id: projectId }, + { type: 'GanttPhases', id: 'LIST' }, + ], + } + ), - updateTaskDates: builder.mutation< - IServerResponse, - UpdateTaskDatesRequest - >({ + updateTaskDates: builder.mutation, UpdateTaskDatesRequest>({ query: body => ({ url: `${rootUrl}/update-task-dates`, method: 'POST', body, }), - invalidatesTags: (result, error, { task_id }) => [ - { type: 'GanttTasks', id: 'LIST' }, - ], + invalidatesTags: (result, error, { task_id }) => [{ type: 'GanttTasks', id: 'LIST' }], }), - createPhase: builder.mutation< - IServerResponse, - CreatePhaseRequest - >({ + createPhase: builder.mutation, CreatePhaseRequest>({ query: body => ({ url: `${rootUrl}/create-phase`, method: 'POST', @@ -171,10 +167,7 @@ export const ganttApi = createApi({ ], }), - createTask: builder.mutation< - IServerResponse, - CreateTaskRequest - >({ + createTask: builder.mutation, CreateTaskRequest>({ query: body => ({ url: `${rootUrl}/create-task`, method: 'POST', @@ -186,10 +179,7 @@ export const ganttApi = createApi({ ], }), - updatePhase: builder.mutation< - IServerResponse, - UpdatePhaseRequest - >({ + updatePhase: builder.mutation, UpdatePhaseRequest>({ query: body => ({ url: `${rootUrl}/update-phase`, method: 'PUT', @@ -217,17 +207,23 @@ export const { /** * Transform API response to Gantt task format with phases as milestones */ -export const transformToGanttTasks = (apiTasks: RoadmapTasksResponse[], apiPhases: ProjectPhaseResponse[]): GanttTask[] => { +export const transformToGanttTasks = ( + apiTasks: RoadmapTasksResponse[], + apiPhases: ProjectPhaseResponse[] +): GanttTask[] => { // Group tasks by phase const tasksByPhase = new Map(); const unassignedTasks: RoadmapTasksResponse[] = []; apiTasks.forEach(task => { - if (task.phase_id) { - if (!tasksByPhase.has(task.phase_id)) { - tasksByPhase.set(task.phase_id, []); + // Tasks now have phases array instead of direct phase_id + const taskPhaseId = task.phases.length > 0 ? task.phases[0].phase_id : null; + + if (taskPhaseId) { + if (!tasksByPhase.has(taskPhaseId)) { + tasksByPhase.set(taskPhaseId, []); } - tasksByPhase.get(task.phase_id)!.push(task); + tasksByPhase.get(taskPhaseId)!.push(task); } else { unassignedTasks.push(task); } @@ -240,7 +236,7 @@ export const transformToGanttTasks = (apiTasks: RoadmapTasksResponse[], apiPhase .sort((a, b) => a.sort_index - b.sort_index) .forEach(phase => { const phaseTasks = tasksByPhase.get(phase.id) || []; - + // Create phase milestone const phaseMilestone: GanttTask = { id: `phase-${phase.id}`, @@ -254,7 +250,12 @@ export const transformToGanttTasks = (apiTasks: RoadmapTasksResponse[], apiPhase type: 'milestone', is_milestone: true, phase_id: phase.id, - children: phaseTasks.map(task => transformTask(task, 1)) + // Pass through phase progress data from backend + todo_progress: phase.todo_progress, + doing_progress: phase.doing_progress, + done_progress: phase.done_progress, + total_tasks: phase.total_tasks, + children: phaseTasks.map(task => transformTask(task, 1)), }; result.push(phaseMilestone); @@ -273,7 +274,7 @@ export const transformToGanttTasks = (apiTasks: RoadmapTasksResponse[], apiPhase type: 'milestone', is_milestone: true, phase_id: null, - children: unassignedTasks.map(task => transformTask(task, 1)) + children: unassignedTasks.map(task => transformTask(task, 1)), }; result.push(unmappedPhase); @@ -284,36 +285,40 @@ export const transformToGanttTasks = (apiTasks: RoadmapTasksResponse[], apiPhase /** * Helper function to transform individual task */ -const transformTask = (task: RoadmapTasksResponse, level: number = 0): GanttTask => ({ - id: task.id, - name: task.name, - start_date: task.start_date ? new Date(task.start_date) : null, - end_date: task.end_date ? new Date(task.end_date) : null, - progress: task.progress, - dependencies: task.dependencies.map(dep => dep.related_task_id), - dependencyType: task.dependencies[0]?.dependency_type as any || 'blocked_by', - parent_id: task.parent_task_id, - children: task.subtasks.map(subtask => ({ - id: subtask.id, - name: subtask.name, - start_date: subtask.start_date ? new Date(subtask.start_date) : null, - end_date: subtask.end_date ? new Date(subtask.end_date) : null, - progress: subtask.progress, - parent_id: subtask.parent_task_id, - level: level + 1, +const transformTask = (task: RoadmapTasksResponse, level: number = 0): GanttTask => { + const taskPhaseId = task.phases.length > 0 ? task.phases[0].phase_id : null; + + return { + id: task.id, + name: task.name, + start_date: task.start_date ? new Date(task.start_date) : null, + end_date: task.end_date ? new Date(task.end_date) : null, + progress: task.progress, + dependencies: task.dependencies.map(dep => dep.related_task_id), + dependencyType: (task.dependencies[0]?.dependency_type as any) || 'blocked_by', + parent_id: task.parent_task_id, + children: task.subtasks.map(subtask => ({ + id: subtask.id, + name: subtask.name, + start_date: subtask.start_date ? new Date(subtask.start_date) : null, + end_date: subtask.end_date ? new Date(subtask.end_date) : null, + progress: subtask.progress, + parent_id: subtask.parent_task_id, + level: level + 1, + type: 'task', + phase_id: subtask.phase_id, // Subtasks might still use direct phase_id + })), + level, + expanded: true, + color: task.status_color || task.priority_color, + assignees: task.assignees.map(a => a.assignee_name), + priority: task.priority_name, + status: task.status_name, + phase_id: taskPhaseId, + is_milestone: false, type: 'task', - phase_id: subtask.phase_id - })), - level, - expanded: true, - color: task.status_color || task.priority_color, - assignees: task.assignees.map(a => a.assignee_name), - priority: task.priority_name, - status: task.status_name, - phase_id: task.phase_id, - is_milestone: false, - type: 'task' -}); + }; +}; /** * Transform API response to Gantt phases format @@ -325,6 +330,10 @@ export const transformToGanttPhases = (apiPhases: ProjectPhaseResponse[]): Gantt color_code: phase.color_code, start_date: phase.start_date ? new Date(phase.start_date) : null, end_date: phase.end_date ? new Date(phase.end_date) : null, - sort_index: phase.sort_index + sort_index: phase.sort_index, + todo_progress: phase.todo_progress, + doing_progress: phase.doing_progress, + done_progress: phase.done_progress, + total_tasks: phase.total_tasks, })); -}; \ No newline at end of file +}; diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/types/gantt-types.ts b/worklenz-frontend/src/pages/projects/projectView/gantt/types/gantt-types.ts index c7837661..9c09838f 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/types/gantt-types.ts +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/types/gantt-types.ts @@ -1,6 +1,11 @@ export type GanttViewMode = 'day' | 'week' | 'month' | 'quarter' | 'year'; -export type DependencyType = 'blocked_by' | 'finish_to_start' | 'start_to_start' | 'finish_to_finish' | 'start_to_finish'; +export type DependencyType = + | 'blocked_by' + | 'finish_to_start' + | 'start_to_start' + | 'finish_to_finish' + | 'start_to_finish'; export interface GanttTask { id: string; @@ -20,7 +25,9 @@ export interface GanttTask { status?: string; phase_id?: string; is_milestone?: boolean; - type?: 'task' | 'milestone' | 'phase'; + type?: 'task' | 'milestone' | 'phase' | 'add-task-button'; + // Add task row specific properties + parent_phase_id?: string; } export interface GanttPhase { @@ -53,4 +60,4 @@ export interface GanttContextType { projectId: string; dateRange: { start: Date; end: Date }; onRefresh: () => void; -} \ No newline at end of file +} diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/utils/timeline-calculator.ts b/worklenz-frontend/src/pages/projects/projectView/gantt/utils/timeline-calculator.ts index fc113719..010d7669 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/utils/timeline-calculator.ts +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/utils/timeline-calculator.ts @@ -18,12 +18,7 @@ export class TimelineCalculator { private columnWidth: number; private timelineBounds: TimelineBounds; - constructor( - viewMode: GanttViewMode, - columnWidth: number, - startDate: Date, - endDate: Date - ) { + constructor(viewMode: GanttViewMode, columnWidth: number, startDate: Date, endDate: Date) { this.viewMode = viewMode; this.columnWidth = columnWidth; this.timelineBounds = this.calculateTimelineBounds(startDate, endDate); @@ -42,7 +37,7 @@ export class TimelineCalculator { startDate, endDate, totalDays, - pixelsPerDay + pixelsPerDay, }; } @@ -51,12 +46,18 @@ export class TimelineCalculator { */ private getColumnsCount(): number { switch (this.viewMode) { - case 'day': return 30; - case 'week': return 12; - case 'month': return 12; - case 'quarter': return 8; - case 'year': return 5; - default: return 12; + case 'day': + return 30; + case 'week': + return 12; + case 'month': + return 12; + case 'quarter': + return 8; + case 'year': + return 5; + default: + return 12; } } @@ -70,14 +71,20 @@ export class TimelineCalculator { const taskStart = new Date(task.start_date); const taskEnd = new Date(task.end_date); - + // Ensure task dates are within timeline bounds - const clampedStart = new Date(Math.max(taskStart.getTime(), this.timelineBounds.startDate.getTime())); + const clampedStart = new Date( + Math.max(taskStart.getTime(), this.timelineBounds.startDate.getTime()) + ); const clampedEnd = new Date(Math.min(taskEnd.getTime(), this.timelineBounds.endDate.getTime())); // Calculate days from timeline start - const daysFromStart = Math.floor((clampedStart.getTime() - this.timelineBounds.startDate.getTime()) / (1000 * 60 * 60 * 24)); - const taskDuration = Math.ceil((clampedEnd.getTime() - clampedStart.getTime()) / (1000 * 60 * 60 * 24)); + const daysFromStart = Math.floor( + (clampedStart.getTime() - this.timelineBounds.startDate.getTime()) / (1000 * 60 * 60 * 24) + ); + const taskDuration = Math.ceil( + (clampedEnd.getTime() - clampedStart.getTime()) / (1000 * 60 * 60 * 24) + ); // Calculate pixel positions const left = daysFromStart * this.timelineBounds.pixelsPerDay; @@ -86,7 +93,7 @@ export class TimelineCalculator { return { left: Math.max(0, left), width, - isValid: true + isValid: true, }; } @@ -99,18 +106,23 @@ export class TimelineCalculator { } const milestoneDate = new Date(date); - + // Check if milestone is within timeline bounds - if (milestoneDate < this.timelineBounds.startDate || milestoneDate > this.timelineBounds.endDate) { + if ( + milestoneDate < this.timelineBounds.startDate || + milestoneDate > this.timelineBounds.endDate + ) { return { left: 0, isValid: false }; } - const daysFromStart = Math.floor((milestoneDate.getTime() - this.timelineBounds.startDate.getTime()) / (1000 * 60 * 60 * 24)); + const daysFromStart = Math.floor( + (milestoneDate.getTime() - this.timelineBounds.startDate.getTime()) / (1000 * 60 * 60 * 24) + ); const left = daysFromStart * this.timelineBounds.pixelsPerDay; return { left: Math.max(0, left), - isValid: true + isValid: true, }; } @@ -118,8 +130,8 @@ export class TimelineCalculator { * Calculate dependency line coordinates */ calculateDependencyLine( - fromTask: GanttTask, - toTask: GanttTask, + fromTask: GanttTask, + toTask: GanttTask, rowHeight: number = 36 ): { x1: number; @@ -144,7 +156,7 @@ export class TimelineCalculator { y1: fromY + rowHeight / 2, x2: toPosition.left, // Start of target task y2: toY + rowHeight / 2, - isValid: true + isValid: true, }; } @@ -164,10 +176,10 @@ export class TimelineCalculator { getTodayLinePosition(): { left: number; isVisible: boolean } { const today = new Date(); const position = this.calculateMilestonePosition(today); - + return { left: position.left, - isVisible: position.isValid + isVisible: position.isValid, }; } @@ -177,7 +189,7 @@ export class TimelineCalculator { getWeekendAreas(): Array<{ left: number; width: number }> { const weekendAreas: Array<{ left: number; width: number }> = []; const current = new Date(this.timelineBounds.startDate); - + while (current <= this.timelineBounds.endDate) { // Saturday (6) and Sunday (0) if (current.getDay() === 0 || current.getDay() === 6) { @@ -185,13 +197,13 @@ export class TimelineCalculator { if (position.isValid) { weekendAreas.push({ left: position.left, - width: this.timelineBounds.pixelsPerDay + width: this.timelineBounds.pixelsPerDay, }); } } current.setDate(current.getDate() + 1); } - + return weekendAreas; } @@ -205,7 +217,12 @@ export class TimelineCalculator { /** * Update calculator with new parameters */ - updateParameters(viewMode: GanttViewMode, columnWidth: number, startDate: Date, endDate: Date): void { + updateParameters( + viewMode: GanttViewMode, + columnWidth: number, + startDate: Date, + endDate: Date + ): void { this.viewMode = viewMode; this.columnWidth = columnWidth; this.timelineBounds = this.calculateTimelineBounds(startDate, endDate); @@ -244,7 +261,7 @@ export const TimelineUtils = { latestEnd = task.end_date; } } - + // Check subtasks too if (task.children) { task.children.forEach(subtask => { @@ -316,6 +333,6 @@ export const TimelineUtils = { const dayNum = d.getUTCDay() || 7; d.setUTCDate(d.getUTCDate() + 4 - dayNum); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); - return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); - } -}; \ No newline at end of file + return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7); + }, +}; diff --git a/worklenz-frontend/src/types/advanced-gantt.types.ts b/worklenz-frontend/src/types/advanced-gantt.types.ts index e080df40..699b93de 100644 --- a/worklenz-frontend/src/types/advanced-gantt.types.ts +++ b/worklenz-frontend/src/types/advanced-gantt.types.ts @@ -188,16 +188,16 @@ export interface AdvancedGanttProps { // Data tasks: GanttTask[]; columns?: ColumnConfig[]; - + // Configuration timelineConfig?: Partial; virtualScrollConfig?: Partial; zoomLevels?: ZoomLevel[]; - + // Initial State initialViewState?: Partial; initialSelection?: string[]; - + // Event Handlers onTaskUpdate?: (taskId: string, updates: Partial) => void; onTaskCreate?: (task: Omit) => void; @@ -209,13 +209,13 @@ export interface AdvancedGanttProps { onColumnResize?: ColumnResizeHandler; onDependencyCreate?: (fromTaskId: string, toTaskId: string) => void; onDependencyDelete?: (fromTaskId: string, toTaskId: string) => void; - + // UI Customization className?: string; style?: React.CSSProperties; theme?: 'light' | 'dark' | 'auto'; locale?: string; - + // Feature Flags enableDragDrop?: boolean; enableResize?: boolean; @@ -225,7 +225,7 @@ export interface AdvancedGanttProps { enableTooltips?: boolean; enableExport?: boolean; enablePrint?: boolean; - + // Performance Options enableVirtualScrolling?: boolean; enableDebouncing?: boolean; @@ -258,7 +258,14 @@ export interface ExportOptions { // Filter and Search export interface FilterConfig { field: string; - operator: 'equals' | 'contains' | 'startsWith' | 'endsWith' | 'greaterThan' | 'lessThan' | 'between'; + operator: + | 'equals' + | 'contains' + | 'startsWith' + | 'endsWith' + | 'greaterThan' + | 'lessThan' + | 'between'; value: any; logic?: 'and' | 'or'; } @@ -304,4 +311,4 @@ export interface KeyboardShortcut { action: string; description: string; handler: (event: KeyboardEvent) => void; -} \ No newline at end of file +} diff --git a/worklenz-frontend/src/utils/gantt-performance.ts b/worklenz-frontend/src/utils/gantt-performance.ts index 5116c186..c3a65001 100644 --- a/worklenz-frontend/src/utils/gantt-performance.ts +++ b/worklenz-frontend/src/utils/gantt-performance.ts @@ -2,37 +2,37 @@ import { useMemo, useCallback, useRef, useEffect } from 'react'; import { GanttTask, PerformanceMetrics } from '../types/advanced-gantt.types'; // Debounce utility for drag operations -export function useDebounce any>( - callback: T, - delay: number -): T { +export function useDebounce any>(callback: T, delay: number): T { const timeoutRef = useRef(); - - return useCallback((...args: Parameters) => { - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - - timeoutRef.current = setTimeout(() => { - callback(...args); - }, delay); - }, [callback, delay]) as T; + + return useCallback( + (...args: Parameters) => { + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); + } + + timeoutRef.current = setTimeout(() => { + callback(...args); + }, delay); + }, + [callback, delay] + ) as T; } // Throttle utility for scroll events -export function useThrottle any>( - callback: T, - delay: number -): T { +export function useThrottle any>(callback: T, delay: number): T { const lastCall = useRef(0); - - return useCallback((...args: Parameters) => { - const now = Date.now(); - if (now - lastCall.current >= delay) { - lastCall.current = now; - callback(...args); - } - }, [callback, delay]) as T; + + return useCallback( + (...args: Parameters) => { + const now = Date.now(); + if (now - lastCall.current >= delay) { + lastCall.current = now; + callback(...args); + } + }, + [callback, delay] + ) as T; } // Memoized task calculations @@ -41,23 +41,23 @@ export const useTaskCalculations = (tasks: GanttTask[]) => { const taskMap = new Map(); const parentChildMap = new Map(); const dependencyMap = new Map(); - + // Build maps for efficient lookups tasks.forEach(task => { taskMap.set(task.id, task); - + if (task.parent) { if (!parentChildMap.has(task.parent)) { parentChildMap.set(task.parent, []); } parentChildMap.get(task.parent)!.push(task.id); } - + if (task.dependencies) { dependencyMap.set(task.id, task.dependencies); } }); - + return { taskMap, parentChildMap, @@ -95,7 +95,7 @@ export const useVirtualScrolling = ( ); const visibleItems = tasks.slice(startIndex, endIndex + 1); const offsetY = startIndex * itemHeight; - + return { startIndex, endIndex, @@ -124,29 +124,31 @@ export const useTimelineVirtualScrolling = ( overscan: number = 10 ): TimelineVirtualData => { return useMemo(() => { - const totalDays = Math.ceil((projectEndDate.getTime() - projectStartDate.getTime()) / (1000 * 60 * 60 * 24)); + const totalDays = Math.ceil( + (projectEndDate.getTime() - projectStartDate.getTime()) / (1000 * 60 * 60 * 24) + ); const totalWidth = totalDays * dayWidth; - + const startDayIndex = Math.max(0, Math.floor(scrollLeft / dayWidth) - overscan); const endDayIndex = Math.min( totalDays - 1, Math.ceil((scrollLeft + containerWidth) / dayWidth) + overscan ); - + const visibleDays: Date[] = []; for (let i = startDayIndex; i <= endDayIndex; i++) { const date = new Date(projectStartDate); date.setDate(date.getDate() + i); visibleDays.push(date); } - + const offsetX = startDayIndex * dayWidth; const startDate = new Date(projectStartDate); startDate.setDate(startDate.getDate() + startDayIndex); - + const endDate = new Date(projectStartDate); endDate.setDate(endDate.getDate() + endDayIndex); - + return { startDate, endDate, @@ -169,25 +171,25 @@ export const usePerformanceMonitoring = (): { taskCount: 0, visibleTaskCount: 0, }); - + const measurementsRef = useRef>(new Map()); - + const startMeasure = useCallback((name: string) => { measurementsRef.current.set(name, performance.now()); }, []); - + const endMeasure = useCallback((name: string) => { const startTime = measurementsRef.current.get(name); if (startTime) { const duration = performance.now() - startTime; measurementsRef.current.delete(name); - + if (name === 'render') { metricsRef.current.renderTime = duration; } } }, []); - + const recordMetric = useCallback((name: string, value: number) => { switch (name) { case 'taskCount': @@ -204,7 +206,7 @@ export const usePerformanceMonitoring = (): { break; } }, []); - + return { metrics: metricsRef.current, startMeasure, @@ -220,26 +222,26 @@ export const useIntersectionObserver = ( ) => { const targetRef = useRef(null); const observerRef = useRef(); - + useEffect(() => { if (!targetRef.current) return; - + observerRef.current = new IntersectionObserver(callback, { root: null, rootMargin: '100px', threshold: 0.1, ...options, }); - + observerRef.current.observe(targetRef.current); - + return () => { if (observerRef.current) { observerRef.current.disconnect(); } }; }, [callback, options]); - + return targetRef; }; @@ -247,47 +249,47 @@ export const useIntersectionObserver = ( export const useDateCalculations = () => { return useMemo(() => { const cache = new Map(); - + const getDaysBetween = (start: Date, end: Date): number => { const key = `${start.getTime()}-${end.getTime()}`; if (cache.has(key)) { return cache.get(key)!; } - + const days = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); cache.set(key, days); return days; }; - + const addDays = (date: Date, days: number): Date => { const result = new Date(date); result.setDate(result.getDate() + days); return result; }; - + const isWeekend = (date: Date): boolean => { const day = date.getDay(); return day === 0 || day === 6; // Sunday or Saturday }; - + const isWorkingDay = (date: Date, workingDays: number[]): boolean => { return workingDays.includes(date.getDay()); }; - + const getWorkingDaysBetween = (start: Date, end: Date, workingDays: number[]): number => { let count = 0; const current = new Date(start); - + while (current <= end) { if (isWorkingDay(current, workingDays)) { count++; } current.setDate(current.getDate() + 1); } - + return count; }; - + return { getDaysBetween, addDays, @@ -300,25 +302,25 @@ export const useDateCalculations = () => { }; // Task position calculations -export const useTaskPositions = ( - tasks: GanttTask[], - timelineStart: Date, - dayWidth: number -) => { +export const useTaskPositions = (tasks: GanttTask[], timelineStart: Date, dayWidth: number) => { return useMemo(() => { const positions = new Map(); - + tasks.forEach((task, index) => { - const startDays = Math.floor((task.startDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24)); - const endDays = Math.floor((task.endDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24)); - + const startDays = Math.floor( + (task.startDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24) + ); + const endDays = Math.floor( + (task.endDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24) + ); + positions.set(task.id, { x: startDays * dayWidth, width: Math.max(1, (endDays - startDays) * dayWidth), y: index * 40, // Assuming 40px row height }); }); - + return positions; }, [tasks, timelineStart, dayWidth]); }; @@ -326,57 +328,57 @@ export const useTaskPositions = ( // Memory management utilities export const useMemoryManagement = () => { const cleanupFunctions = useRef void>>([]); - + const addCleanup = useCallback((cleanup: () => void) => { cleanupFunctions.current.push(cleanup); }, []); - + const runCleanup = useCallback(() => { cleanupFunctions.current.forEach(cleanup => cleanup()); cleanupFunctions.current = []; }, []); - + useEffect(() => { return runCleanup; }, [runCleanup]); - + return { addCleanup, runCleanup }; }; // Batch update utility for multiple task changes -export const useBatchUpdates = ( - updateFunction: (updates: T[]) => void, - delay: number = 100 -) => { +export const useBatchUpdates = (updateFunction: (updates: T[]) => void, delay: number = 100) => { const batchRef = useRef([]); const timeoutRef = useRef(); - - const addUpdate = useCallback((update: T) => { - batchRef.current.push(update); - - if (timeoutRef.current) { - clearTimeout(timeoutRef.current); - } - - timeoutRef.current = setTimeout(() => { - if (batchRef.current.length > 0) { - updateFunction([...batchRef.current]); - batchRef.current = []; + + const addUpdate = useCallback( + (update: T) => { + batchRef.current.push(update); + + if (timeoutRef.current) { + clearTimeout(timeoutRef.current); } - }, delay); - }, [updateFunction, delay]); - + + timeoutRef.current = setTimeout(() => { + if (batchRef.current.length > 0) { + updateFunction([...batchRef.current]); + batchRef.current = []; + } + }, delay); + }, + [updateFunction, delay] + ); + const flushUpdates = useCallback(() => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); } - + if (batchRef.current.length > 0) { updateFunction([...batchRef.current]); batchRef.current = []; } }, [updateFunction]); - + return { addUpdate, flushUpdates }; }; @@ -385,24 +387,24 @@ export const useFPSMonitoring = () => { const fpsRef = useRef(0); const frameCountRef = useRef(0); const lastTimeRef = useRef(performance.now()); - + const measureFPS = useCallback(() => { frameCountRef.current++; const now = performance.now(); - + if (now - lastTimeRef.current >= 1000) { fpsRef.current = Math.round((frameCountRef.current * 1000) / (now - lastTimeRef.current)); frameCountRef.current = 0; lastTimeRef.current = now; } - + requestAnimationFrame(measureFPS); }, []); - + useEffect(() => { const rafId = requestAnimationFrame(measureFPS); return () => cancelAnimationFrame(rafId); }, [measureFPS]); - + return fpsRef.current; -}; \ No newline at end of file +};