diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index 0e1e0ac0..7e9265ce 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -20,7 +20,7 @@ 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') + () => import('@/pages/projects/projectView/gantt/ProjectViewGantt') ); // type of a tab items diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/project-view-gantt.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/ProjectViewGantt.tsx similarity index 57% rename from worklenz-frontend/src/pages/projects/projectView/gantt/project-view-gantt.tsx rename to worklenz-frontend/src/pages/projects/projectView/gantt/ProjectViewGantt.tsx index 79d32323..28944fb0 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/project-view-gantt.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/ProjectViewGantt.tsx @@ -1,18 +1,27 @@ -import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react'; -import { Spin, message } from 'antd'; +import React, { useState, useCallback, useRef, useMemo } from 'react'; +import { Spin, message } from '@/shared/antd-imports'; 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 GanttTimeline from './components/gantt-timeline/GanttTimeline'; +import GanttTaskList from './components/gantt-task-list/GanttTaskList'; +import GanttChart from './components/gantt-chart/GanttChart'; +import GanttToolbar from './components/gantt-toolbar/GanttToolbar'; +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 { GanttViewMode } 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 { 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(() => { @@ -31,38 +40,44 @@ const ProjectViewGantt: React.FC = React.memo(() => { data: tasksResponse, error: tasksError, isLoading: tasksLoading, - refetch: refetchTasks - } = useGetRoadmapTasksQuery( - { projectId: projectId || '' }, - { skip: !projectId } - ); + refetch: refetchTasks, + } = useGetRoadmapTasksQuery({ projectId: projectId || '' }, { skip: !projectId }); const { data: phasesResponse, error: phasesError, isLoading: phasesLoading, - refetch: refetchPhases - } = useGetProjectPhasesQuery( - { projectId: projectId || '' }, - { skip: !projectId } - ); + 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(); + const result: any[] = []; + transformedTasks.forEach(task => { - if ((task.type === 'milestone' || task.is_milestone) && task.expanded !== false) { - expanded.add(task.id); + // Always show phase milestones + if (task.type === 'milestone' || task.is_milestone) { + result.push(task); + + // If this phase is expanded, show its children tasks + const phaseId = task.id === 'phase-unmapped' ? 'unmapped' : task.phase_id; + if (expandedTasks.has(phaseId) && task.children) { + task.children.forEach((child: any) => { + result.push({ + ...child, + phase_id: task.phase_id // Ensure child has correct phase_id + }); + }); + } } }); - setExpandedTasks(expanded); - return transformedTasks; + + return result; } return []; - }, [tasksResponse, phasesResponse]); + }, [tasksResponse, phasesResponse, expandedTasks]); const phases = useMemo(() => { if (phasesResponse?.body) { @@ -85,40 +100,28 @@ const ProjectViewGantt: React.FC = React.memo(() => { 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(); @@ -129,20 +132,33 @@ const ProjectViewGantt: React.FC = React.memo(() => { 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 - }; + 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]); + dispatch(setSelectedTaskId(null)); + dispatch(setTaskFormViewModel(newTaskViewModel)); + dispatch(setShowTaskDrawer(true)); + }, + [dispatch, projectId] + ); + + const handleTaskClick = useCallback( + (taskId: string) => { + // Open existing task in the task drawer + dispatch(setSelectedTaskId(taskId)); + dispatch(setTaskFormViewModel(null)); // Clear form view model for existing task + dispatch(setShowTaskDrawer(true)); + }, + [dispatch] + ); const handleClosePhaseModal = useCallback(() => { setShowPhaseModal(false); @@ -154,11 +170,15 @@ const ProjectViewGantt: React.FC = React.memo(() => { 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]); + 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) { @@ -174,17 +194,22 @@ const ProjectViewGantt: React.FC = React.memo(() => { } return ( - -
- +
+ {
{/* Fixed Task List - positioned absolutely to avoid scrollbar interference */}
- { onExpandedTasksChange={setExpandedTasks} />
- + {/* Scrollable Timeline and Chart - with left margin for task list */} -
- + - { ProjectViewGantt.displayName = 'ProjectViewGantt'; -export default ProjectViewGantt; \ No newline at end of file +export default ProjectViewGantt; 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/GanttChart.tsx similarity index 89% rename from worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-chart/gantt-chart.tsx rename to worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-chart/GanttChart.tsx index bdac2ef8..c053f039 100644 --- 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/GanttChart.tsx @@ -2,6 +2,20 @@ import React, { memo, useMemo, forwardRef, RefObject } from 'react'; import { GanttTask, GanttViewMode, GanttPhase } from '../../types/gantt-types'; import { useGanttDimensions } from '../../hooks/useGanttDimensions'; +// Utility function to add alpha channel to hex color +const addAlphaToHex = (hex: string, alpha: number): string => { + // Remove # if present + const cleanHex = hex.replace('#', ''); + + // Convert hex to RGB + const r = parseInt(cleanHex.substring(0, 2), 16); + const g = parseInt(cleanHex.substring(2, 4), 16); + const b = parseInt(cleanHex.substring(4, 6), 16); + + // Return rgba string + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +}; + interface GanttChartProps { tasks: GanttTask[]; viewMode: GanttViewMode; @@ -47,7 +61,7 @@ const TaskBarRow: React.FC = memo(({ task, viewMode, columnWidt return (
= memo(({ task, viewMode, columnWidt ); }; + const isPhase = task.type === 'milestone' || task.is_milestone; + return (
- {task.type === 'milestone' || task.is_milestone ? renderMilestone() : renderTaskBar()} + {isPhase ? renderMilestone() : renderTaskBar()}
); }); @@ -213,7 +230,7 @@ const GanttChart = forwardRef(({ tasks, viewMod
{flattenedTasks.map(item => { if ('isEmptyRow' in item && item.isEmptyRow) { - // Render empty row for "Add Task" placeholder + // Render empty row without "Add Task" button return (
{ + // Remove # if present + const cleanHex = hex.replace('#', ''); + + // Convert hex to RGB + const r = parseInt(cleanHex.substring(0, 2), 16); + const g = parseInt(cleanHex.substring(2, 4), 16); + const b = parseInt(cleanHex.substring(4, 6), 16); + + // Return rgba string + return `rgba(${r}, ${g}, ${b}, ${alpha})`; +}; interface GanttTaskListProps { tasks: GanttTask[]; projectId: string; + viewMode: GanttViewMode; onTaskToggle?: (taskId: string) => void; + onTaskClick?: (taskId: string) => void; onCreateTask?: (phaseId?: string) => void; onCreateQuickTask?: (taskName: string, phaseId?: string) => void; onPhaseReorder?: (oldIndex: number, newIndex: number) => void; @@ -28,6 +46,7 @@ interface TaskRowProps { index: number; projectId: string; onToggle?: (taskId: string) => void; + onTaskClick?: (taskId: string) => void; expandedTasks: Set; onCreateTask?: (phaseId?: string) => void; onCreateQuickTask?: (taskName: string, phaseId?: string) => void; @@ -75,17 +94,23 @@ const TaskRow: React.FC { const [showInlineInput, setShowInlineInput] = useState(false); const [taskName, setTaskName] = useState(''); + const [showDatePickers, setShowDatePickers] = useState(false); + const datePickerRef = useRef(null); const { socket, connected } = useSocket(); const dispatch = useAppDispatch(); + const [updatePhase] = useUpdatePhaseMutation(); const formatDateRange = useCallback(() => { if (!task.start_date || !task.end_date) { return Not scheduled; @@ -96,36 +121,40 @@ const TaskRow: React.FC 0; - const isExpanded = expandedTasks.has(task.id); + // For phases, use phase_id for expansion state, for tasks use task.id + const phaseId = isPhase ? (task.id === 'phase-unmapped' ? 'unmapped' : task.phase_id || task.id.replace('phase-', '')) : task.id; + const isExpanded = expandedTasks.has(phaseId); const indentLevel = (task.level || 0) * 20; const handleToggle = useCallback(() => { - if (hasChildren && onToggle) { + // For phases, always allow toggle (regardless of having children) + // Use the standard onToggle handler which will call handleTaskToggle in GanttTaskList + if (isPhase && onToggle) { + onToggle(phaseId); + } else if (hasChildren && onToggle) { onToggle(task.id); } - }, [hasChildren, onToggle, task.id]); + }, [isPhase, hasChildren, onToggle, task.id, phaseId]); const getTaskIcon = () => { - if (task.type === 'milestone' || task.is_milestone) { - return ; - } + // No icon for phases return null; }; const getExpandIcon = () => { - // For empty phases, show expand icon to allow adding tasks - if (isEmpty || hasChildren) { + // All phases should be expandable (with or without children) + if (isPhase) { return ( ); } @@ -185,33 +214,90 @@ const TaskRow: React.FC { + if (!projectId || !task.phase_id) return; + + try { + await updatePhase({ + project_id: projectId, + phase_id: task.phase_id, + start_date: startDate.toISOString(), + end_date: endDate.toISOString(), + }).unwrap(); + + message.success('Phase dates updated successfully'); + setShowDatePickers(false); + } catch (error) { + console.error('Failed to update phase dates:', error); + message.error('Failed to update phase dates'); + } + }, [projectId, task.phase_id, updatePhase]); + const isEmpty = isPhase && (!task.children || task.children.length === 0); + // Calculate phase completion percentage + const phaseCompletion = useMemo(() => { + if (!isPhase || !task.children || task.children.length === 0) { + return 0; + } + const totalTasks = task.children.length; + const completedTasks = task.children.filter(child => child.progress === 100).length; + return Math.round((completedTasks / totalTasks) * 100); + }, [isPhase, task.children]); + + const handleTaskClick = useCallback(() => { + if (!isPhase && onTaskClick) { + onTaskClick(task.id); + } + }, [isPhase, onTaskClick, task.id]); + + // Handle click outside to close date picker + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) { + setShowDatePickers(false); + } + }; + + if (showDatePickers) { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + } + }, [showDatePickers]); + return ( <>
-
+
{/* Drag handle for phases */} {isPhase && isDraggable && ( + )} + {showDatePickers && isPhase && ( +
+ { + if (dates && dates[0] && dates[1]) { + handlePhaseDateUpdate(dates[0].toDate(), dates[1].toDate()); + } + }} + onOpenChange={(open) => { + if (!open) { + setShowDatePickers(false); + } + }} + className="text-xs" + style={{ width: 180 }} + format="MMM D, YYYY" + placeholder={['Start date', 'End date']} + autoFocus + /> +
+ )} +
+ )} +
- - {/* Add Task button for phases */} - {isPhase && onCreateTask && ( - -
-
- {formatDateRange()} +
- {/* Inline task creation for empty expanded phases */} - {isEmpty && isExpanded && ( -
+ {/* Inline task creation for all expanded phases */} + {isPhase && isExpanded && ( +
{showInlineInput ? ( @@ -274,15 +405,12 @@ const TaskRow: React.FC} onClick={handleShowInlineInput} - className="text-xs text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400" + className="text-xs text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 gantt-add-task-btn" > Add Task )}
-
- {/* Empty duration column */} -
)} @@ -294,7 +422,9 @@ TaskRow.displayName = 'TaskRow'; const GanttTaskList = forwardRef(({ tasks, projectId, + viewMode, onTaskToggle, + onTaskClick, onCreateTask, onCreateQuickTask, onPhaseReorder, @@ -471,15 +601,16 @@ const GanttTaskList = forwardRef(({ const allDraggableItems = [...phases.map(p => p.id), ...regularTasks.map(t => t.id)]; const phasesSet = new Set(phases.map(p => p.id)); + // Determine if the timeline has dual headers + const hasDualHeaders = ['month', 'week', 'day'].includes(viewMode); + const headerHeight = hasDualHeaders ? 'h-20' : 'h-10'; + return ( -
-
-
+
+
+
Task Name
-
- Duration -
{visibleTasks.length === 0 && ( @@ -511,6 +642,7 @@ const GanttTaskList = forwardRef(({ index={index} projectId={projectId} onToggle={handleTaskToggle} + onTaskClick={onTaskClick} expandedTasks={expandedTasks} onCreateTask={onCreateTask} onCreateQuickTask={onCreateQuickTask} @@ -526,6 +658,7 @@ const GanttTaskList = forwardRef(({ index={index} projectId={projectId} onToggle={handleTaskToggle} + onTaskClick={onTaskClick} expandedTasks={expandedTasks} onCreateTask={onCreateTask} onCreateQuickTask={onCreateQuickTask} @@ -544,6 +677,7 @@ const GanttTaskList = forwardRef(({ index={index} projectId={projectId} onToggle={handleTaskToggle} + onTaskClick={onTaskClick} expandedTasks={expandedTasks} onCreateTask={onCreateTask} onCreateQuickTask={onCreateQuickTask} diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-timeline/GanttTimeline.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-timeline/GanttTimeline.tsx new file mode 100644 index 00000000..a1cf396e --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-timeline/GanttTimeline.tsx @@ -0,0 +1,239 @@ +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 { topHeaders, bottomHeaders } = useMemo(() => { + if (!dateRange) { + return { topHeaders: [], bottomHeaders: [] }; + } + + const { start, end } = dateRange; + const topHeaders: Array<{ label: string; key: string; span: number }> = []; + const bottomHeaders: Array<{ label: string; key: string }> = []; + + switch (viewMode) { + case 'month': + // Top: Years, Bottom: Months + const startYear = start.getFullYear(); + const startMonth = start.getMonth(); + const endYear = end.getFullYear(); + const endMonth = end.getMonth(); + + // Generate bottom headers (months) + let currentYear = startYear; + let currentMonth = startMonth; + + while (currentYear < endYear || (currentYear === endYear && currentMonth <= endMonth)) { + const date = new Date(currentYear, currentMonth, 1); + bottomHeaders.push({ + label: date.toLocaleDateString('en-US', { month: 'short' }), + key: `month-${currentYear}-${currentMonth}`, + }); + + currentMonth++; + if (currentMonth > 11) { + currentMonth = 0; + currentYear++; + } + } + + // Generate top headers (years) + for (let year = startYear; year <= endYear; year++) { + const monthsInYear = bottomHeaders.filter(h => h.key.includes(`-${year}-`)).length; + if (monthsInYear > 0) { + topHeaders.push({ + label: `${year}`, + key: `year-${year}`, + span: monthsInYear + }); + } + } + break; + + case 'week': + // Top: Months, Bottom: Weeks + const weekStart = new Date(start); + const weekEnd = new Date(end); + weekStart.setDate(weekStart.getDate() - weekStart.getDay()); + + const weekDates: Date[] = []; + const tempDate = new Date(weekStart); + while (tempDate <= weekEnd) { + weekDates.push(new Date(tempDate)); + tempDate.setDate(tempDate.getDate() + 7); + } + + // Generate bottom headers (weeks) + weekDates.forEach(date => { + const weekNum = TimelineUtils.getWeekNumber(date); + bottomHeaders.push({ + label: `W${weekNum}`, + key: `week-${date.getFullYear()}-${weekNum}`, + }); + }); + + // Generate top headers (months) + const monthGroups = new Map(); + weekDates.forEach(date => { + const monthKey = `${date.getFullYear()}-${date.getMonth()}`; + monthGroups.set(monthKey, (monthGroups.get(monthKey) || 0) + 1); + }); + + monthGroups.forEach((count, monthKey) => { + const [year, month] = monthKey.split('-').map(Number); + const date = new Date(year, month, 1); + topHeaders.push({ + label: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }), + key: `month-${monthKey}`, + span: count + }); + }); + break; + + case 'day': + // Top: Months, Bottom: Days + const dayStart = new Date(start); + const dayEnd = new Date(end); + + const dayDates: Date[] = []; + const tempDayDate = new Date(dayStart); + while (tempDayDate <= dayEnd) { + dayDates.push(new Date(tempDayDate)); + tempDayDate.setDate(tempDayDate.getDate() + 1); + } + + // Generate bottom headers (days) + dayDates.forEach(date => { + bottomHeaders.push({ + label: date.getDate().toString(), + key: `day-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`, + }); + }); + + // Generate top headers (months) + const dayMonthGroups = new Map(); + dayDates.forEach(date => { + const monthKey = `${date.getFullYear()}-${date.getMonth()}`; + dayMonthGroups.set(monthKey, (dayMonthGroups.get(monthKey) || 0) + 1); + }); + + dayMonthGroups.forEach((count, monthKey) => { + const [year, month] = monthKey.split('-').map(Number); + const date = new Date(year, month, 1); + topHeaders.push({ + label: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }), + key: `month-${monthKey}`, + span: count + }); + }); + break; + + default: + // Fallback to single row for other view modes + const result = []; + switch (viewMode) { + case 'quarter': + const qStartYear = start.getFullYear(); + const qStartQuarter = Math.ceil((start.getMonth() + 1) / 3); + const qEndYear = end.getFullYear(); + const qEndQuarter = Math.ceil((end.getMonth() + 1) / 3); + + let qYear = qStartYear; + let qQuarter = qStartQuarter; + + while (qYear < qEndYear || (qYear === qEndYear && qQuarter <= qEndQuarter)) { + result.push({ + label: `Q${qQuarter} ${qYear}`, + key: `quarter-${qYear}-${qQuarter}`, + }); + + qQuarter++; + if (qQuarter > 4) { + qQuarter = 1; + qYear++; + } + } + break; + case 'year': + const yearStart = start.getFullYear(); + const yearEnd = end.getFullYear(); + + for (let year = yearStart; year <= yearEnd; year++) { + result.push({ + label: `${year}`, + key: `year-${year}`, + }); + } + break; + } + + result.forEach(item => { + bottomHeaders.push(item); + }); + break; + } + + return { topHeaders, bottomHeaders }; + }, [viewMode, dateRange]); + + const { actualColumnWidth, totalWidth, shouldScroll } = useGanttDimensions( + viewMode, + containerRef, + bottomHeaders.length + ); + + const hasTopHeaders = topHeaders.length > 0; + + return ( +
+ {hasTopHeaders && ( +
+ {topHeaders.map(header => ( +
+ {header.label} +
+ ))} +
+ )} +
+ {bottomHeaders.map(header => ( +
+ {header.label} +
+ ))} +
+
+ ); +}); + +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-timeline/gantt-timeline.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-timeline/gantt-timeline.tsx deleted file mode 100644 index 69031687..00000000 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-timeline/gantt-timeline.tsx +++ /dev/null @@ -1,156 +0,0 @@ -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/GanttToolbar.tsx similarity index 100% rename from worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-toolbar/gantt-toolbar.tsx rename to worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-toolbar/GanttToolbar.tsx diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/gantt-styles.css b/worklenz-frontend/src/pages/projects/projectView/gantt/gantt-styles.css index 7776d5e7..b007e583 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/gantt-styles.css +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/gantt-styles.css @@ -22,59 +22,24 @@ box-sizing: border-box; } -/* Custom scrollbar for task list */ +/* Custom scrollbar for task list - hide scrollbar */ .gantt-task-list-scroll { overflow-y: auto; overflow-x: hidden; - scrollbar-width: thin; /* Firefox */ - scrollbar-gutter: stable; /* Prevent layout shift */ + scrollbar-width: none; /* Firefox - hide scrollbar */ + -ms-overflow-style: none; /* IE and Edge - hide scrollbar */ } -/* Webkit scrollbar styling */ +/* Webkit scrollbar styling - hide scrollbar */ .gantt-task-list-scroll::-webkit-scrollbar { - width: 6px; + display: none; } -.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 scrollbar - show both vertical and horizontal */ .gantt-chart-scroll::-webkit-scrollbar { width: 8px; - height: 0; -} - -/* Hide horizontal scrollbar specifically */ -.gantt-chart-scroll::-webkit-scrollbar:horizontal { - display: none; + height: 8px; } .gantt-chart-scroll::-webkit-scrollbar-track { @@ -107,4 +72,126 @@ .dark .gantt-chart-scroll { scrollbar-color: #4a5568 transparent; +} + +/* Ensure consistent row heights and alignment */ +.gantt-task-list-scroll, +.gantt-chart-scroll { + margin: 0; + padding: 0; + box-sizing: border-box; + /* Ensure same scrolling behavior */ + scroll-behavior: auto; + /* Prevent sub-pixel rendering issues */ + transform: translateZ(0); + will-change: scroll-position; +} + +/* Ensure all rows have exact same box model */ +.gantt-task-list-scroll > div > div > div, +.gantt-chart-scroll > div > div > div { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* Force consistent border rendering */ +.gantt-task-list-scroll .border-b, +.gantt-chart-scroll .border-b { + border-bottom-width: 1px; + border-bottom-style: solid; +} + +/* Improve visual hierarchy for phase rows */ +.gantt-phase-row { + position: relative; +} + +.gantt-phase-row::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 4px; + background-color: currentColor; + opacity: 0.6; +} + +/* Better hover states */ +.gantt-task-row:hover { + background-color: rgba(0, 0, 0, 0.02); +} + +.dark .gantt-task-row:hover { + background-color: rgba(255, 255, 255, 0.02); +} + +/* Improved button styles */ +.gantt-add-task-btn { + transition: all 0.2s ease; + border-radius: 6px; +} + +.gantt-add-task-btn:hover { + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +/* Phase expansion transitions */ +.gantt-phase-children { + overflow: hidden; + transition: max-height 0.3s ease-in-out, opacity 0.2s ease-in-out; +} + +.gantt-phase-children.collapsed { + max-height: 0; + opacity: 0; +} + +.gantt-phase-children.expanded { + max-height: 1000px; /* Adjust based on expected max children */ + opacity: 1; +} + +/* Expand/collapse icon transitions */ +.gantt-expand-icon { + transition: transform 0.2s ease-in-out; +} + +.gantt-expand-icon.expanded { + transform: rotate(90deg); +} + +/* Task row slide-in animation */ +.gantt-task-slide-in { + animation: slideIn 0.3s ease-out; +} + +@keyframes slideIn { + from { + opacity: 0; + transform: translateY(-10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Add task button specific styles */ +.gantt-add-task-inline { + transition: all 0.2s ease-in-out; + animation: fadeIn 0.3s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-5px); + } + to { + opacity: 1; + transform: translateY(0); + } } \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/services/gantt-api.service.ts b/worklenz-frontend/src/pages/projects/projectView/gantt/services/gantt-api.service.ts index 27560e67..571381a1 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/services/gantt-api.service.ts +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/services/gantt-api.service.ts @@ -78,6 +78,15 @@ export interface CreateTaskRequest { status_id?: string; } +export interface UpdatePhaseRequest { + phase_id: string; + project_id: string; + name?: string; + color_code?: string; + start_date?: string; + end_date?: string; +} + export const ganttApi = createApi({ reducerPath: 'ganttApi', baseQuery: fetchBaseQuery({ @@ -176,6 +185,23 @@ export const ganttApi = createApi({ { type: 'GanttTasks', id: 'LIST' }, ], }), + + updatePhase: builder.mutation< + IServerResponse, + UpdatePhaseRequest + >({ + query: body => ({ + url: `${rootUrl}/update-phase`, + method: 'PUT', + body, + }), + invalidatesTags: (result, error, { project_id }) => [ + { type: 'GanttPhases', id: project_id }, + { type: 'GanttPhases', id: 'LIST' }, + { type: 'GanttTasks', id: project_id }, + { type: 'GanttTasks', id: 'LIST' }, + ], + }), }), }); @@ -185,6 +211,7 @@ export const { useUpdateTaskDatesMutation, useCreatePhaseMutation, useCreateTaskMutation, + useUpdatePhaseMutation, } = ganttApi; /**