From 9c4293e7a914ba2073b759395ec0d251cab9a0e0 Mon Sep 17 00:00:00 2001 From: chamiakJ Date: Mon, 4 Aug 2025 07:22:56 +0530 Subject: [PATCH] feat(gantt): implement Gantt chart functionality with roadmap tasks and project phases - Added GanttController with endpoints for fetching roadmap tasks and project phases. - Implemented task date update functionality in the GanttController. - Created Gantt components including GanttChart, GanttTaskList, GanttTimeline, and GanttToolbar for rendering the Gantt interface. - Integrated Redux Toolkit Query for API interactions related to Gantt tasks and phases. - Established context for Gantt state management and utility functions for timeline calculations. - Enhanced styling for Gantt components and added responsive design features. - Introduced drag-and-drop functionality for task management within the Gantt chart. --- .../src/controllers/gantt-controller.ts | 129 ++++ .../src/routes/apis/gantt-api-router.ts | 5 + worklenz-frontend/src/app/store.ts | 4 +- .../src/lib/project/project-view-constants.ts | 17 + .../components/gantt-chart/gantt-chart.tsx | 248 ++++++++ .../gantt-task-list/gantt-task-list.tsx | 565 ++++++++++++++++++ .../gantt-timeline/gantt-timeline.tsx | 156 +++++ .../gantt-toolbar/gantt-toolbar.tsx | 70 +++ .../gantt/constants/gantt-constants.ts | 18 + .../gantt/context/gantt-context.tsx | 19 + .../projectView/gantt/gantt-styles.css | 110 ++++ .../gantt/hooks/useGanttDimensions.ts | 44 ++ .../projectView/gantt/project-view-gantt.tsx | 245 ++++++++ .../gantt/services/gantt-api.service.ts | 303 ++++++++++ .../projectView/gantt/types/gantt-types.ts | 56 ++ .../gantt/utils/timeline-calculator.ts | 321 ++++++++++ worklenz-frontend/tailwind.config.js | 6 + 17 files changed, 2315 insertions(+), 1 deletion(-) create mode 100644 worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-chart/gantt-chart.tsx create mode 100644 worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-task-list/gantt-task-list.tsx create mode 100644 worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-timeline/gantt-timeline.tsx create mode 100644 worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-toolbar/gantt-toolbar.tsx create mode 100644 worklenz-frontend/src/pages/projects/projectView/gantt/constants/gantt-constants.ts create mode 100644 worklenz-frontend/src/pages/projects/projectView/gantt/context/gantt-context.tsx create mode 100644 worklenz-frontend/src/pages/projects/projectView/gantt/gantt-styles.css create mode 100644 worklenz-frontend/src/pages/projects/projectView/gantt/hooks/useGanttDimensions.ts create mode 100644 worklenz-frontend/src/pages/projects/projectView/gantt/project-view-gantt.tsx create mode 100644 worklenz-frontend/src/pages/projects/projectView/gantt/services/gantt-api.service.ts create mode 100644 worklenz-frontend/src/pages/projects/projectView/gantt/types/gantt-types.ts create mode 100644 worklenz-frontend/src/pages/projects/projectView/gantt/utils/timeline-calculator.ts diff --git a/worklenz-backend/src/controllers/gantt-controller.ts b/worklenz-backend/src/controllers/gantt-controller.ts index 425cb96b..155af606 100644 --- a/worklenz-backend/src/controllers/gantt-controller.ts +++ b/worklenz-backend/src/controllers/gantt-controller.ts @@ -94,4 +94,133 @@ export default class GanttController extends WorklenzControllerBase { } return res.status(200).send(new ServerResponse(true, result.rows)); } + + @HandleExceptions() + public static async getRoadmapTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const projectId = req.query.project_id; + + const q = ` + SELECT + t.id, + t.name, + t.start_date, + t.end_date, + t.done, + t.roadmap_sort_order, + t.parent_task_id, + CASE WHEN t.done THEN 100 ELSE 0 END as progress, + ts.name as status_name, + tsc.color_code as status_color, + tp.name as priority_name, + tp.value as priority_value, + tp.color_code as priority_color, + ( + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(assignee_info))), '[]'::JSON) + FROM ( + SELECT + tm.id as team_member_id, + u.name as assignee_name, + u.avatar_url + FROM tasks_assignees ta + JOIN team_members tm ON ta.team_member_id = tm.id + JOIN users u ON tm.user_id = u.id + WHERE ta.task_id = t.id + ) assignee_info + ) as assignees, + ( + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(phase_info))), '[]'::JSON) + FROM ( + SELECT + pp.id as phase_id, + pp.name as phase_name, + pp.color_code as phase_color + FROM task_phase tp + JOIN project_phases pp ON tp.phase_id = pp.id + WHERE tp.task_id = t.id + ) phase_info + ) as phases, + ( + SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(dependency_info))), '[]'::JSON) + FROM ( + SELECT + td.related_task_id, + td.dependency_type, + rt.name as related_task_name + FROM task_dependencies td + JOIN tasks rt ON td.related_task_id = rt.id + WHERE td.task_id = t.id + ) dependency_info + ) as dependencies + FROM tasks t + LEFT JOIN task_statuses ts ON t.status_id = ts.id + LEFT JOIN sys_task_status_categories tsc ON ts.category_id = tsc.id + LEFT JOIN task_priorities tp ON t.priority_id = tp.id + WHERE t.project_id = $1 + AND t.archived = FALSE + AND t.parent_task_id IS NULL + ORDER BY t.roadmap_sort_order, t.created_at DESC; + `; + + const result = await db.query(q, [projectId]); + + // Get subtasks for each parent task + for (const task of result.rows) { + const subtasksQuery = ` + SELECT + id, + name, + start_date, + end_date, + done, + roadmap_sort_order, + parent_task_id, + CASE WHEN done THEN 100 ELSE 0 END as progress + FROM tasks + WHERE parent_task_id = $1 + AND archived = FALSE + ORDER BY roadmap_sort_order, created_at DESC; + `; + + const subtasksResult = await db.query(subtasksQuery, [task.id]); + task.subtasks = subtasksResult.rows; + } + + return res.status(200).send(new ServerResponse(true, result.rows)); + } + + @HandleExceptions() + public static async getProjectPhases(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const projectId = req.query.project_id; + + 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; + `; + + const result = await db.query(q, [projectId]); + return res.status(200).send(new ServerResponse(true, result.rows)); + } + + @HandleExceptions() + public static async updateTaskDates(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const { task_id, start_date, end_date } = req.body; + + const q = ` + UPDATE tasks + SET start_date = $2, end_date = $3, updated_at = NOW() + WHERE id = $1 + RETURNING id, start_date, end_date; + `; + + const result = await db.query(q, [task_id, start_date, end_date]); + return res.status(200).send(new ServerResponse(true, result.rows[0])); + } } diff --git a/worklenz-backend/src/routes/apis/gantt-api-router.ts b/worklenz-backend/src/routes/apis/gantt-api-router.ts index a3478578..9b80a8bd 100644 --- a/worklenz-backend/src/routes/apis/gantt-api-router.ts +++ b/worklenz-backend/src/routes/apis/gantt-api-router.ts @@ -12,4 +12,9 @@ ganttApiRouter.get("/project-phases/:id", safeControllerFunction(GanttController ganttApiRouter.get("/project-workload", safeControllerFunction(GanttController.getWorkload)); +// New roadmap Gantt APIs +ganttApiRouter.get("/roadmap-tasks", safeControllerFunction(GanttController.getRoadmapTasks)); +ganttApiRouter.get("/project-phases", safeControllerFunction(GanttController.getProjectPhases)); +ganttApiRouter.post("/update-task-dates", safeControllerFunction(GanttController.updateTaskDates)); + export default ganttApiRouter; \ No newline at end of file diff --git a/worklenz-frontend/src/app/store.ts b/worklenz-frontend/src/app/store.ts index 4190f3a9..f5712a73 100644 --- a/worklenz-frontend/src/app/store.ts +++ b/worklenz-frontend/src/app/store.ts @@ -84,6 +84,7 @@ import selectionReducer from '@/features/task-management/selection.slice'; import homePageApiService from '@/api/home-page/home-page.api.service'; import { projectsApi } from '@/api/projects/projects.v1.api.service'; import { userActivityApiService } from '@/api/home-page/user-activity.api.service'; +import { ganttApi } from '@/pages/projects/projectView/gantt/services/gantt-api.service'; import projectViewReducer from '@features/project/project-view-slice'; import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice'; @@ -92,7 +93,7 @@ export const store = configureStore({ middleware: getDefaultMiddleware => getDefaultMiddleware({ serializableCheck: false, - }).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware), + }).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware, ganttApi.middleware), reducer: { // Auth & User auth: authReducer, @@ -105,6 +106,7 @@ export const store = configureStore({ homePageReducer: homePageReducer, [homePageApiService.reducerPath]: homePageApiService.reducer, [projectsApi.reducerPath]: projectsApi.reducer, + [ganttApi.reducerPath]: ganttApi.reducer, userActivityReducer: userActivityReducer, [userActivityApiService.reducerPath]: userActivityApiService.reducer, diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index c6b71a79..0e1e0ac0 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -19,6 +19,9 @@ const ProjectViewMembers = React.lazy( const ProjectViewUpdates = React.lazy( () => import('@/pages/projects/project-view-1/updates/project-view-updates') ); +const ProjectViewGantt = React.lazy( + () => import('@/pages/projects/projectView/gantt/project-view-gantt') +); // type of a tab items type TabItems = { @@ -43,6 +46,7 @@ const getTabLabel = (key: string): string => { files: 'Files', members: 'Members', updates: 'Updates', + gantt: 'Gantt Chart', }; return fallbacks[key] || key; } @@ -117,6 +121,16 @@ export const tabItems: TabItems[] = [ React.createElement(ProjectViewUpdates) ), }, + { + index: 6, + key: 'gantt', + label: getTabLabel('gantt'), + element: React.createElement( + Suspense, + { fallback: React.createElement(InlineSuspenseFallback) }, + React.createElement(ProjectViewGantt) + ), + }, ]; // Function to update tab labels when language changes @@ -142,6 +156,9 @@ export const updateTabLabels = () => { case 'updates': item.label = getTabLabel('updates'); break; + case 'gantt': + item.label = getTabLabel('gantt'); + break; } }); } catch (error) { diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-chart/gantt-chart.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-chart/gantt-chart.tsx new file mode 100644 index 00000000..bdac2ef8 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-chart/gantt-chart.tsx @@ -0,0 +1,248 @@ +import React, { memo, useMemo, forwardRef, RefObject } from 'react'; +import { GanttTask, GanttViewMode, GanttPhase } from '../../types/gantt-types'; +import { useGanttDimensions } from '../../hooks/useGanttDimensions'; + +interface GanttChartProps { + tasks: GanttTask[]; + viewMode: GanttViewMode; + onScroll?: (e: React.UIEvent) => void; + containerRef: RefObject; + dateRange?: { start: Date; end: Date }; + phases?: GanttPhase[]; + expandedTasks?: Set; +} + +interface GridColumnProps { + index: number; + columnWidth: number; +} + +const GridColumn: React.FC = memo(({ index, columnWidth }) => ( +
+)); + +GridColumn.displayName = 'GridColumn'; + +interface TaskBarRowProps { + task: GanttTask; + viewMode: GanttViewMode; + columnWidth: number; + columnsCount: number; + dateRange?: { start: Date; end: Date }; +} + +const TaskBarRow: React.FC = 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 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 && ( +
+ )} +
+ ); + }; + + return ( +
+ {task.type === 'milestone' || task.is_milestone ? 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 + 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 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]); + + const { actualColumnWidth, totalWidth, shouldScroll } = useGanttDimensions( + viewMode, + containerRef, + 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]); + + return ( +
+
+
+ {/* Grid columns for timeline */} + {gridColumns.map(index => ( + + ))} +
+
+ {flattenedTasks.map(item => { + if ('isEmptyRow' in item && item.isEmptyRow) { + // Render empty row for "Add Task" placeholder + return ( +
+ ); + } + return ( + + ); + })} + {flattenedTasks.length === 0 && ( +
+ No tasks to display +
+ )} +
+
+
+ ); +}); + +GanttChart.displayName = 'GanttChart'; + +export default memo(GanttChart); \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-task-list/gantt-task-list.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-task-list/gantt-task-list.tsx new file mode 100644 index 00000000..a6492951 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-task-list/gantt-task-list.tsx @@ -0,0 +1,565 @@ +import React, { memo, useCallback, useState, forwardRef } from 'react'; +import { RightOutlined, DownOutlined, StarOutlined, PlusOutlined, HolderOutlined } from '@ant-design/icons'; +import { Button, Tooltip, Input } from 'antd'; +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 } from '../../types/gantt-types'; +import { useSocket } from '../../../../../../socket/socketContext'; +import { SocketEvents } from '../../../../../../shared/socket-events'; +import { useAppDispatch } from '../../../../../../hooks/useAppDispatch'; +import { addTask } from '../../../../../../features/task-management/task-management.slice'; +import { useAuthService } from '../../../../../../hooks/useAuth'; + +interface GanttTaskListProps { + tasks: GanttTask[]; + projectId: string; + onTaskToggle?: (taskId: string) => void; + onCreateTask?: (phaseId?: string) => void; + onCreateQuickTask?: (taskName: string, phaseId?: string) => void; + onPhaseReorder?: (oldIndex: number, newIndex: number) => void; + onScroll?: (e: React.UIEvent) => void; + expandedTasks?: Set; + onExpandedTasksChange?: (expanded: Set) => void; +} + +interface TaskRowProps { + task: GanttTask; + index: number; + projectId: string; + onToggle?: (taskId: string) => void; + expandedTasks: Set; + onCreateTask?: (phaseId?: string) => void; + onCreateQuickTask?: (taskName: string, phaseId?: string) => void; + isDraggable?: boolean; + activeId?: string | null; + overId?: string | null; +} + +interface SortableTaskRowProps extends TaskRowProps { + id: string; +} + +// Sortable wrapper for phase milestones +const SortableTaskRow: React.FC = memo((props) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ id: props.id }); + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.7 : 1, + }; + + return ( +
+ +
+ ); +}); + +SortableTaskRow.displayName = 'SortableTaskRow'; + +const TaskRow: React.FC = memo(({ + task, + projectId, + onToggle, + expandedTasks, + onCreateTask, + onCreateQuickTask, + isDraggable = false, + dragAttributes, + dragListeners +}) => { + const [showInlineInput, setShowInlineInput] = useState(false); + const [taskName, setTaskName] = useState(''); + const { socket, connected } = useSocket(); + const dispatch = useAppDispatch(); + 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 hasChildren = task.children && task.children.length > 0; + const isExpanded = expandedTasks.has(task.id); + const indentLevel = (task.level || 0) * 20; + + const handleToggle = useCallback(() => { + if (hasChildren && onToggle) { + onToggle(task.id); + } + }, [hasChildren, onToggle, task.id]); + + const getTaskIcon = () => { + if (task.type === 'milestone' || task.is_milestone) { + return ; + } + return null; + }; + + const getExpandIcon = () => { + // For empty phases, show expand icon to allow adding tasks + if (isEmpty || hasChildren) { + 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, + }; + + 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 isPhase = task.type === 'milestone' || task.is_milestone; + const isEmpty = isPhase && (!task.children || task.children.length === 0); + + return ( + <> +
+
+
+ {/* Drag handle for phases */} + {isPhase && isDraggable && ( + + )} + + {getExpandIcon()} + +
+ {getTaskIcon()} + + {task.name} + +
+
+ + {/* Add Task button for phases */} + {isPhase && onCreateTask && ( + +
+
+ {formatDateRange()} +
+
+ + {/* Inline task creation for empty expanded phases */} + {isEmpty && 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" + /> + ) : ( + + )} +
+
+ {/* Empty duration column */} +
+
+ )} + + ); +}); + +TaskRow.displayName = 'TaskRow'; + +const GanttTaskList = forwardRef(({ + tasks, + projectId, + onTaskToggle, + 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)); + + return ( +
+
+
+ Task Name +
+
+ Duration +
+
+
+ {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 ( + + ); + } + })} + + +
+
+ ); +}); + +GanttTaskList.displayName = 'GanttTaskList'; + +export default memo(GanttTaskList); \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-timeline/gantt-timeline.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-timeline/gantt-timeline.tsx new file mode 100644 index 00000000..69031687 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-timeline/gantt-timeline.tsx @@ -0,0 +1,156 @@ +import React, { memo, useMemo, forwardRef, RefObject } from 'react'; +import { GanttViewMode } from '../../types/gantt-types'; +import { useGanttDimensions } from '../../hooks/useGanttDimensions'; +import { TimelineUtils } from '../../utils/timeline-calculator'; + +interface GanttTimelineProps { + viewMode: GanttViewMode; + containerRef: RefObject; + dateRange?: { start: Date; end: Date }; +} + +const GanttTimeline = forwardRef(({ viewMode, containerRef, dateRange }, ref) => { + const headers = useMemo(() => { + // Generate timeline headers based on view mode and date range + const result = []; + + if (!dateRange) { + return result; + } + + const { start, end } = dateRange; + + switch (viewMode) { + case 'month': + // Generate month headers based on date range + const startYear = start.getFullYear(); + const startMonth = start.getMonth(); + const endYear = end.getFullYear(); + const endMonth = end.getMonth(); + + let currentYear = startYear; + let currentMonth = startMonth; + + while (currentYear < endYear || (currentYear === endYear && currentMonth <= endMonth)) { + const date = new Date(currentYear, currentMonth, 1); + result.push({ + label: date.toLocaleDateString('en-US', { month: 'short', year: currentYear !== new Date().getFullYear() ? 'numeric' : undefined }), + key: `month-${currentYear}-${currentMonth}`, + }); + + currentMonth++; + if (currentMonth > 11) { + currentMonth = 0; + currentYear++; + } + } + break; + case 'week': + // Generate week headers based on date range + const weekStart = new Date(start); + const weekEnd = new Date(end); + + // Align to start of week + weekStart.setDate(weekStart.getDate() - weekStart.getDay()); + + while (weekStart <= weekEnd) { + const weekNum = TimelineUtils.getWeekNumber(weekStart); + result.push({ + label: `Week ${weekNum}`, + key: `week-${weekStart.getFullYear()}-${weekNum}`, + }); + weekStart.setDate(weekStart.getDate() + 7); + } + break; + case 'day': + // Generate day headers based on date range + const dayStart = new Date(start); + const dayEnd = new Date(end); + + while (dayStart <= dayEnd) { + result.push({ + label: dayStart.toLocaleDateString('en-US', { day: '2-digit', month: 'short' }), + key: `day-${dayStart.getFullYear()}-${dayStart.getMonth()}-${dayStart.getDate()}`, + }); + dayStart.setDate(dayStart.getDate() + 1); + } + break; + case 'quarter': + // Generate quarter headers based on date range + 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': + // Generate year headers based on date range + const yearStart = start.getFullYear(); + const yearEnd = end.getFullYear(); + + for (let year = yearStart; year <= yearEnd; year++) { + result.push({ + label: `${year}`, + key: `year-${year}`, + }); + } + break; + default: + break; + } + + return result; + }, [viewMode, dateRange]); + + const { actualColumnWidth, totalWidth, shouldScroll } = useGanttDimensions( + viewMode, + containerRef, + headers.length + ); + + return ( +
+
+ {headers.map(header => ( +
+ {header.label} +
+ ))} +
+
+ ); +}); + +GanttTimeline.displayName = 'GanttTimeline'; + +export default memo(GanttTimeline); \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-toolbar/gantt-toolbar.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-toolbar/gantt-toolbar.tsx new file mode 100644 index 00000000..4a7adcb3 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-toolbar/gantt-toolbar.tsx @@ -0,0 +1,70 @@ +import React, { memo } from 'react'; +import { Select, Button, Space, Divider } from 'antd'; +import { ZoomInOutlined, ZoomOutOutlined, FullscreenOutlined, PlusOutlined, FlagOutlined } from '@ant-design/icons'; +import { GanttViewMode } from '../../types/gantt-types'; + +const { Option } = Select; + +interface GanttToolbarProps { + viewMode: GanttViewMode; + onViewModeChange: (mode: GanttViewMode) => void; + dateRange?: { start: Date; end: Date }; + onCreatePhase?: () => void; + onCreateTask?: () => void; +} + +const GanttToolbar: React.FC = memo(({ viewMode, onViewModeChange, dateRange, onCreatePhase, onCreateTask }) => { + return ( +
+ + + + + + +
+ ); +}); + +GanttToolbar.displayName = 'GanttToolbar'; + +export default GanttToolbar; \ No newline at end of file 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 new file mode 100644 index 00000000..0a2926aa --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/constants/gantt-constants.ts @@ -0,0 +1,18 @@ +export const GANTT_COLUMN_WIDTH = 80; // Base column width in pixels + +export const getColumnWidth = (viewMode: string): number => { + switch (viewMode) { + case 'day': + return 40; + case 'week': + return 60; + case 'month': + return 80; + case 'quarter': + return 120; + case 'year': + return 160; + 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 new file mode 100644 index 00000000..20eaa4f9 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/context/gantt-context.tsx @@ -0,0 +1,19 @@ +import React, { createContext, useContext } from 'react'; +import { GanttContextType } from '../types/gantt-types'; + +const GanttContext = createContext(undefined); + +export const GanttProvider: React.FC<{ + children: React.ReactNode; + value: GanttContextType; +}> = ({ children, value }) => { + return {children}; +}; + +export const useGanttContext = () => { + const context = useContext(GanttContext); + if (!context) { + 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 new file mode 100644 index 00000000..7776d5e7 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/gantt-styles.css @@ -0,0 +1,110 @@ +/* Hide scrollbar for Chrome, Safari and Opera */ +.scrollbar-hide::-webkit-scrollbar { + display: none; +} + +/* Hide scrollbar for IE, Edge and Firefox */ +.scrollbar-hide { + -ms-overflow-style: none; /* IE and Edge */ + scrollbar-width: none; /* Firefox */ +} + +/* Gantt task list specific styles */ +.gantt-task-list-container { + /* Ensure the task list stays fixed and doesn't interfere with timeline scrolling */ + position: relative; + box-sizing: border-box; +} + +.gantt-timeline-container { + /* Ensure timeline scrolling doesn't affect task list positioning */ + position: relative; + box-sizing: border-box; +} + +/* Custom scrollbar for task list */ +.gantt-task-list-scroll { + overflow-y: auto; + overflow-x: hidden; + scrollbar-width: thin; /* Firefox */ + scrollbar-gutter: stable; /* Prevent layout shift */ +} + +/* Webkit scrollbar styling */ +.gantt-task-list-scroll::-webkit-scrollbar { + width: 6px; +} + +.gantt-task-list-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.gantt-task-list-scroll::-webkit-scrollbar-thumb { + background-color: #cbd5e0; + border-radius: 3px; +} + +.gantt-task-list-scroll::-webkit-scrollbar-thumb:hover { + background-color: #a0aec0; +} + +/* Firefox scrollbar color */ +.gantt-task-list-scroll { + scrollbar-color: #cbd5e0 transparent; +} + +/* Dark mode scrollbar styles */ +.dark .gantt-task-list-scroll { + scrollbar-color: #4a5568 transparent; +} + +.dark .gantt-task-list-scroll::-webkit-scrollbar-thumb { + background-color: #4a5568; +} + +.dark .gantt-task-list-scroll::-webkit-scrollbar-thumb:hover { + background-color: #718096; +} + +/* Gantt chart scrollbar - show vertical only */ +.gantt-chart-scroll::-webkit-scrollbar { + width: 8px; + height: 0; +} + +/* Hide horizontal scrollbar specifically */ +.gantt-chart-scroll::-webkit-scrollbar:horizontal { + display: none; +} + +.gantt-chart-scroll::-webkit-scrollbar-track { + background: transparent; +} + +.gantt-chart-scroll::-webkit-scrollbar-thumb { + background-color: #cbd5e0; + border-radius: 4px; +} + +.gantt-chart-scroll::-webkit-scrollbar-thumb:hover { + background-color: #a0aec0; +} + +/* Firefox - hide horizontal scrollbar */ +.gantt-chart-scroll { + scrollbar-width: thin; + scrollbar-color: #cbd5e0 transparent; +} + +/* Dark mode chart scrollbar */ +.dark .gantt-chart-scroll::-webkit-scrollbar-thumb { + background-color: #4a5568; +} + +.dark .gantt-chart-scroll::-webkit-scrollbar-thumb:hover { + background-color: #718096; +} + +.dark .gantt-chart-scroll { + scrollbar-color: #4a5568 transparent; +} \ 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 new file mode 100644 index 00000000..27d8a18c --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/hooks/useGanttDimensions.ts @@ -0,0 +1,44 @@ +import { useState, useEffect, useCallback, RefObject } from 'react'; +import { GanttViewMode } from '../types/gantt-types'; +import { getColumnWidth } from '../constants/gantt-constants'; + +export const useGanttDimensions = ( + viewMode: GanttViewMode, + containerRef: RefObject, + columnsCount: number +) => { + const [containerWidth, setContainerWidth] = useState(0); + + const updateContainerWidth = useCallback(() => { + if (containerRef.current) { + setContainerWidth(containerRef.current.offsetWidth); + } + }, [containerRef]); + + useEffect(() => { + updateContainerWidth(); + window.addEventListener('resize', updateContainerWidth); + return () => window.removeEventListener('resize', updateContainerWidth); + }, [updateContainerWidth]); + + 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 totalWidth = columnsCount * actualColumnWidth; + + return { + containerWidth, + actualColumnWidth, + totalWidth, + columnsCount, + shouldScroll: totalWidth > containerWidth + }; +}; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/project-view-gantt.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/project-view-gantt.tsx new file mode 100644 index 00000000..79d32323 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/project-view-gantt.tsx @@ -0,0 +1,245 @@ +import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; +import { Spin, message } from 'antd'; +import { useParams } from 'react-router-dom'; +import GanttTimeline from './components/gantt-timeline/gantt-timeline'; +import GanttTaskList from './components/gantt-task-list/gantt-task-list'; +import GanttChart from './components/gantt-chart/gantt-chart'; +import GanttToolbar from './components/gantt-toolbar/gantt-toolbar'; +import ManagePhaseModal from '../../../../components/task-management/ManagePhaseModal'; +import { GanttProvider } from './context/gantt-context'; +import { GanttTask, GanttViewMode, GanttPhase } from './types/gantt-types'; +import { useGetRoadmapTasksQuery, useGetProjectPhasesQuery, transformToGanttTasks, transformToGanttPhases } from './services/gantt-api.service'; +import { TimelineUtils } from './utils/timeline-calculator'; +import { useAppDispatch } from '../../../../hooks/useAppDispatch'; +import { setShowTaskDrawer, setSelectedTaskId, setTaskFormViewModel } from '../../../../features/task-drawer/task-drawer.slice'; +import { DEFAULT_TASK_NAME } from '../../../../shared/constants'; +import './gantt-styles.css'; + +const ProjectViewGantt: React.FC = React.memo(() => { + const { projectId } = useParams<{ projectId: string }>(); + const dispatch = useAppDispatch(); + const [viewMode, setViewMode] = useState('month'); + const [showPhaseModal, setShowPhaseModal] = useState(false); + const [expandedTasks, setExpandedTasks] = useState>(new Set()); + const timelineRef = useRef(null); + const chartRef = useRef(null); + const taskListRef = useRef(null); + const containerRef = useRef(null); + + // RTK Query hooks + const { + data: tasksResponse, + error: tasksError, + isLoading: tasksLoading, + refetch: refetchTasks + } = useGetRoadmapTasksQuery( + { projectId: projectId || '' }, + { skip: !projectId } + ); + + const { + data: phasesResponse, + error: phasesError, + isLoading: phasesLoading, + refetch: refetchPhases + } = useGetProjectPhasesQuery( + { projectId: projectId || '' }, + { skip: !projectId } + ); + + // Transform API data to component format + const tasks = useMemo(() => { + if (tasksResponse?.body && phasesResponse?.body) { + const transformedTasks = transformToGanttTasks(tasksResponse.body, phasesResponse.body); + // Initialize expanded state for all phases + const expanded = new Set(); + transformedTasks.forEach(task => { + if ((task.type === 'milestone' || task.is_milestone) && task.expanded !== false) { + expanded.add(task.id); + } + }); + setExpandedTasks(expanded); + return transformedTasks; + } + return []; + }, [tasksResponse, phasesResponse]); + + const phases = useMemo(() => { + if (phasesResponse?.body) { + return transformToGanttPhases(phasesResponse.body); + } + return []; + }, [phasesResponse]); + + // Calculate date range based on tasks + const dateRange = useMemo(() => { + if (tasks.length > 0) { + return TimelineUtils.getSmartDateRange(tasks, viewMode); + } + return { start: new Date(), end: new Date() }; + }, [tasks, viewMode]); + + const loading = tasksLoading || phasesLoading; + + const handleViewModeChange = useCallback((mode: GanttViewMode) => { + setViewMode(mode); + }, []); + + const [isScrolling, setIsScrolling] = useState(false); + + const handleChartScroll = useCallback((e: React.UIEvent) => { + if (isScrolling) return; + setIsScrolling(true); + + const target = e.target as HTMLDivElement; + + // Sync horizontal scroll with timeline + if (timelineRef.current) { + timelineRef.current.scrollLeft = target.scrollLeft; + } + + // Sync vertical scroll with task list + if (taskListRef.current) { + taskListRef.current.scrollTop = target.scrollTop; + } + + setTimeout(() => setIsScrolling(false), 10); + }, [isScrolling]); + + const handleTaskListScroll = useCallback((e: React.UIEvent) => { + if (isScrolling) return; + setIsScrolling(true); + + const target = e.target as HTMLDivElement; + + // Sync vertical scroll with chart + if (chartRef.current) { + chartRef.current.scrollTop = target.scrollTop; + } + + setTimeout(() => setIsScrolling(false), 10); + }, [isScrolling]); + + const handleRefresh = useCallback(() => { + refetchTasks(); + refetchPhases(); + }, [refetchTasks, refetchPhases]); + + const handleCreatePhase = useCallback(() => { + setShowPhaseModal(true); + }, []); + + const handleCreateTask = useCallback((phaseId?: string) => { + // Create a new task using the task drawer + const newTaskViewModel = { + id: null, + name: DEFAULT_TASK_NAME, + project_id: projectId, + phase_id: phaseId || null, + // Add other default properties as needed + }; + + dispatch(setSelectedTaskId(null)); + dispatch(setTaskFormViewModel(newTaskViewModel)); + dispatch(setShowTaskDrawer(true)); + }, [dispatch, projectId]); + + const handleClosePhaseModal = useCallback(() => { + setShowPhaseModal(false); + }, []); + + const handlePhaseReorder = useCallback((oldIndex: number, newIndex: number) => { + // TODO: Implement phase reordering API call + console.log('Reorder phases:', { oldIndex, newIndex }); + message.info('Phase reordering will be implemented with the backend API'); + }, []); + + const handleCreateQuickTask = useCallback((taskName: string, phaseId?: string) => { + // Refresh the Gantt data after task creation + refetchTasks(); + message.success(`Task "${taskName}" created successfully!`); + }, [refetchTasks]); + + // Handle errors + if (tasksError || phasesError) { + message.error('Failed to load Gantt chart data'); + } + + if (loading) { + return ( +
+ +
+ ); + } + + return ( + +
+ +
+
+ {/* Fixed Task List - positioned absolutely to avoid scrollbar interference */} +
+ +
+ + {/* Scrollable Timeline and Chart - with left margin for task list */} +
+ + +
+
+
+
+ + {/* Phase Management Modal */} + +
+ ); +}); + +ProjectViewGantt.displayName = 'ProjectViewGantt'; + +export default ProjectViewGantt; \ 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 new file mode 100644 index 00000000..27560e67 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/services/gantt-api.service.ts @@ -0,0 +1,303 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { API_BASE_URL } from '@/shared/constants'; +import { IServerResponse } from '@/types/common.types'; +import { getCsrfToken, refreshCsrfToken } from '@/api/api-client'; +import config from '@/config/env'; +import { GanttTask, GanttPhase } from '../types/gantt-types'; + +const rootUrl = '/gantt'; + +export interface RoadmapTasksResponse { + id: string; + name: string; + start_date: string | null; + end_date: string | null; + done: boolean; + progress: number; + roadmap_sort_order: number; + parent_task_id: string | null; + status_name: string; + status_color: string; + priority_name: string; + priority_value: number; + priority_color: string; + phase_id: string | null; + assignees: Array<{ + team_member_id: string; + assignee_name: string; + avatar_url?: string; + }>; + dependencies: Array<{ + related_task_id: string; + dependency_type: string; + related_task_name: string; + }>; + subtasks: Array<{ + id: string; + name: string; + start_date: string | null; + end_date: string | null; + done: boolean; + progress: number; + roadmap_sort_order: number; + parent_task_id: string; + phase_id: string | null; + }>; +} + +export interface ProjectPhaseResponse { + id: string; + name: string; + color_code: string; + start_date: string | null; + end_date: string | null; + sort_index: number; +} + +export interface UpdateTaskDatesRequest { + task_id: string; + start_date: string; + end_date: string; +} + +export interface CreatePhaseRequest { + project_id: string; + name: string; + color_code?: string; + start_date?: string; + end_date?: string; +} + +export interface CreateTaskRequest { + project_id: string; + name: string; + phase_id?: string; + start_date?: string; + end_date?: string; + priority_id?: string; + status_id?: string; +} + +export const ganttApi = createApi({ + reducerPath: 'ganttApi', + baseQuery: fetchBaseQuery({ + baseUrl: `${config.apiUrl}${API_BASE_URL}`, + prepareHeaders: async headers => { + // Get CSRF token, refresh if needed + let token = getCsrfToken(); + if (!token) { + token = await refreshCsrfToken(); + } + + if (token) { + headers.set('X-CSRF-Token', token); + } + headers.set('Content-Type', 'application/json'); + return headers; + }, + credentials: 'include', + }), + tagTypes: ['GanttTasks', 'GanttPhases'], + endpoints: builder => ({ + getRoadmapTasks: builder.query< + IServerResponse, + { projectId: string } + >({ + query: ({ projectId }) => { + const params = new URLSearchParams({ + project_id: projectId, + }); + return `${rootUrl}/roadmap-tasks?${params.toString()}`; + }, + providesTags: (result, error, { projectId }) => [ + { type: 'GanttTasks', id: projectId }, + { type: 'GanttTasks', id: 'LIST' }, + ], + }), + + 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' }, + ], + }), + + updateTaskDates: builder.mutation< + IServerResponse, + UpdateTaskDatesRequest + >({ + query: body => ({ + url: `${rootUrl}/update-task-dates`, + method: 'POST', + body, + }), + invalidatesTags: (result, error, { task_id }) => [ + { type: 'GanttTasks', id: 'LIST' }, + ], + }), + + createPhase: builder.mutation< + IServerResponse, + CreatePhaseRequest + >({ + query: body => ({ + url: `${rootUrl}/create-phase`, + method: 'POST', + body, + }), + invalidatesTags: (result, error, { project_id }) => [ + { type: 'GanttPhases', id: project_id }, + { type: 'GanttPhases', id: 'LIST' }, + { type: 'GanttTasks', id: project_id }, + { type: 'GanttTasks', id: 'LIST' }, + ], + }), + + createTask: builder.mutation< + IServerResponse, + CreateTaskRequest + >({ + query: body => ({ + url: `${rootUrl}/create-task`, + method: 'POST', + body, + }), + invalidatesTags: (result, error, { project_id }) => [ + { type: 'GanttTasks', id: project_id }, + { type: 'GanttTasks', id: 'LIST' }, + ], + }), + }), +}); + +export const { + useGetRoadmapTasksQuery, + useGetProjectPhasesQuery, + useUpdateTaskDatesMutation, + useCreatePhaseMutation, + useCreateTaskMutation, +} = ganttApi; + +/** + * Transform API response to Gantt task format with phases as milestones + */ +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, []); + } + tasksByPhase.get(task.phase_id)!.push(task); + } else { + unassignedTasks.push(task); + } + }); + + const result: GanttTask[] = []; + + // Create phase milestones with their tasks (sorted by phase order) + [...apiPhases] + .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}`, + name: phase.name, + start_date: phase.start_date ? new Date(phase.start_date) : null, + end_date: phase.end_date ? new Date(phase.end_date) : null, + progress: 0, + level: 0, + expanded: true, + color: phase.color_code, + type: 'milestone', + is_milestone: true, + phase_id: phase.id, + children: phaseTasks.map(task => transformTask(task, 1)) + }; + + result.push(phaseMilestone); + }); + + // Always create unmapped phase at the bottom (even if empty) + const unmappedPhase: GanttTask = { + id: 'phase-unmapped', + name: 'Unmapped', + start_date: null, + end_date: null, + progress: 0, + level: 0, + expanded: true, + color: '#9CA3AF', // Gray color for unmapped phase + type: 'milestone', + is_milestone: true, + phase_id: null, + children: unassignedTasks.map(task => transformTask(task, 1)) + }; + + result.push(unmappedPhase); + + return result; +}; + +/** + * 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, + 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 + */ +export const transformToGanttPhases = (apiPhases: ProjectPhaseResponse[]): GanttPhase[] => { + return apiPhases.map(phase => ({ + id: phase.id, + name: phase.name, + 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 + })); +}; \ 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 new file mode 100644 index 00000000..c7837661 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/types/gantt-types.ts @@ -0,0 +1,56 @@ +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 interface GanttTask { + id: string; + name: string; + start_date: Date | null; + end_date: Date | null; + progress: number; + dependencies?: string[]; + dependencyType?: DependencyType; + parent_id?: string; + children?: GanttTask[]; + level?: number; + expanded?: boolean; + color?: string; + assignees?: string[]; + priority?: string; + status?: string; + phase_id?: string; + is_milestone?: boolean; + type?: 'task' | 'milestone' | 'phase'; +} + +export interface GanttPhase { + id: string; + name: string; + color_code: string; + start_date: Date | null; + end_date: Date | null; + sort_index: number; + tasks?: GanttTask[]; + expanded?: boolean; +} + +export interface GanttMilestone extends Omit { + type: 'milestone'; + phase_id: string; +} + +export interface GanttDependency { + id: string; + task_id: string; + related_task_id: string; + dependency_type: DependencyType; +} + +export interface GanttContextType { + tasks: GanttTask[]; + phases: GanttPhase[]; + viewMode: GanttViewMode; + 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 new file mode 100644 index 00000000..fc113719 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/utils/timeline-calculator.ts @@ -0,0 +1,321 @@ +import { GanttViewMode, GanttTask } from '../types/gantt-types'; + +export interface TimelinePosition { + left: number; + width: number; + isValid: boolean; +} + +export interface TimelineBounds { + startDate: Date; + endDate: Date; + totalDays: number; + pixelsPerDay: number; +} + +export class TimelineCalculator { + private viewMode: GanttViewMode; + private columnWidth: number; + private timelineBounds: TimelineBounds; + + constructor( + viewMode: GanttViewMode, + columnWidth: number, + startDate: Date, + endDate: Date + ) { + this.viewMode = viewMode; + this.columnWidth = columnWidth; + this.timelineBounds = this.calculateTimelineBounds(startDate, endDate); + } + + /** + * Calculate timeline bounds and pixels per day + */ + private calculateTimelineBounds(startDate: Date, endDate: Date): TimelineBounds { + const totalDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); + const columnsCount = this.getColumnsCount(); + const totalWidth = columnsCount * this.columnWidth; + const pixelsPerDay = totalWidth / totalDays; + + return { + startDate, + endDate, + totalDays, + pixelsPerDay + }; + } + + /** + * Get number of columns based on view mode + */ + 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; + } + } + + /** + * Calculate task bar position and width + */ + calculateTaskPosition(task: GanttTask): TimelinePosition { + if (!task.start_date || !task.end_date) { + return { left: 0, width: 0, isValid: false }; + } + + 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 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)); + + // Calculate pixel positions + const left = daysFromStart * this.timelineBounds.pixelsPerDay; + const width = Math.max(taskDuration * this.timelineBounds.pixelsPerDay, 10); // Minimum 10px width + + return { + left: Math.max(0, left), + width, + isValid: true + }; + } + + /** + * Calculate milestone position (single point in time) + */ + calculateMilestonePosition(date: Date): { left: number; isValid: boolean } { + if (!date) { + return { left: 0, isValid: false }; + } + + const milestoneDate = new Date(date); + + // Check if milestone is within timeline bounds + 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 left = daysFromStart * this.timelineBounds.pixelsPerDay; + + return { + left: Math.max(0, left), + isValid: true + }; + } + + /** + * Calculate dependency line coordinates + */ + calculateDependencyLine( + fromTask: GanttTask, + toTask: GanttTask, + rowHeight: number = 36 + ): { + x1: number; + y1: number; + x2: number; + y2: number; + isValid: boolean; + } { + const fromPosition = this.calculateTaskPosition(fromTask); + const toPosition = this.calculateTaskPosition(toTask); + + if (!fromPosition.isValid || !toPosition.isValid) { + return { x1: 0, y1: 0, x2: 0, y2: 0, isValid: false }; + } + + // Assume tasks are in different rows - would need actual row indices in real implementation + const fromY = 0; // Would be calculated based on task index * rowHeight + const toY = rowHeight; // Would be calculated based on task index * rowHeight + + return { + x1: fromPosition.left + fromPosition.width, // End of source task + y1: fromY + rowHeight / 2, + x2: toPosition.left, // Start of target task + y2: toY + rowHeight / 2, + isValid: true + }; + } + + /** + * Convert pixel position back to date + */ + pixelToDate(pixelPosition: number): Date { + const daysFromStart = pixelPosition / this.timelineBounds.pixelsPerDay; + const targetDate = new Date(this.timelineBounds.startDate); + targetDate.setDate(targetDate.getDate() + daysFromStart); + return targetDate; + } + + /** + * Get today line position + */ + getTodayLinePosition(): { left: number; isVisible: boolean } { + const today = new Date(); + const position = this.calculateMilestonePosition(today); + + return { + left: position.left, + isVisible: position.isValid + }; + } + + /** + * Calculate weekend/holiday shading areas + */ + 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) { + const position = this.calculateMilestonePosition(current); + if (position.isValid) { + weekendAreas.push({ + left: position.left, + width: this.timelineBounds.pixelsPerDay + }); + } + } + current.setDate(current.getDate() + 1); + } + + return weekendAreas; + } + + /** + * Get timeline bounds for external use + */ + getTimelineBounds(): TimelineBounds { + return { ...this.timelineBounds }; + } + + /** + * Update calculator with new parameters + */ + updateParameters(viewMode: GanttViewMode, columnWidth: number, startDate: Date, endDate: Date): void { + this.viewMode = viewMode; + this.columnWidth = columnWidth; + this.timelineBounds = this.calculateTimelineBounds(startDate, endDate); + } +} + +/** + * Utility functions for timeline calculations + */ +export const TimelineUtils = { + /** + * Get smart timeline date range based on project tasks + */ + getSmartDateRange(tasks: GanttTask[], viewMode: GanttViewMode): { start: Date; end: Date } { + if (!tasks.length) { + // Default to current year + const start = new Date(); + start.setMonth(0, 1); // January 1st + const end = new Date(); + end.setMonth(11, 31); // December 31st + return { start, end }; + } + + // Find earliest start date and latest end date + let earliestStart: Date | null = null; + let latestEnd: Date | null = null; + + tasks.forEach(task => { + if (task.start_date) { + if (!earliestStart || task.start_date < earliestStart) { + earliestStart = task.start_date; + } + } + if (task.end_date) { + if (!latestEnd || task.end_date > latestEnd) { + latestEnd = task.end_date; + } + } + + // Check subtasks too + if (task.children) { + task.children.forEach(subtask => { + if (subtask.start_date && (!earliestStart || subtask.start_date < earliestStart)) { + earliestStart = subtask.start_date; + } + if (subtask.end_date && (!latestEnd || subtask.end_date > latestEnd)) { + latestEnd = subtask.end_date; + } + }); + } + }); + + // Add padding based on view mode + const start = earliestStart || new Date(); + const end = latestEnd || new Date(); + + switch (viewMode) { + case 'day': + start.setDate(start.getDate() - 7); // 1 week before + end.setDate(end.getDate() + 7); // 1 week after + break; + case 'week': + start.setDate(start.getDate() - 14); // 2 weeks before + end.setDate(end.getDate() + 14); // 2 weeks after + break; + case 'month': + start.setMonth(start.getMonth() - 1); // 1 month before + end.setMonth(end.getMonth() + 1); // 1 month after + break; + case 'quarter': + start.setMonth(start.getMonth() - 3); // 1 quarter before + end.setMonth(end.getMonth() + 3); // 1 quarter after + break; + case 'year': + start.setFullYear(start.getFullYear() - 1); // 1 year before + end.setFullYear(end.getFullYear() + 1); // 1 year after + break; + } + + return { start, end }; + }, + + /** + * Format date based on view mode + */ + formatDateForViewMode(date: Date, viewMode: GanttViewMode): string { + switch (viewMode) { + case 'day': + return date.toLocaleDateString('en-US', { day: '2-digit', month: 'short' }); + case 'week': + return `Week ${this.getWeekNumber(date)}`; + case 'month': + return date.toLocaleDateString('en-US', { month: 'short' }); + case 'quarter': + return `Q${Math.ceil((date.getMonth() + 1) / 3)} ${date.getFullYear()}`; + case 'year': + return date.getFullYear().toString(); + default: + return date.toLocaleDateString(); + } + }, + + /** + * Get week number of the year + */ + getWeekNumber(date: Date): number { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + 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 diff --git a/worklenz-frontend/tailwind.config.js b/worklenz-frontend/tailwind.config.js index d9da4bfb..25cdf25d 100644 --- a/worklenz-frontend/tailwind.config.js +++ b/worklenz-frontend/tailwind.config.js @@ -19,6 +19,12 @@ module.exports = { '"Noto Color Emoji"', ], }, + colors: { + gray: { + 750: '#2d3748', + 850: '#1a202c', + }, + }, }, }, plugins: [],