diff --git a/worklenz-frontend/src/app/routes/main-routes.tsx b/worklenz-frontend/src/app/routes/main-routes.tsx index 8ec8cb9a..4c96c8f9 100644 --- a/worklenz-frontend/src/app/routes/main-routes.tsx +++ b/worklenz-frontend/src/app/routes/main-routes.tsx @@ -17,6 +17,7 @@ const ProjectTemplateEditView = lazy( const LicenseExpired = lazy(() => import('@/pages/license-expired/license-expired')); const ProjectView = lazy(() => import('@/pages/projects/projectView/project-view')); const Unauthorized = lazy(() => import('@/pages/unauthorized/unauthorized')); +const GanttDemoPage = lazy(() => import('@/pages/GanttDemoPage')); // Define AdminGuard component with defensive programming const AdminGuard = ({ children }: { children: React.ReactNode }) => { @@ -106,6 +107,14 @@ const mainRoutes: RouteObject[] = [ ), }, + { + path: 'gantt-demo', + element: ( + }> + + + ), + }, ...settingsRoutes, ...adminCenterRoutes, ], diff --git a/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttChart.tsx b/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttChart.tsx new file mode 100644 index 00000000..b1858c92 --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttChart.tsx @@ -0,0 +1,612 @@ +import React, { useReducer, useMemo, useCallback, useRef, useEffect, useState } from 'react'; +import { + GanttTask, + ColumnConfig, + TimelineConfig, + VirtualScrollConfig, + ZoomLevel, + GanttState, + GanttAction, + AdvancedGanttProps, + SelectionState, + GanttViewState, + DragState +} from '../../types/advanced-gantt.types'; +import GanttGrid from './GanttGrid'; +import DraggableTaskBar from './DraggableTaskBar'; +import TimelineMarkers, { holidayPresets, workingDayPresets } from './TimelineMarkers'; +import VirtualScrollContainer, { VirtualTimeline } from './VirtualScrollContainer'; +import { + usePerformanceMonitoring, + useTaskCalculations, + useDateCalculations, + useDebounce, + useThrottle +} from '../../utils/gantt-performance'; +import { useAppSelector } from '../../hooks/useAppSelector'; +import { themeWiseColor } from '../../utils/themeWiseColor'; + +// Default configurations +const defaultColumns: ColumnConfig[] = [ + { + field: 'name', + title: 'Task Name', + width: 250, + minWidth: 150, + resizable: true, + sortable: true, + fixed: true, + editor: 'text' + }, + { + field: 'startDate', + title: 'Start Date', + width: 120, + minWidth: 100, + resizable: true, + sortable: true, + fixed: true, + editor: 'date' + }, + { + field: 'endDate', + title: 'End Date', + width: 120, + minWidth: 100, + resizable: true, + sortable: true, + fixed: true, + editor: 'date' + }, + { + field: 'duration', + title: 'Duration', + width: 80, + minWidth: 60, + resizable: true, + sortable: false, + fixed: true + }, + { + field: 'progress', + title: 'Progress', + width: 100, + minWidth: 80, + resizable: true, + sortable: true, + fixed: true, + editor: 'number' + }, +]; + +const defaultTimelineConfig: TimelineConfig = { + topTier: { unit: 'month', format: 'MMM yyyy', height: 30 }, + bottomTier: { unit: 'day', format: 'dd', height: 25 }, + showWeekends: true, + showNonWorkingDays: true, + holidays: holidayPresets.US, + workingDays: workingDayPresets.standard, + workingHours: { start: 9, end: 17 }, + dayWidth: 30, +}; + +const defaultVirtualScrollConfig: VirtualScrollConfig = { + enableRowVirtualization: true, + enableTimelineVirtualization: true, + bufferSize: 10, + itemHeight: 40, + overscan: 5, +}; + +const defaultZoomLevels: ZoomLevel[] = [ + { + name: 'Year', + dayWidth: 2, + scale: 0.1, + topTier: { unit: 'year', format: 'yyyy' }, + bottomTier: { unit: 'month', format: 'MMM' } + }, + { + name: 'Month', + dayWidth: 8, + scale: 0.5, + topTier: { unit: 'month', format: 'MMM yyyy' }, + bottomTier: { unit: 'week', format: 'w' } + }, + { + name: 'Week', + dayWidth: 25, + scale: 1, + topTier: { unit: 'week', format: 'MMM dd' }, + bottomTier: { unit: 'day', format: 'dd' } + }, + { + name: 'Day', + dayWidth: 50, + scale: 2, + topTier: { unit: 'day', format: 'MMM dd' }, + bottomTier: { unit: 'hour', format: 'HH' } + }, +]; + +// Gantt state reducer +function ganttReducer(state: GanttState, action: GanttAction): GanttState { + switch (action.type) { + case 'SET_TASKS': + return { ...state, tasks: action.payload }; + + case 'UPDATE_TASK': + return { + ...state, + tasks: state.tasks.map(task => + task.id === action.payload.id + ? { ...task, ...action.payload.updates } + : task + ), + }; + + case 'ADD_TASK': + return { ...state, tasks: [...state.tasks, action.payload] }; + + case 'DELETE_TASK': + return { + ...state, + tasks: state.tasks.filter(task => task.id !== action.payload), + }; + + case 'SET_SELECTION': + return { + ...state, + selectionState: { ...state.selectionState, selectedTasks: action.payload }, + }; + + case 'SET_DRAG_STATE': + return { ...state, dragState: action.payload }; + + case 'SET_ZOOM_LEVEL': + const newZoomLevel = Math.max(0, Math.min(state.zoomLevels.length - 1, action.payload)); + return { + ...state, + viewState: { ...state.viewState, zoomLevel: newZoomLevel }, + timelineConfig: { + ...state.timelineConfig, + dayWidth: state.zoomLevels[newZoomLevel].dayWidth, + topTier: state.zoomLevels[newZoomLevel].topTier, + bottomTier: state.zoomLevels[newZoomLevel].bottomTier, + }, + }; + + case 'SET_SCROLL_POSITION': + return { + ...state, + viewState: { ...state.viewState, scrollPosition: action.payload }, + }; + + case 'SET_SPLITTER_POSITION': + return { + ...state, + viewState: { ...state.viewState, splitterPosition: action.payload }, + }; + + case 'TOGGLE_TASK_EXPANSION': + return { + ...state, + tasks: state.tasks.map(task => + task.id === action.payload + ? { ...task, isExpanded: !task.isExpanded } + : task + ), + }; + + case 'SET_VIEW_STATE': + return { + ...state, + viewState: { ...state.viewState, ...action.payload }, + }; + + case 'UPDATE_COLUMN_WIDTH': + return { + ...state, + columns: state.columns.map(col => + col.field === action.payload.field + ? { ...col, width: action.payload.width } + : col + ), + }; + + default: + return state; + } +} + +const AdvancedGanttChart: React.FC = ({ + tasks: initialTasks, + columns = defaultColumns, + timelineConfig = {}, + virtualScrollConfig = {}, + zoomLevels = defaultZoomLevels, + initialViewState = {}, + initialSelection = [], + onTaskUpdate, + onTaskCreate, + onTaskDelete, + onTaskMove, + onTaskResize, + onProgressChange, + onSelectionChange, + onColumnResize, + onDependencyCreate, + onDependencyDelete, + className = '', + style = {}, + theme = 'auto', + enableDragDrop = true, + enableResize = true, + enableProgressEdit = true, + enableInlineEdit = true, + enableVirtualScrolling = true, + enableDebouncing = true, + debounceDelay = 300, + maxVisibleTasks = 1000, +}) => { + const containerRef = useRef(null); + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + const themeMode = useAppSelector(state => state.themeReducer.mode); + const { startMeasure, endMeasure, metrics } = usePerformanceMonitoring(); + const { getDaysBetween } = useDateCalculations(); + + // Initialize state + const initialState: GanttState = { + tasks: initialTasks, + columns, + timelineConfig: { ...defaultTimelineConfig, ...timelineConfig }, + virtualScrollConfig: { ...defaultVirtualScrollConfig, ...virtualScrollConfig }, + dragState: null, + selectionState: { + selectedTasks: initialSelection, + selectedRows: [], + focusedTask: undefined, + }, + viewState: { + zoomLevel: 2, // Week view by default + scrollPosition: { x: 0, y: 0 }, + viewportSize: { width: 0, height: 0 }, + splitterPosition: 40, // 40% for grid, 60% for timeline + showCriticalPath: false, + showBaseline: false, + showProgress: true, + showDependencies: true, + autoSchedule: false, + readOnly: false, + ...initialViewState, + }, + zoomLevels, + performanceMetrics: { + renderTime: 0, + taskCount: initialTasks.length, + visibleTaskCount: 0, + }, + }; + + const [state, dispatch] = useReducer(ganttReducer, initialState); + const { taskMap, parentChildMap, totalTasks } = useTaskCalculations(state.tasks); + + // Calculate project timeline bounds + const projectBounds = useMemo(() => { + if (state.tasks.length === 0) { + const today = new Date(); + return { + start: new Date(today.getFullYear(), today.getMonth(), 1), + end: new Date(today.getFullYear(), today.getMonth() + 3, 0), + }; + } + + const startDates = state.tasks.map(task => task.startDate); + const endDates = state.tasks.map(task => task.endDate); + const minStart = new Date(Math.min(...startDates.map(d => d.getTime()))); + const maxEnd = new Date(Math.max(...endDates.map(d => d.getTime()))); + + // Add some padding + minStart.setDate(minStart.getDate() - 7); + maxEnd.setDate(maxEnd.getDate() + 7); + + return { start: minStart, end: maxEnd }; + }, [state.tasks]); + + // Debounced event handlers + const debouncedTaskUpdate = useDebounce( + useCallback((taskId: string, updates: Partial) => { + dispatch({ type: 'UPDATE_TASK', payload: { id: taskId, updates } }); + onTaskUpdate?.(taskId, updates); + }, [onTaskUpdate]), + enableDebouncing ? debounceDelay : 0 + ); + + const debouncedTaskMove = useDebounce( + useCallback((taskId: string, newDates: { start: Date; end: Date }) => { + dispatch({ type: 'UPDATE_TASK', payload: { + id: taskId, + updates: { startDate: newDates.start, endDate: newDates.end } + }}); + onTaskMove?.(taskId, newDates); + }, [onTaskMove]), + enableDebouncing ? debounceDelay : 0 + ); + + const debouncedProgressChange = useDebounce( + useCallback((taskId: string, progress: number) => { + dispatch({ type: 'UPDATE_TASK', payload: { id: taskId, updates: { progress } }}); + onProgressChange?.(taskId, progress); + }, [onProgressChange]), + enableDebouncing ? debounceDelay : 0 + ); + + // Throttled scroll handler + const throttledScrollHandler = useThrottle( + useCallback((scrollLeft: number, scrollTop: number) => { + dispatch({ type: 'SET_SCROLL_POSITION', payload: { x: scrollLeft, y: scrollTop } }); + }, []), + 16 // 60fps + ); + + // Container size observer + useEffect(() => { + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + setContainerSize({ width, height }); + dispatch({ + type: 'SET_VIEW_STATE', + payload: { viewportSize: { width, height } } + }); + } + }); + + if (containerRef.current) { + observer.observe(containerRef.current); + } + + return () => observer.disconnect(); + }, []); + + // Calculate grid and timeline dimensions + const gridWidth = useMemo(() => { + return Math.floor(containerSize.width * (state.viewState.splitterPosition / 100)); + }, [containerSize.width, state.viewState.splitterPosition]); + + const timelineWidth = useMemo(() => { + return containerSize.width - gridWidth; + }, [containerSize.width, gridWidth]); + + // Handle zoom changes + const handleZoomChange = useCallback((direction: 'in' | 'out') => { + const currentZoom = state.viewState.zoomLevel; + const newZoom = direction === 'in' + ? Math.min(state.zoomLevels.length - 1, currentZoom + 1) + : Math.max(0, currentZoom - 1); + + dispatch({ type: 'SET_ZOOM_LEVEL', payload: newZoom }); + }, [state.viewState.zoomLevel, state.zoomLevels.length]); + + // Theme-aware colors + const colors = useMemo(() => ({ + background: themeWiseColor('#ffffff', '#1f2937', themeMode), + border: themeWiseColor('#e5e7eb', '#4b5563', themeMode), + timelineBackground: themeWiseColor('#f8f9fa', '#374151', themeMode), + }), [themeMode]); + + // Render timeline header + const renderTimelineHeader = () => { + const currentZoom = state.zoomLevels[state.viewState.zoomLevel]; + const totalDays = getDaysBetween(projectBounds.start, projectBounds.end); + const totalWidth = totalDays * state.timelineConfig.dayWidth; + + return ( +
+ + {(date, index, style) => ( +
+
+ {formatDateForUnit(date, currentZoom.topTier.unit)} +
+
+ {formatDateForUnit(date, currentZoom.bottomTier.unit)} +
+
+ )} +
+
+ ); + }; + + // Render timeline content + const renderTimelineContent = () => { + const headerHeight = (state.zoomLevels[state.viewState.zoomLevel].topTier.height || 30) + + (state.zoomLevels[state.viewState.zoomLevel].bottomTier.height || 25); + const contentHeight = containerSize.height - headerHeight; + + return ( +
+ {/* Timeline markers (weekends, holidays, etc.) */} + + + {/* Task bars */} + + {(task, index, style) => ( + + )} + +
+ ); + }; + + // Render toolbar + const renderToolbar = () => ( +
+
+ + + {state.zoomLevels[state.viewState.zoomLevel].name} + + +
+ +
+ Tasks: {state.tasks.length} + + Render: {Math.round(metrics.renderTime)}ms +
+
+ ); + + // Performance monitoring + useEffect(() => { + startMeasure('render'); + return () => endMeasure('render'); + }); + + return ( +
+ {/* Toolbar */} + {renderToolbar()} + + {/* Main content */} +
+ {/* Grid */} +
+ { + // Handle task selection + const newSelection = { ...state.selectionState, selectedTasks: [task.id] }; + dispatch({ type: 'SET_SELECTION', payload: [task.id] }); + onSelectionChange?.(newSelection); + }} + onTaskExpand={(taskId) => { + dispatch({ type: 'TOGGLE_TASK_EXPANSION', payload: taskId }); + }} + onColumnResize={(field, width) => { + dispatch({ type: 'UPDATE_COLUMN_WIDTH', payload: { field, width } }); + onColumnResize?.(field, width); + }} + onTaskUpdate={debouncedTaskUpdate} + /> +
+ + {/* Timeline */} +
+ {renderTimelineHeader()} + {renderTimelineContent()} +
+
+
+ ); +}; + +// Helper function to format dates based on unit +function formatDateForUnit(date: Date, unit: string): string { + switch (unit) { + case 'year': + return date.getFullYear().toString(); + case 'month': + return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); + case 'week': + return `W${getWeekNumber(date)}`; + case 'day': + return date.getDate().toString(); + case 'hour': + return date.getHours().toString().padStart(2, '0'); + default: + return ''; + } +} + +function 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); +} + +export default AdvancedGanttChart; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttDemo.tsx b/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttDemo.tsx new file mode 100644 index 00000000..64b10de8 --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttDemo.tsx @@ -0,0 +1,668 @@ +import React, { useState, useMemo } from 'react'; +import { Button, Space, message, Card } from 'antd'; +import AdvancedGanttChart from './AdvancedGanttChart'; +import { GanttTask, ColumnConfig } from '../../types/advanced-gantt.types'; +import { useAppSelector } from '../../hooks/useAppSelector'; +import { holidayPresets, workingDayPresets } from './TimelineMarkers'; + +// Enhanced sample data with more realistic project structure +const generateSampleTasks = (): GanttTask[] => { + const baseDate = new Date(2024, 11, 1); // December 1, 2024 + + return [ + // Project Phase 1: Planning & Design + { + id: 'project-1', + name: '🚀 Web Platform Development', + startDate: new Date(2024, 11, 1), + endDate: new Date(2025, 2, 31), + progress: 45, + type: 'project', + status: 'in-progress', + priority: 'high', + color: '#1890ff', + hasChildren: true, + isExpanded: true, + level: 0, + }, + { + id: 'planning-phase', + name: '📋 Planning & Analysis Phase', + startDate: new Date(2024, 11, 1), + endDate: new Date(2024, 11, 20), + progress: 85, + type: 'project', + status: 'in-progress', + priority: 'high', + parent: 'project-1', + color: '#52c41a', + hasChildren: true, + isExpanded: true, + level: 1, + }, + { + id: 'requirements-analysis', + name: 'Requirements Gathering & Analysis', + startDate: new Date(2024, 11, 1), + endDate: new Date(2024, 11, 8), + progress: 100, + type: 'task', + status: 'completed', + priority: 'high', + parent: 'planning-phase', + assignee: { + id: 'user-1', + name: 'Alice Johnson', + avatar: 'https://ui-avatars.com/api/?name=Alice+Johnson&background=1890ff&color=fff', + }, + tags: ['research', 'documentation'], + level: 2, + }, + { + id: 'technical-architecture', + name: 'Technical Architecture Design', + startDate: new Date(2024, 11, 8), + endDate: new Date(2024, 11, 18), + progress: 75, + type: 'task', + status: 'in-progress', + priority: 'high', + parent: 'planning-phase', + assignee: { + id: 'user-2', + name: 'Bob Smith', + avatar: 'https://ui-avatars.com/api/?name=Bob+Smith&background=52c41a&color=fff', + }, + dependencies: ['requirements-analysis'], + tags: ['architecture', 'design'], + level: 2, + }, + { + id: 'ui-ux-design', + name: 'UI/UX Design & Prototyping', + startDate: new Date(2024, 11, 10), + endDate: new Date(2024, 11, 20), + progress: 60, + type: 'task', + status: 'in-progress', + priority: 'medium', + parent: 'planning-phase', + assignee: { + id: 'user-3', + name: 'Carol Davis', + avatar: 'https://ui-avatars.com/api/?name=Carol+Davis&background=faad14&color=fff', + }, + dependencies: ['requirements-analysis'], + tags: ['design', 'prototype'], + level: 2, + }, + { + id: 'milestone-planning-complete', + name: '🎯 Planning Phase Complete', + startDate: new Date(2024, 11, 20), + endDate: new Date(2024, 11, 20), + progress: 0, + type: 'milestone', + status: 'not-started', + priority: 'critical', + parent: 'planning-phase', + dependencies: ['technical-architecture', 'ui-ux-design'], + level: 2, + }, + + // Development Phase + { + id: 'development-phase', + name: '⚡ Development Phase', + startDate: new Date(2024, 11, 21), + endDate: new Date(2025, 1, 28), + progress: 35, + type: 'project', + status: 'in-progress', + priority: 'high', + parent: 'project-1', + color: '#722ed1', + hasChildren: true, + isExpanded: true, + level: 1, + }, + { + id: 'backend-development', + name: 'Backend API Development', + startDate: new Date(2024, 11, 21), + endDate: new Date(2025, 1, 15), + progress: 45, + type: 'task', + status: 'in-progress', + priority: 'high', + parent: 'development-phase', + assignee: { + id: 'user-4', + name: 'David Wilson', + avatar: 'https://ui-avatars.com/api/?name=David+Wilson&background=722ed1&color=fff', + }, + dependencies: ['milestone-planning-complete'], + tags: ['backend', 'api'], + level: 2, + }, + { + id: 'frontend-development', + name: 'Frontend React Application', + startDate: new Date(2025, 0, 5), + endDate: new Date(2025, 1, 25), + progress: 25, + type: 'task', + status: 'in-progress', + priority: 'high', + parent: 'development-phase', + assignee: { + id: 'user-5', + name: 'Eva Brown', + avatar: 'https://ui-avatars.com/api/?name=Eva+Brown&background=ff7a45&color=fff', + }, + dependencies: ['backend-development'], + tags: ['frontend', 'react'], + level: 2, + }, + { + id: 'database-setup', + name: 'Database Schema & Migration', + startDate: new Date(2024, 11, 21), + endDate: new Date(2025, 0, 10), + progress: 80, + type: 'task', + status: 'in-progress', + priority: 'medium', + parent: 'development-phase', + assignee: { + id: 'user-6', + name: 'Frank Miller', + avatar: 'https://ui-avatars.com/api/?name=Frank+Miller&background=13c2c2&color=fff', + }, + dependencies: ['milestone-planning-complete'], + tags: ['database', 'migration'], + level: 2, + }, + + // Testing Phase + { + id: 'testing-phase', + name: '🧪 Testing & QA Phase', + startDate: new Date(2025, 2, 1), + endDate: new Date(2025, 2, 20), + progress: 0, + type: 'project', + status: 'not-started', + priority: 'high', + parent: 'project-1', + color: '#fa8c16', + hasChildren: true, + isExpanded: false, + level: 1, + }, + { + id: 'unit-testing', + name: 'Unit Testing Implementation', + startDate: new Date(2025, 2, 1), + endDate: new Date(2025, 2, 10), + progress: 0, + type: 'task', + status: 'not-started', + priority: 'high', + parent: 'testing-phase', + assignee: { + id: 'user-7', + name: 'Grace Lee', + avatar: 'https://ui-avatars.com/api/?name=Grace+Lee&background=fa8c16&color=fff', + }, + dependencies: ['frontend-development'], + tags: ['testing', 'unit'], + level: 2, + }, + { + id: 'integration-testing', + name: 'Integration & E2E Testing', + startDate: new Date(2025, 2, 8), + endDate: new Date(2025, 2, 18), + progress: 0, + type: 'task', + status: 'not-started', + priority: 'high', + parent: 'testing-phase', + assignee: { + id: 'user-8', + name: 'Henry Clark', + avatar: 'https://ui-avatars.com/api/?name=Henry+Clark&background=eb2f96&color=fff', + }, + dependencies: ['unit-testing'], + tags: ['testing', 'integration'], + level: 2, + }, + { + id: 'milestone-beta-ready', + name: '🎯 Beta Release Ready', + startDate: new Date(2025, 2, 20), + endDate: new Date(2025, 2, 20), + progress: 0, + type: 'milestone', + status: 'not-started', + priority: 'critical', + parent: 'testing-phase', + dependencies: ['integration-testing'], + level: 2, + }, + + // Deployment Phase + { + id: 'deployment-phase', + name: '🚀 Deployment & Launch', + startDate: new Date(2025, 2, 21), + endDate: new Date(2025, 2, 31), + progress: 0, + type: 'project', + status: 'not-started', + priority: 'critical', + parent: 'project-1', + color: '#f5222d', + hasChildren: true, + isExpanded: false, + level: 1, + }, + { + id: 'production-deployment', + name: 'Production Environment Setup', + startDate: new Date(2025, 2, 21), + endDate: new Date(2025, 2, 25), + progress: 0, + type: 'task', + status: 'not-started', + priority: 'critical', + parent: 'deployment-phase', + assignee: { + id: 'user-9', + name: 'Ivy Taylor', + avatar: 'https://ui-avatars.com/api/?name=Ivy+Taylor&background=f5222d&color=fff', + }, + dependencies: ['milestone-beta-ready'], + tags: ['deployment', 'production'], + level: 2, + }, + { + id: 'go-live', + name: 'Go Live & Monitoring', + startDate: new Date(2025, 2, 26), + endDate: new Date(2025, 2, 31), + progress: 0, + type: 'task', + status: 'not-started', + priority: 'critical', + parent: 'deployment-phase', + assignee: { + id: 'user-10', + name: 'Jack Anderson', + avatar: 'https://ui-avatars.com/api/?name=Jack+Anderson&background=2f54eb&color=fff', + }, + dependencies: ['production-deployment'], + tags: ['launch', 'monitoring'], + level: 2, + }, + { + id: 'milestone-project-complete', + name: '🎉 Project Launch Complete', + startDate: new Date(2025, 2, 31), + endDate: new Date(2025, 2, 31), + progress: 0, + type: 'milestone', + status: 'not-started', + priority: 'critical', + parent: 'deployment-phase', + dependencies: ['go-live'], + level: 2, + }, + ]; +}; + +// Enhanced column configuration +const sampleColumns: ColumnConfig[] = [ + { + field: 'name', + title: 'Task / Phase Name', + width: 300, + minWidth: 200, + resizable: true, + sortable: true, + fixed: true, + editor: 'text' + }, + { + field: 'assignee', + title: 'Assignee', + width: 150, + minWidth: 120, + resizable: true, + sortable: true, + fixed: true + }, + { + field: 'startDate', + title: 'Start Date', + width: 120, + minWidth: 100, + resizable: true, + sortable: true, + fixed: true, + editor: 'date' + }, + { + field: 'endDate', + title: 'End Date', + width: 120, + minWidth: 100, + resizable: true, + sortable: true, + fixed: true, + editor: 'date' + }, + { + field: 'duration', + title: 'Duration', + width: 80, + minWidth: 60, + resizable: true, + sortable: false, + fixed: true, + align: 'center' + }, + { + field: 'progress', + title: 'Progress', + width: 120, + minWidth: 100, + resizable: true, + sortable: true, + fixed: true, + editor: 'number' + }, + { + field: 'status', + title: 'Status', + width: 100, + minWidth: 80, + resizable: true, + sortable: true, + fixed: true, + editor: 'select', + editorOptions: [ + { value: 'not-started', label: 'Not Started' }, + { value: 'in-progress', label: 'In Progress' }, + { value: 'completed', label: 'Completed' }, + { value: 'on-hold', label: 'On Hold' }, + { value: 'overdue', label: 'Overdue' }, + ] + }, + { + field: 'priority', + title: 'Priority', + width: 100, + minWidth: 80, + resizable: true, + sortable: true, + fixed: true, + editor: 'select', + editorOptions: [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'critical', label: 'Critical' }, + ] + }, +]; + +const AdvancedGanttDemo: React.FC = () => { + const [tasks, setTasks] = useState(generateSampleTasks()); + const [selectedTasks, setSelectedTasks] = useState([]); + const themeMode = useAppSelector(state => state.themeReducer.mode); + + const handleTaskUpdate = (taskId: string, updates: Partial) => { + setTasks(prevTasks => + prevTasks.map(task => + task.id === taskId ? { ...task, ...updates } : task + ) + ); + message.success(`Task "${tasks.find(t => t.id === taskId)?.name}" updated`); + }; + + const handleTaskMove = (taskId: string, newDates: { start: Date; end: Date }) => { + setTasks(prevTasks => + prevTasks.map(task => + task.id === taskId + ? { ...task, startDate: newDates.start, endDate: newDates.end } + : task + ) + ); + message.info(`Task moved: ${newDates.start.toLocaleDateString()} - ${newDates.end.toLocaleDateString()}`); + }; + + const handleProgressChange = (taskId: string, progress: number) => { + setTasks(prevTasks => + prevTasks.map(task => + task.id === taskId ? { ...task, progress } : task + ) + ); + message.info(`Progress updated: ${Math.round(progress)}%`); + }; + + const handleSelectionChange = (selection: any) => { + setSelectedTasks(selection.selectedTasks); + }; + + const resetToSampleData = () => { + setTasks(generateSampleTasks()); + setSelectedTasks([]); + message.info('Gantt chart reset to sample data'); + }; + + const addSampleTask = () => { + const newTask: GanttTask = { + id: `task-${Date.now()}`, + name: `New Task ${tasks.length + 1}`, + startDate: new Date(), + endDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +7 days + progress: 0, + type: 'task', + status: 'not-started', + priority: 'medium', + level: 0, + }; + setTasks(prev => [...prev, newTask]); + message.success('New task added'); + }; + + const deleteSelectedTasks = () => { + if (selectedTasks.length === 0) { + message.warning('No tasks selected'); + return; + } + + setTasks(prev => prev.filter(task => !selectedTasks.includes(task.id))); + setSelectedTasks([]); + message.success(`${selectedTasks.length} task(s) deleted`); + }; + + const taskStats = useMemo(() => { + const total = tasks.length; + const completed = tasks.filter(t => t.status === 'completed').length; + const inProgress = tasks.filter(t => t.status === 'in-progress').length; + const overdue = tasks.filter(t => t.status === 'overdue').length; + const avgProgress = tasks.reduce((sum, t) => sum + t.progress, 0) / total; + + return { total, completed, inProgress, overdue, avgProgress }; + }, [tasks]); + + return ( +
+ {/* Header */} +
+
+
+
+

+ 🚀 Advanced Gantt Chart Demo +

+

+ Professional Gantt chart with draggable tasks, virtual scrolling, holiday markers, + and performance optimizations for modern project management. +

+
+ +
+ + + + + +
+
+ + {/* Project Statistics */} +
+
+
Total Tasks
+
{taskStats.total}
+
+
+
Completed
+
{taskStats.completed}
+
+
+
In Progress
+
{taskStats.inProgress}
+
+
+
Avg Progress
+
+ {Math.round(taskStats.avgProgress)}% +
+
+
+
+
+ + {/* Gantt Chart */} +
+ +
+ + {/* Feature List */} +
+
+

+ ✨ Advanced Features Demonstrated +

+
+
+

Performance & UX

+
    +
  • • Virtual scrolling for 1000+ tasks
  • +
  • • Smooth 60fps drag & drop
  • +
  • • Debounced updates
  • +
  • • Memory-optimized rendering
  • +
  • • Responsive design
  • +
+
+
+

Gantt Features

+
    +
  • • Draggable task bars
  • +
  • • Resizable task duration
  • +
  • • Progress editing
  • +
  • • Multi-level hierarchy
  • +
  • • Task dependencies
  • +
+
+
+

Timeline & Markers

+
    +
  • • Weekend & holiday markers
  • +
  • • Working day indicators
  • +
  • • Today line
  • +
  • • Multi-tier timeline
  • +
  • • Zoom levels (Year/Month/Week/Day)
  • +
+
+
+

Grid Features

+
    +
  • • Fixed columns layout
  • +
  • • Inline editing
  • +
  • • Column resizing
  • +
  • • Multi-select
  • +
  • • Hierarchical tree view
  • +
+
+
+

UI/UX

+
    +
  • • Dark/Light theme support
  • +
  • • Tailwind CSS styling
  • +
  • • Consistent with Worklenz
  • +
  • • Accessibility features
  • +
  • • Mobile responsive
  • +
+
+
+

Architecture

+
    +
  • • Modern React patterns
  • +
  • • TypeScript safety
  • +
  • • Optimized performance
  • +
  • • Enterprise features
  • +
  • • Best practices 2025
  • +
+
+
+
+
+
+ ); +}; + +export default AdvancedGanttDemo; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/DraggableTaskBar.tsx b/worklenz-frontend/src/components/advanced-gantt/DraggableTaskBar.tsx new file mode 100644 index 00000000..f8d930ca --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/DraggableTaskBar.tsx @@ -0,0 +1,304 @@ +import React, { useState, useRef, useCallback, useMemo } from 'react'; +import { GanttTask, DragState } from '../../types/advanced-gantt.types'; +import { useAppSelector } from '../../hooks/useAppSelector'; +import { themeWiseColor } from '../../utils/themeWiseColor'; +import { useDateCalculations } from '../../utils/gantt-performance'; + +interface DraggableTaskBarProps { + task: GanttTask; + timelineStart: Date; + dayWidth: number; + rowHeight: number; + index: number; + onTaskMove?: (taskId: string, newDates: { start: Date; end: Date }) => void; + onTaskResize?: (taskId: string, newDates: { start: Date; end: Date }) => void; + onProgressChange?: (taskId: string, progress: number) => void; + onTaskClick?: (task: GanttTask) => void; + onTaskDoubleClick?: (task: GanttTask) => void; + enableDragDrop?: boolean; + enableResize?: boolean; + enableProgressEdit?: boolean; + readOnly?: boolean; +} + +const DraggableTaskBar: React.FC = ({ + task, + timelineStart, + dayWidth, + rowHeight, + index, + onTaskMove, + onTaskResize, + onProgressChange, + onTaskClick, + onTaskDoubleClick, + enableDragDrop = true, + enableResize = true, + enableProgressEdit = true, + readOnly = false, +}) => { + const [dragState, setDragState] = useState(null); + const [hoverState, setHoverState] = useState(null); + const taskBarRef = useRef(null); + const themeMode = useAppSelector(state => state.themeReducer.mode); + const { getDaysBetween, addDays } = useDateCalculations(); + + // Calculate task position and dimensions + const taskPosition = useMemo(() => { + const startDays = getDaysBetween(timelineStart, task.startDate); + const duration = getDaysBetween(task.startDate, task.endDate); + + return { + x: startDays * dayWidth, + width: Math.max(dayWidth * 0.5, duration * dayWidth), + y: index * rowHeight + 8, // 8px padding + height: rowHeight - 16, // 16px total padding + }; + }, [task.startDate, task.endDate, timelineStart, dayWidth, rowHeight, index, getDaysBetween]); + + // Theme-aware colors + const colors = useMemo(() => { + const baseColor = task.color || getDefaultTaskColor(task.status); + return { + background: themeWiseColor(baseColor, adjustColorForDarkMode(baseColor), themeMode), + border: themeWiseColor(darkenColor(baseColor, 0.2), lightenColor(baseColor, 0.2), themeMode), + progress: themeWiseColor('#52c41a', '#34d399', themeMode), + text: themeWiseColor('#ffffff', '#f9fafb', themeMode), + hover: themeWiseColor(lightenColor(baseColor, 0.1), darkenColor(baseColor, 0.1), themeMode), + }; + }, [task.color, task.status, themeMode]); + + // Mouse event handlers + const handleMouseDown = useCallback((e: React.MouseEvent, dragType: DragState['dragType']) => { + if (readOnly || !enableDragDrop) return; + + e.preventDefault(); + e.stopPropagation(); + + const rect = taskBarRef.current?.getBoundingClientRect(); + if (!rect) return; + + setDragState({ + isDragging: true, + dragType, + taskId: task.id, + initialPosition: { x: e.clientX, y: e.clientY }, + currentPosition: { x: e.clientX, y: e.clientY }, + initialDates: { start: task.startDate, end: task.endDate }, + initialProgress: task.progress, + snapToGrid: true, + }); + + // Add global mouse event listeners + const handleMouseMove = (moveEvent: MouseEvent) => { + handleMouseMove_Internal(moveEvent, dragType); + }; + + const handleMouseUp = () => { + handleMouseUp_Internal(); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, [readOnly, enableDragDrop, task]); + + const handleMouseMove_Internal = useCallback((e: MouseEvent, dragType: DragState['dragType']) => { + if (!dragState) return; + + const deltaX = e.clientX - dragState.initialPosition.x; + const deltaDays = Math.round(deltaX / dayWidth); + + let newStartDate = task.startDate; + let newEndDate = task.endDate; + + switch (dragType) { + case 'move': + newStartDate = addDays(dragState.initialDates.start, deltaDays); + newEndDate = addDays(dragState.initialDates.end, deltaDays); + break; + + case 'resize-start': + newStartDate = addDays(dragState.initialDates.start, deltaDays); + // Ensure minimum duration + if (newStartDate >= newEndDate) { + newStartDate = addDays(newEndDate, -1); + } + break; + + case 'resize-end': + newEndDate = addDays(dragState.initialDates.end, deltaDays); + // Ensure minimum duration + if (newEndDate <= newStartDate) { + newEndDate = addDays(newStartDate, 1); + } + break; + + case 'progress': + if (enableProgressEdit) { + const progressDelta = deltaX / taskPosition.width; + const newProgress = Math.max(0, Math.min(100, (dragState.initialProgress || 0) + progressDelta * 100)); + onProgressChange?.(task.id, newProgress); + } + return; + } + + // Update drag state + setDragState(prev => prev ? { + ...prev, + currentPosition: { x: e.clientX, y: e.clientY }, + } : null); + + // Call appropriate handler + if (dragType === 'move') { + onTaskMove?.(task.id, { start: newStartDate, end: newEndDate }); + } else if (dragType.startsWith('resize')) { + onTaskResize?.(task.id, { start: newStartDate, end: newEndDate }); + } + }, [dragState, dayWidth, task, taskPosition.width, enableProgressEdit, onTaskMove, onTaskResize, onProgressChange, addDays]); + + const handleMouseUp_Internal = useCallback(() => { + setDragState(null); + }, []); + + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onTaskClick?.(task); + }, [task, onTaskClick]); + + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onTaskDoubleClick?.(task); + }, [task, onTaskDoubleClick]); + + // Render task bar with handles + const renderTaskBar = () => { + const isSelected = false; // TODO: Get from selection state + const isDragging = dragState?.isDragging || false; + + return ( +
setHoverState('task')} + onMouseLeave={() => setHoverState(null)} + onMouseDown={(e) => handleMouseDown(e, 'move')} + > + {/* Progress bar */} +
+ + {/* Task content */} +
+ + {task.name} + + + {/* Duration display for smaller tasks */} + {taskPosition.width < 100 && ( + + {getDaysBetween(task.startDate, task.endDate)}d + + )} +
+ + {/* Resize handles */} + {enableResize && !readOnly && hoverState === 'task' && ( + <> + {/* Left resize handle */} +
handleMouseDown(e, 'resize-start')} + onMouseEnter={() => setHoverState('resize-start')} + /> + + {/* Right resize handle */} +
handleMouseDown(e, 'resize-end')} + onMouseEnter={() => setHoverState('resize-end')} + /> + + )} + + {/* Progress handle */} + {enableProgressEdit && !readOnly && hoverState === 'task' && ( +
handleMouseDown(e, 'progress')} + onMouseEnter={() => setHoverState('progress')} + /> + )} + + {/* Task type indicator */} + {task.type === 'milestone' && ( +
+ )} +
+ ); + }; + + return renderTaskBar(); +}; + +// Helper functions +function getDefaultTaskColor(status: GanttTask['status']): string { + switch (status) { + case 'completed': return '#52c41a'; + case 'in-progress': return '#1890ff'; + case 'overdue': return '#ff4d4f'; + case 'on-hold': return '#faad14'; + default: return '#d9d9d9'; + } +} + +function darkenColor(color: string, amount: number): string { + // Simple color darkening - in a real app, use a proper color manipulation library + return color; +} + +function lightenColor(color: string, amount: number): string { + // Simple color lightening - in a real app, use a proper color manipulation library + return color; +} + +function adjustColorForDarkMode(color: string): string { + // Adjust color for dark mode - in a real app, use a proper color manipulation library + return color; +} + +export default DraggableTaskBar; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/GanttGrid.tsx b/worklenz-frontend/src/components/advanced-gantt/GanttGrid.tsx new file mode 100644 index 00000000..0d5aaed5 --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/GanttGrid.tsx @@ -0,0 +1,492 @@ +import React, { useMemo, useRef, useState, useCallback } from 'react'; +import { GanttTask, ColumnConfig, SelectionState } from '../../types/advanced-gantt.types'; +import { useAppSelector } from '../../hooks/useAppSelector'; +import { themeWiseColor } from '../../utils/themeWiseColor'; +import { ChevronRightIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; +import { CalendarIcon, UserIcon, FlagIcon } from '@heroicons/react/24/solid'; + +interface GanttGridProps { + tasks: GanttTask[]; + columns: ColumnConfig[]; + rowHeight: number; + containerHeight: number; + selection: SelectionState; + enableInlineEdit?: boolean; + enableMultiSelect?: boolean; + onTaskClick?: (task: GanttTask, event: React.MouseEvent) => void; + onTaskDoubleClick?: (task: GanttTask) => void; + onTaskExpand?: (taskId: string) => void; + onSelectionChange?: (selection: SelectionState) => void; + onColumnResize?: (columnField: string, newWidth: number) => void; + onTaskUpdate?: (taskId: string, field: string, value: any) => void; + className?: string; +} + +const GanttGrid: React.FC = ({ + tasks, + columns, + rowHeight, + containerHeight, + selection, + enableInlineEdit = true, + enableMultiSelect = true, + onTaskClick, + onTaskDoubleClick, + onTaskExpand, + onSelectionChange, + onColumnResize, + onTaskUpdate, + className = '', +}) => { + const [editingCell, setEditingCell] = useState<{ taskId: string; field: string } | null>(null); + const [columnWidths, setColumnWidths] = useState>( + columns.reduce((acc, col) => ({ ...acc, [col.field]: col.width }), {}) + ); + const gridRef = useRef(null); + const themeMode = useAppSelector(state => state.themeReducer.mode); + + // Theme-aware colors + const colors = useMemo(() => ({ + background: themeWiseColor('#ffffff', '#1f2937', themeMode), + alternateRow: themeWiseColor('#f9fafb', '#374151', themeMode), + border: themeWiseColor('#e5e7eb', '#4b5563', themeMode), + text: themeWiseColor('#111827', '#f9fafb', themeMode), + textSecondary: themeWiseColor('#6b7280', '#d1d5db', themeMode), + selected: themeWiseColor('#eff6ff', '#1e3a8a', themeMode), + hover: themeWiseColor('#f3f4f6', '#4b5563', themeMode), + headerBg: themeWiseColor('#f8f9fa', '#374151', themeMode), + }), [themeMode]); + + // Calculate total grid width + const totalWidth = useMemo(() => { + return columns.reduce((sum, col) => sum + columnWidths[col.field], 0); + }, [columns, columnWidths]); + + // Handle column resize + const handleColumnResize = useCallback((columnField: string, deltaX: number) => { + const column = columns.find(col => col.field === columnField); + if (!column) return; + + const currentWidth = columnWidths[columnField]; + const newWidth = Math.max(column.minWidth || 60, Math.min(column.maxWidth || 400, currentWidth + deltaX)); + + setColumnWidths(prev => ({ ...prev, [columnField]: newWidth })); + onColumnResize?.(columnField, newWidth); + }, [columns, columnWidths, onColumnResize]); + + // Handle task selection + const handleTaskSelection = useCallback((task: GanttTask, event: React.MouseEvent) => { + const { ctrlKey, shiftKey } = event; + let newSelectedTasks = [...selection.selectedTasks]; + + if (shiftKey && enableMultiSelect && selection.selectedTasks.length > 0) { + // Range selection + const lastSelectedIndex = tasks.findIndex(t => t.id === selection.selectedTasks[selection.selectedTasks.length - 1]); + const currentIndex = tasks.findIndex(t => t.id === task.id); + const [start, end] = [Math.min(lastSelectedIndex, currentIndex), Math.max(lastSelectedIndex, currentIndex)]; + + newSelectedTasks = tasks.slice(start, end + 1).map(t => t.id); + } else if (ctrlKey && enableMultiSelect) { + // Multi selection + if (newSelectedTasks.includes(task.id)) { + newSelectedTasks = newSelectedTasks.filter(id => id !== task.id); + } else { + newSelectedTasks.push(task.id); + } + } else { + // Single selection + newSelectedTasks = [task.id]; + } + + onSelectionChange?.({ + ...selection, + selectedTasks: newSelectedTasks, + focusedTask: task.id, + }); + + onTaskClick?.(task, event); + }, [tasks, selection, enableMultiSelect, onSelectionChange, onTaskClick]); + + // Handle cell editing + const handleCellDoubleClick = useCallback((task: GanttTask, column: ColumnConfig) => { + if (!enableInlineEdit || !column.editor) return; + + setEditingCell({ taskId: task.id, field: column.field }); + }, [enableInlineEdit]); + + const handleCellEditComplete = useCallback((value: any) => { + if (!editingCell) return; + + onTaskUpdate?.(editingCell.taskId, editingCell.field, value); + setEditingCell(null); + }, [editingCell, onTaskUpdate]); + + // Render cell content + const renderCellContent = useCallback((task: GanttTask, column: ColumnConfig) => { + const value = task[column.field as keyof GanttTask]; + const isEditing = editingCell?.taskId === task.id && editingCell?.field === column.field; + + if (isEditing) { + return renderCellEditor(value, column, handleCellEditComplete); + } + + if (column.renderer) { + return column.renderer(value, task); + } + + // Default renderers + switch (column.field) { + case 'name': + return ( +
+ {task.hasChildren && ( + + )} +
+ {getTaskTypeIcon(task.type)} + {task.name} +
+
+ ); + + case 'startDate': + case 'endDate': + return ( +
+ + {(value as Date)?.toLocaleDateString() || '-'} +
+ ); + + case 'assignee': + return task.assignee ? ( +
+ {task.assignee.avatar ? ( + {task.assignee.name} + ) : ( + + )} + {task.assignee.name} +
+ ) : ( + Unassigned + ); + + case 'progress': + return ( +
+
+
+
+ {task.progress}% +
+ ); + + case 'status': + return ( + + {task.status.replace('-', ' ')} + + ); + + case 'priority': + return ( +
+ + {task.priority} +
+ ); + + case 'duration': + const duration = task.duration || Math.ceil((task.endDate.getTime() - task.startDate.getTime()) / (1000 * 60 * 60 * 24)); + return {duration}d; + + default: + return {String(value || '')}; + } + }, [editingCell, onTaskExpand, handleCellEditComplete]); + + // Render header + const renderHeader = () => ( +
+ {columns.map((column, index) => ( +
+ + {column.title} + + + {/* Resize handle */} + {column.resizable && ( + handleColumnResize(column.field, deltaX)} + className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-blue-500 opacity-0 group-hover:opacity-100" + /> + )} +
+ ))} +
+ ); + + // Render task rows + const renderRows = () => ( +
+ {tasks.map((task, rowIndex) => { + const isSelected = selection.selectedTasks.includes(task.id); + const isFocused = selection.focusedTask === task.id; + + return ( +
handleTaskSelection(task, e)} + onDoubleClick={() => onTaskDoubleClick?.(task)} + > + {columns.map((column) => ( +
handleCellDoubleClick(task, column)} + > + {renderCellContent(task, column)} +
+ ))} +
+ ); + })} +
+ ); + + return ( +
+ {renderHeader()} +
+ {renderRows()} +
+
+ ); +}; + +// Resize handle component +interface ResizeHandleProps { + onResize: (deltaX: number) => void; + className?: string; +} + +const ResizeHandle: React.FC = ({ onResize, className }) => { + const [isDragging, setIsDragging] = useState(false); + const startXRef = useRef(0); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + startXRef.current = e.clientX; + + const handleMouseMove = (moveEvent: MouseEvent) => { + const deltaX = moveEvent.clientX - startXRef.current; + onResize(deltaX); + startXRef.current = moveEvent.clientX; + }; + + const handleMouseUp = () => { + setIsDragging(false); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, [onResize]); + + return ( +
+ ); +}; + +// Cell editor component +const renderCellEditor = (value: any, column: ColumnConfig, onComplete: (value: any) => void) => { + const [editValue, setEditValue] = useState(value); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onComplete(editValue); + } else if (e.key === 'Escape') { + onComplete(value); // Cancel editing + } + }; + + const handleBlur = () => { + onComplete(editValue); + }; + + switch (column.editor) { + case 'text': + return ( + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + className="w-full px-1 py-0.5 border rounded text-sm" + autoFocus + /> + ); + + case 'date': + return ( + setEditValue(new Date(e.target.value))} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + className="w-full px-1 py-0.5 border rounded text-sm" + autoFocus + /> + ); + + case 'number': + return ( + setEditValue(parseFloat(e.target.value))} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + className="w-full px-1 py-0.5 border rounded text-sm" + autoFocus + /> + ); + + case 'select': + return ( + + ); + + default: + return {String(value)}; + } +}; + +// Helper functions +const getTaskTypeIcon = (type: GanttTask['type']) => { + switch (type) { + case 'project': + return
; + case 'milestone': + return
; + default: + return
; + } +}; + +const getStatusColor = (status: GanttTask['status']) => { + switch (status) { + case 'completed': + return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'; + case 'in-progress': + return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'; + case 'overdue': + return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'; + case 'on-hold': + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'; + default: + return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'; + } +}; + +const getPriorityColor = (priority: GanttTask['priority']) => { + switch (priority) { + case 'critical': + return 'text-red-600'; + case 'high': + return 'text-orange-500'; + case 'medium': + return 'text-yellow-500'; + case 'low': + return 'text-green-500'; + default: + return 'text-gray-400'; + } +}; + +export default GanttGrid; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/TimelineMarkers.tsx b/worklenz-frontend/src/components/advanced-gantt/TimelineMarkers.tsx new file mode 100644 index 00000000..4dd3a9ad --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/TimelineMarkers.tsx @@ -0,0 +1,295 @@ +import React, { useMemo } from 'react'; +import { Holiday, TimelineConfig } from '../../types/advanced-gantt.types'; +import { useAppSelector } from '../../hooks/useAppSelector'; +import { themeWiseColor } from '../../utils/themeWiseColor'; +import { useDateCalculations } from '../../utils/gantt-performance'; + +interface TimelineMarkersProps { + startDate: Date; + endDate: Date; + dayWidth: number; + containerHeight: number; + timelineConfig: TimelineConfig; + holidays?: Holiday[]; + showWeekends?: boolean; + showHolidays?: boolean; + showToday?: boolean; + className?: string; +} + +const TimelineMarkers: React.FC = ({ + startDate, + endDate, + dayWidth, + containerHeight, + timelineConfig, + holidays = [], + showWeekends = true, + showHolidays = true, + showToday = true, + className = '', +}) => { + const themeMode = useAppSelector(state => state.themeReducer.mode); + const { getDaysBetween, isWeekend, isWorkingDay } = useDateCalculations(); + + // Generate all dates in the timeline + const timelineDates = useMemo(() => { + const dates: Date[] = []; + const totalDays = getDaysBetween(startDate, endDate); + + for (let i = 0; i <= totalDays; i++) { + const date = new Date(startDate); + date.setDate(date.getDate() + i); + dates.push(date); + } + + return dates; + }, [startDate, endDate, getDaysBetween]); + + // Theme-aware colors + const colors = useMemo(() => ({ + weekend: themeWiseColor('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)', themeMode), + nonWorkingDay: themeWiseColor('rgba(0, 0, 0, 0.03)', 'rgba(255, 255, 255, 0.03)', themeMode), + holiday: themeWiseColor('rgba(255, 107, 107, 0.1)', 'rgba(255, 107, 107, 0.15)', themeMode), + today: themeWiseColor('rgba(24, 144, 255, 0.15)', 'rgba(64, 169, 255, 0.2)', themeMode), + todayLine: themeWiseColor('#1890ff', '#40a9ff', themeMode), + holidayBorder: themeWiseColor('#ff6b6b', '#ff8787', themeMode), + }), [themeMode]); + + // Check if a date is a holiday + const isHoliday = (date: Date): Holiday | undefined => { + return holidays.find(holiday => { + if (holiday.recurring) { + return holiday.date.getMonth() === date.getMonth() && + holiday.date.getDate() === date.getDate(); + } + return holiday.date.toDateString() === date.toDateString(); + }); + }; + + // Check if date is today + const isToday = (date: Date): boolean => { + const today = new Date(); + return date.toDateString() === today.toDateString(); + }; + + // Render weekend markers + const renderWeekendMarkers = () => { + if (!showWeekends) return null; + + return timelineDates.map((date, index) => { + if (!isWeekend(date)) return null; + + return ( +
+ ); + }); + }; + + // Render non-working day markers + const renderNonWorkingDayMarkers = () => { + return timelineDates.map((date, index) => { + if (isWorkingDay(date, timelineConfig.workingDays)) return null; + + return ( +
+ ); + }); + }; + + // Render holiday markers + const renderHolidayMarkers = () => { + if (!showHolidays) return null; + + return timelineDates.map((date, index) => { + const holiday = isHoliday(date); + if (!holiday) return null; + + const holidayColor = holiday.color || colors.holiday; + + return ( +
+ {/* Holiday tooltip */} +
+
{holiday.name}
+
{date.toLocaleDateString()}
+
+
+ + {/* Holiday icon */} +
+
+
+
+ ); + }); + }; + + // Render today marker + const renderTodayMarker = () => { + if (!showToday) return null; + + const todayIndex = timelineDates.findIndex(date => isToday(date)); + if (todayIndex === -1) return null; + + return ( +
+ {/* Today line */} +
+ + {/* Today label */} +
+ Today +
+
+ ); + }; + + // Render time period markers (quarters, months, etc.) + const renderTimePeriodMarkers = () => { + const markers: React.ReactNode[] = []; + const currentDate = new Date(startDate); + currentDate.setDate(1); // Start of month + + while (currentDate <= endDate) { + const daysSinceStart = getDaysBetween(startDate, currentDate); + const isQuarterStart = currentDate.getMonth() % 3 === 0 && currentDate.getDate() === 1; + const isYearStart = currentDate.getMonth() === 0 && currentDate.getDate() === 1; + + if (isYearStart) { + markers.push( +
+
+ {currentDate.getFullYear()} +
+
+ ); + } else if (isQuarterStart) { + markers.push( +
+
+ Q{Math.floor(currentDate.getMonth() / 3) + 1} +
+
+ ); + } + + // Move to next month + currentDate.setMonth(currentDate.getMonth() + 1); + } + + return markers; + }; + + return ( +
+ {renderNonWorkingDayMarkers()} + {renderWeekendMarkers()} + {renderHolidayMarkers()} + {renderTodayMarker()} + {renderTimePeriodMarkers()} +
+ ); +}; + +// Holiday presets for common countries +export const holidayPresets = { + US: [ + { date: new Date(2024, 0, 1), name: "New Year's Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 0, 15), name: "Martin Luther King Jr. Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 1, 19), name: "Presidents' Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 4, 27), name: "Memorial Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 5, 19), name: "Juneteenth", type: 'national' as const, recurring: true }, + { date: new Date(2024, 6, 4), name: "Independence Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 8, 2), name: "Labor Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 9, 14), name: "Columbus Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 10, 11), name: "Veterans Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 10, 28), name: "Thanksgiving", type: 'national' as const, recurring: true }, + { date: new Date(2024, 11, 25), name: "Christmas Day", type: 'national' as const, recurring: true }, + ], + + UK: [ + { date: new Date(2024, 0, 1), name: "New Year's Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 2, 29), name: "Good Friday", type: 'religious' as const, recurring: false }, + { date: new Date(2024, 3, 1), name: "Easter Monday", type: 'religious' as const, recurring: false }, + { date: new Date(2024, 4, 6), name: "Early May Bank Holiday", type: 'national' as const, recurring: true }, + { date: new Date(2024, 4, 27), name: "Spring Bank Holiday", type: 'national' as const, recurring: true }, + { date: new Date(2024, 7, 26), name: "Summer Bank Holiday", type: 'national' as const, recurring: true }, + { date: new Date(2024, 11, 25), name: "Christmas Day", type: 'religious' as const, recurring: true }, + { date: new Date(2024, 11, 26), name: "Boxing Day", type: 'national' as const, recurring: true }, + ], +}; + +// Working day presets +export const workingDayPresets = { + standard: [1, 2, 3, 4, 5], // Monday to Friday + middle_east: [0, 1, 2, 3, 4], // Sunday to Thursday + six_day: [1, 2, 3, 4, 5, 6], // Monday to Saturday + four_day: [1, 2, 3, 4], // Monday to Thursday +}; + +export default TimelineMarkers; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/VirtualScrollContainer.tsx b/worklenz-frontend/src/components/advanced-gantt/VirtualScrollContainer.tsx new file mode 100644 index 00000000..0e855bc3 --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/VirtualScrollContainer.tsx @@ -0,0 +1,372 @@ +import React, { useRef, useEffect, useState, useCallback, ReactNode } from 'react'; +import { useThrottle, usePerformanceMonitoring } from '../../utils/gantt-performance'; +import { useAppSelector } from '../../hooks/useAppSelector'; + +interface VirtualScrollContainerProps { + items: any[]; + itemHeight: number; + containerHeight: number; + containerWidth?: number; + overscan?: number; + horizontal?: boolean; + children: (item: any, index: number, style: React.CSSProperties) => ReactNode; + onScroll?: (scrollLeft: number, scrollTop: number) => void; + className?: string; + style?: React.CSSProperties; +} + +const VirtualScrollContainer: React.FC = ({ + items, + itemHeight, + containerHeight, + containerWidth = 0, + overscan = 5, + horizontal = false, + children, + onScroll, + className = '', + style = {}, +}) => { + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [scrollLeft, setScrollLeft] = useState(0); + const { startMeasure, endMeasure, recordMetric } = usePerformanceMonitoring(); + const themeMode = useAppSelector(state => state.themeReducer.mode); + + // Calculate visible range + const totalHeight = items.length * itemHeight; + const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); + const endIndex = Math.min( + items.length - 1, + Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan + ); + const visibleItems = items.slice(startIndex, endIndex + 1); + const offsetY = startIndex * itemHeight; + + // Throttled scroll handler + const throttledScrollHandler = useThrottle( + useCallback((event: Event) => { + const target = event.target as HTMLDivElement; + const newScrollTop = target.scrollTop; + const newScrollLeft = target.scrollLeft; + + setScrollTop(newScrollTop); + setScrollLeft(newScrollLeft); + onScroll?.(newScrollLeft, newScrollTop); + }, [onScroll]), + 16 // ~60fps + ); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + container.addEventListener('scroll', throttledScrollHandler, { passive: true }); + + return () => { + container.removeEventListener('scroll', throttledScrollHandler); + }; + }, [throttledScrollHandler]); + + // Performance monitoring + useEffect(() => { + startMeasure('virtualScroll'); + recordMetric('visibleTaskCount', visibleItems.length); + recordMetric('taskCount', items.length); + endMeasure('virtualScroll'); + }, [visibleItems.length, items.length, startMeasure, endMeasure, recordMetric]); + + const renderVisibleItems = () => { + return visibleItems.map((item, virtualIndex) => { + const actualIndex = startIndex + virtualIndex; + const itemStyle: React.CSSProperties = { + position: 'absolute', + top: horizontal ? 0 : actualIndex * itemHeight, + left: horizontal ? actualIndex * itemHeight : 0, + height: horizontal ? '100%' : itemHeight, + width: horizontal ? itemHeight : '100%', + transform: horizontal ? 'none' : `translateY(${offsetY}px)`, + }; + + return ( +
+ {children(item, actualIndex, itemStyle)} +
+ ); + }); + }; + + return ( +
+ {/* Spacer to maintain scroll height */} +
+ {/* Visible items container */} +
+ {renderVisibleItems()} +
+
+
+ ); +}; + +// Grid virtual scrolling component for both rows and columns +interface VirtualGridProps { + data: any[][]; + rowHeight: number; + columnWidth: number | number[]; + containerHeight: number; + containerWidth: number; + overscan?: number; + children: (item: any, rowIndex: number, colIndex: number, style: React.CSSProperties) => ReactNode; + onScroll?: (scrollLeft: number, scrollTop: number) => void; + className?: string; +} + +export const VirtualGrid: React.FC = ({ + data, + rowHeight, + columnWidth, + containerHeight, + containerWidth, + overscan = 3, + children, + onScroll, + className = '', +}) => { + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [scrollLeft, setScrollLeft] = useState(0); + + const rowCount = data.length; + const colCount = data[0]?.length || 0; + + // Calculate column positions for variable width columns + const columnWidths = Array.isArray(columnWidth) ? columnWidth : new Array(colCount).fill(columnWidth); + const columnPositions = columnWidths.reduce((acc, width, index) => { + acc[index] = index === 0 ? 0 : acc[index - 1] + columnWidths[index - 1]; + return acc; + }, {} as Record); + + const totalWidth = columnWidths.reduce((sum, width) => sum + width, 0); + const totalHeight = rowCount * rowHeight; + + // Calculate visible ranges + const startRowIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan); + const endRowIndex = Math.min(rowCount - 1, Math.ceil((scrollTop + containerHeight) / rowHeight) + overscan); + + const startColIndex = Math.max(0, findColumnIndex(scrollLeft) - overscan); + const endColIndex = Math.min(colCount - 1, findColumnIndex(scrollLeft + containerWidth) + overscan); + + function findColumnIndex(position: number): number { + for (let i = 0; i < colCount; i++) { + if (columnPositions[i] <= position && position < columnPositions[i] + columnWidths[i]) { + return i; + } + } + return colCount - 1; + } + + const throttledScrollHandler = useThrottle( + useCallback((event: Event) => { + const target = event.target as HTMLDivElement; + const newScrollTop = target.scrollTop; + const newScrollLeft = target.scrollLeft; + + setScrollTop(newScrollTop); + setScrollLeft(newScrollLeft); + onScroll?.(newScrollLeft, newScrollTop); + }, [onScroll]), + 16 + ); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + container.addEventListener('scroll', throttledScrollHandler, { passive: true }); + + return () => { + container.removeEventListener('scroll', throttledScrollHandler); + }; + }, [throttledScrollHandler]); + + const renderVisibleCells = () => { + const cells: ReactNode[] = []; + + for (let rowIndex = startRowIndex; rowIndex <= endRowIndex; rowIndex++) { + for (let colIndex = startColIndex; colIndex <= endColIndex; colIndex++) { + const item = data[rowIndex]?.[colIndex]; + if (!item) continue; + + const cellStyle: React.CSSProperties = { + position: 'absolute', + top: rowIndex * rowHeight, + left: columnPositions[colIndex], + height: rowHeight, + width: columnWidths[colIndex], + }; + + cells.push( +
+ {children(item, rowIndex, colIndex, cellStyle)} +
+ ); + } + } + + return cells; + }; + + return ( +
+
+ {renderVisibleCells()} +
+
+ ); +}; + +// Timeline virtual scrolling component +interface VirtualTimelineProps { + startDate: Date; + endDate: Date; + dayWidth: number; + containerWidth: number; + containerHeight: number; + overscan?: number; + children: (date: Date, index: number, style: React.CSSProperties) => ReactNode; + onScroll?: (scrollLeft: number) => void; + className?: string; +} + +export const VirtualTimeline: React.FC = ({ + startDate, + endDate, + dayWidth, + containerWidth, + containerHeight, + overscan = 10, + children, + onScroll, + className = '', +}) => { + const containerRef = useRef(null); + const [scrollLeft, setScrollLeft] = useState(0); + + const totalDays = Math.ceil((endDate.getTime() - startDate.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 throttledScrollHandler = useThrottle( + useCallback((event: Event) => { + const target = event.target as HTMLDivElement; + const newScrollLeft = target.scrollLeft; + setScrollLeft(newScrollLeft); + onScroll?.(newScrollLeft); + }, [onScroll]), + 16 + ); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + container.addEventListener('scroll', throttledScrollHandler, { passive: true }); + + return () => { + container.removeEventListener('scroll', throttledScrollHandler); + }; + }, [throttledScrollHandler]); + + const renderVisibleDays = () => { + const days: ReactNode[] = []; + + for (let dayIndex = startDayIndex; dayIndex <= endDayIndex; dayIndex++) { + const date = new Date(startDate); + date.setDate(date.getDate() + dayIndex); + + const dayStyle: React.CSSProperties = { + position: 'absolute', + left: dayIndex * dayWidth, + top: 0, + width: dayWidth, + height: '100%', + }; + + days.push( +
+ {children(date, dayIndex, dayStyle)} +
+ ); + } + + return days; + }; + + return ( +
+
+ {renderVisibleDays()} +
+
+ ); +}; + +export default VirtualScrollContainer; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/index.ts b/worklenz-frontend/src/components/advanced-gantt/index.ts new file mode 100644 index 00000000..7d27b5bc --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/index.ts @@ -0,0 +1,17 @@ +// Main Components +export { default as AdvancedGanttChart } from './AdvancedGanttChart'; +export { default as AdvancedGanttDemo } from './AdvancedGanttDemo'; + +// Core Components +export { default as GanttGrid } from './GanttGrid'; +export { default as DraggableTaskBar } from './DraggableTaskBar'; +export { default as TimelineMarkers, holidayPresets, workingDayPresets } from './TimelineMarkers'; + +// Utility Components +export { default as VirtualScrollContainer, VirtualGrid, VirtualTimeline } from './VirtualScrollContainer'; + +// Types +export * from '../../types/advanced-gantt.types'; + +// Performance Utilities +export * from '../../utils/gantt-performance'; \ No newline at end of file diff --git a/worklenz-frontend/src/components/project-roadmap-gantt/PhaseModal.tsx b/worklenz-frontend/src/components/project-roadmap-gantt/PhaseModal.tsx new file mode 100644 index 00000000..c921d2f6 --- /dev/null +++ b/worklenz-frontend/src/components/project-roadmap-gantt/PhaseModal.tsx @@ -0,0 +1,406 @@ +import React, { useState } from 'react'; +import { + Modal, + Tabs, + Progress, + Tag, + List, + Avatar, + Badge, + Space, + Button, + Statistic, + Row, + Col, + Timeline, + Input, + Form, + DatePicker, + Select +} from 'antd'; +import { + CalendarOutlined, + TeamOutlined, + CheckCircleOutlined, + ClockCircleOutlined, + FlagOutlined, + ExclamationCircleOutlined, + EditOutlined, + SaveOutlined, + CloseOutlined +} from '@ant-design/icons'; +import { PhaseModalData, ProjectPhase, PhaseTask, PhaseMilestone } from '../../types/project-roadmap.types'; +import { useAppSelector } from '../../hooks/useAppSelector'; +import { themeWiseColor } from '../../utils/themeWiseColor'; +import dayjs from 'dayjs'; + +const { TabPane } = Tabs; +const { TextArea } = Input; + +interface PhaseModalProps { + visible: boolean; + phase: PhaseModalData | null; + onClose: () => void; + onUpdate?: (updates: Partial) => void; +} + +const PhaseModal: React.FC = ({ + visible, + phase, + onClose, + onUpdate, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [form] = Form.useForm(); + + // Theme support + const themeMode = useAppSelector(state => state.themeReducer.mode); + const isDarkMode = themeMode === 'dark'; + + if (!phase) return null; + + const handleEdit = () => { + setIsEditing(true); + form.setFieldsValue({ + name: phase.name, + description: phase.description, + startDate: dayjs(phase.startDate), + endDate: dayjs(phase.endDate), + status: phase.status, + }); + }; + + const handleSave = async () => { + try { + const values = await form.validateFields(); + const updates: Partial = { + name: values.name, + description: values.description, + startDate: values.startDate.toDate(), + endDate: values.endDate.toDate(), + status: values.status, + }; + + onUpdate?.(updates); + setIsEditing(false); + } catch (error) { + console.error('Validation failed:', error); + } + }; + + const handleCancel = () => { + setIsEditing(false); + form.resetFields(); + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'completed': return 'success'; + case 'in-progress': return 'processing'; + case 'on-hold': return 'warning'; + default: return 'default'; + } + }; + + const getPriorityColor = (priority: string) => { + switch (priority) { + case 'high': return 'red'; + case 'medium': return 'orange'; + case 'low': return 'green'; + default: return 'default'; + } + }; + + const getTaskStatusIcon = (status: string) => { + switch (status) { + case 'done': return ; + case 'in-progress': return ; + default: return ; + } + }; + + return ( + + + + {isEditing ? ( + + + + ) : ( +

+ {phase.name} +

+ )} +
+ + {isEditing ? ( + <> + + + + ) : ( + + )} + +
+ } + open={visible} + onCancel={onClose} + width={800} + footer={null} + className="dark:bg-gray-800" + > +
+
+ {isEditing ? ( + Description}> +