feat(gantt): enhance Gantt chart functionality with task progress tracking

- Updated GanttController to include task counts by status (todo, doing, done) and total tasks for each project phase.
- Implemented progress percentage calculations for each phase based on task counts.
- Enhanced ProjectViewGantt component to fetch task priorities and manage task data more effectively.
- Improved GanttChart and GanttTaskList components for better rendering of tasks and phases, including drag-and-drop functionality.
- Refactored GanttTimeline to optimize header generation based on view mode and date range.
- Updated GanttToolbar for improved user interaction with task management features.
This commit is contained in:
Chamika J
2025-08-05 16:02:07 +05:30
parent d33a7db253
commit ad7eb505b5
15 changed files with 1643 additions and 1310 deletions

View File

@@ -194,19 +194,80 @@ export default class GanttController extends WorklenzControllerBase {
const q = ` const q = `
SELECT SELECT
id, pp.id,
name, pp.name,
color_code, pp.color_code,
start_date, pp.start_date,
end_date, pp.end_date,
sort_index pp.sort_index,
FROM project_phases -- Calculate task counts by status category for progress
WHERE project_id = $1 COALESCE(
ORDER BY sort_index, created_at; (SELECT COUNT(*)
FROM tasks t
JOIN task_phase tp ON t.id = tp.task_id
JOIN task_statuses ts ON t.status_id = ts.id
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
WHERE tp.phase_id = pp.id
AND t.archived = FALSE
AND stsc.is_todo = TRUE), 0
) as todo_count,
COALESCE(
(SELECT COUNT(*)
FROM tasks t
JOIN task_phase tp ON t.id = tp.task_id
JOIN task_statuses ts ON t.status_id = ts.id
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
WHERE tp.phase_id = pp.id
AND t.archived = FALSE
AND stsc.is_doing = TRUE), 0
) as doing_count,
COALESCE(
(SELECT COUNT(*)
FROM tasks t
JOIN task_phase tp ON t.id = tp.task_id
JOIN task_statuses ts ON t.status_id = ts.id
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
WHERE tp.phase_id = pp.id
AND t.archived = FALSE
AND stsc.is_done = TRUE), 0
) as done_count,
COALESCE(
(SELECT COUNT(*)
FROM tasks t
JOIN task_phase tp ON t.id = tp.task_id
WHERE tp.phase_id = pp.id
AND t.archived = FALSE), 0
) as total_count
FROM project_phases pp
WHERE pp.project_id = $1
ORDER BY pp.sort_index, pp.created_at;
`; `;
const result = await db.query(q, [projectId]); const result = await db.query(q, [projectId]);
return res.status(200).send(new ServerResponse(true, result.rows));
// Calculate progress percentages for each phase
const phasesWithProgress = result.rows.map(phase => {
const total = parseInt(phase.total_count) || 0;
const todoCount = parseInt(phase.todo_count) || 0;
const doingCount = parseInt(phase.doing_count) || 0;
const doneCount = parseInt(phase.done_count) || 0;
return {
id: phase.id,
name: phase.name,
color_code: phase.color_code,
start_date: phase.start_date,
end_date: phase.end_date,
sort_index: phase.sort_index,
// Calculate progress percentages
todo_progress: total > 0 ? Math.round((todoCount / total) * 100) : 0,
doing_progress: total > 0 ? Math.round((doingCount / total) * 100) : 0,
done_progress: total > 0 ? Math.round((doneCount / total) * 100) : 0,
total_tasks: total
};
});
return res.status(200).send(new ServerResponse(true, phasesWithProgress));
} }
@HandleExceptions() @HandleExceptions()

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef, useMemo } from 'react'; import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { Spin, message } from '@/shared/antd-imports'; import { Spin, message } from '@/shared/antd-imports';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import GanttTimeline from './components/gantt-timeline/GanttTimeline'; import GanttTimeline from './components/gantt-timeline/GanttTimeline';
@@ -20,7 +20,9 @@ import {
setShowTaskDrawer, setShowTaskDrawer,
setSelectedTaskId, setSelectedTaskId,
setTaskFormViewModel, setTaskFormViewModel,
fetchTask,
} from '@features/task-drawer/task-drawer.slice'; } from '@features/task-drawer/task-drawer.slice';
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
import { DEFAULT_TASK_NAME } from '@/shared/constants'; import { DEFAULT_TASK_NAME } from '@/shared/constants';
import './gantt-styles.css'; import './gantt-styles.css';
@@ -55,25 +57,30 @@ const ProjectViewGantt: React.FC = React.memo(() => {
if (tasksResponse?.body && phasesResponse?.body) { if (tasksResponse?.body && phasesResponse?.body) {
const transformedTasks = transformToGanttTasks(tasksResponse.body, phasesResponse.body); const transformedTasks = transformToGanttTasks(tasksResponse.body, phasesResponse.body);
const result: any[] = []; const result: any[] = [];
transformedTasks.forEach(task => { transformedTasks.forEach(task => {
// Always show phase milestones // Always show phase milestones
if (task.type === 'milestone' || task.is_milestone) { if (task.type === 'milestone' || task.is_milestone) {
result.push(task); result.push(task);
// If this phase is expanded, show its children tasks // If this phase is expanded, show its children tasks
const phaseId = task.id === 'phase-unmapped' ? 'unmapped' : task.phase_id; const phaseId =
if (expandedTasks.has(phaseId) && task.children) { task.id === 'phase-unmapped'
? 'unmapped'
: task.phase_id || task.id.replace('phase-', '');
const isExpanded = expandedTasks.has(phaseId);
if (isExpanded && task.children) {
task.children.forEach((child: any) => { task.children.forEach((child: any) => {
result.push({ result.push({
...child, ...child,
phase_id: task.phase_id // Ensure child has correct phase_id phase_id: task.phase_id, // Ensure child has correct phase_id
}); });
}); });
} }
} }
}); });
return result; return result;
} }
return []; return [];
@@ -96,6 +103,11 @@ const ProjectViewGantt: React.FC = React.memo(() => {
const loading = tasksLoading || phasesLoading; const loading = tasksLoading || phasesLoading;
// Load priorities for task drawer functionality
useEffect(() => {
dispatch(fetchPriorities());
}, [dispatch]);
const handleViewModeChange = useCallback((mode: GanttViewMode) => { const handleViewModeChange = useCallback((mode: GanttViewMode) => {
setViewMode(mode); setViewMode(mode);
}, []); }, []);
@@ -156,8 +168,13 @@ const ProjectViewGantt: React.FC = React.memo(() => {
dispatch(setSelectedTaskId(taskId)); dispatch(setSelectedTaskId(taskId));
dispatch(setTaskFormViewModel(null)); // Clear form view model for existing task dispatch(setTaskFormViewModel(null)); // Clear form view model for existing task
dispatch(setShowTaskDrawer(true)); dispatch(setShowTaskDrawer(true));
// Fetch the complete task data including priorities
if (projectId) {
dispatch(fetchTask({ taskId, projectId }));
}
}, },
[dispatch] [dispatch, projectId]
); );
const handleClosePhaseModal = useCallback(() => { const handleClosePhaseModal = useCallback(() => {
@@ -172,14 +189,12 @@ const ProjectViewGantt: React.FC = React.memo(() => {
const handleCreateQuickTask = useCallback( const handleCreateQuickTask = useCallback(
(taskName: string, phaseId?: string) => { (taskName: string, phaseId?: string) => {
// Refresh the Gantt data after task creation // Refresh the Gantt data after task creation to show the new task
refetchTasks(); refetchTasks();
message.success(`Task "${taskName}" created successfully!`);
}, },
[refetchTasks] [refetchTasks]
); );
// Handle errors // Handle errors
if (tasksError || phasesError) { if (tasksError || phasesError) {
message.error('Failed to load Gantt chart data'); message.error('Failed to load Gantt chart data');

View File

@@ -6,12 +6,12 @@ import { useGanttDimensions } from '../../hooks/useGanttDimensions';
const addAlphaToHex = (hex: string, alpha: number): string => { const addAlphaToHex = (hex: string, alpha: number): string => {
// Remove # if present // Remove # if present
const cleanHex = hex.replace('#', ''); const cleanHex = hex.replace('#', '');
// Convert hex to RGB // Convert hex to RGB
const r = parseInt(cleanHex.substring(0, 2), 16); const r = parseInt(cleanHex.substring(0, 2), 16);
const g = parseInt(cleanHex.substring(2, 4), 16); const g = parseInt(cleanHex.substring(2, 4), 16);
const b = parseInt(cleanHex.substring(4, 6), 16); const b = parseInt(cleanHex.substring(4, 6), 16);
// Return rgba string // Return rgba string
return `rgba(${r}, ${g}, ${b}, ${alpha})`; return `rgba(${r}, ${g}, ${b}, ${alpha})`;
}; };
@@ -32,7 +32,7 @@ interface GridColumnProps {
} }
const GridColumn: React.FC<GridColumnProps> = memo(({ index, columnWidth }) => ( const GridColumn: React.FC<GridColumnProps> = memo(({ index, columnWidth }) => (
<div <div
className={`border-r border-gray-100 dark:border-gray-700 flex-shrink-0 h-full ${ className={`border-r border-gray-100 dark:border-gray-700 flex-shrink-0 h-full ${
index % 2 === 1 ? 'bg-gray-50 dark:bg-gray-850' : '' index % 2 === 1 ? 'bg-gray-50 dark:bg-gray-850' : ''
}`} }`}
@@ -50,216 +50,248 @@ interface TaskBarRowProps {
dateRange?: { start: Date; end: Date }; dateRange?: { start: Date; end: Date };
} }
const TaskBarRow: React.FC<TaskBarRowProps> = memo(({ task, viewMode, columnWidth, columnsCount, dateRange }) => { const TaskBarRow: React.FC<TaskBarRowProps> = memo(
const renderMilestone = () => { ({ task, viewMode, columnWidth, columnsCount, dateRange }) => {
if (!task.start_date || !dateRange) return null; const renderMilestone = () => {
if (!task.start_date || !dateRange) return null;
// Calculate position for milestone diamond
const totalDays = Math.ceil((dateRange.end.getTime() - dateRange.start.getTime()) / (1000 * 60 * 60 * 24)); // Calculate position for milestone diamond based on view mode
const daysFromStart = Math.floor((task.start_date.getTime() - dateRange.start.getTime()) / (1000 * 60 * 60 * 24)); const totalTimeSpan = dateRange.end.getTime() - dateRange.start.getTime();
const left = Math.max(0, (daysFromStart / totalDays) * (columnsCount * columnWidth)); const timeFromStart = task.start_date.getTime() - dateRange.start.getTime();
const left = Math.max(0, (timeFromStart / totalTimeSpan) * (columnsCount * columnWidth));
return (
<div return (
className="absolute top-1/2 transform -translate-y-1/2 -translate-x-1/2 w-4 h-4 rotate-45 z-10 shadow-sm" <div
style={{ className="absolute top-1/2 transform -translate-y-1/2 -translate-x-1/2 w-4 h-4 rotate-45 z-10 shadow-sm"
left: `${left}px`, style={{
backgroundColor: task.color || '#3b82f6' left: `${left}px`,
}} backgroundColor: task.color || '#3b82f6',
title={`${task.name} - ${task.start_date.toLocaleDateString()}`} }}
/> title={`${task.name} - ${task.start_date.toLocaleDateString()}`}
); />
}; );
};
const renderTaskBar = () => {
if (!task.start_date || !task.end_date || !dateRange) return null;
// Calculate position and width for task bar based on time ratios
const totalTimeSpan = dateRange.end.getTime() - dateRange.start.getTime();
const timeFromStart = task.start_date.getTime() - dateRange.start.getTime();
const taskDuration = task.end_date.getTime() - task.start_date.getTime();
const totalWidth = columnsCount * columnWidth;
const left = Math.max(0, (timeFromStart / totalTimeSpan) * totalWidth);
const width = Math.max(10, (taskDuration / totalTimeSpan) * totalWidth);
return (
<div
className="absolute top-1/2 transform -translate-y-1/2 h-6 rounded flex items-center px-2 text-xs text-white font-medium shadow-sm"
style={{
left: `${left}px`,
width: `${width}px`,
backgroundColor: task.color || '#6b7280',
}}
title={`${task.name} - ${task.start_date.toLocaleDateString()} to ${task.end_date.toLocaleDateString()}`}
>
<div className="truncate">{task.name}</div>
{task.progress > 0 && (
<div
className="absolute top-0 left-0 h-full bg-black bg-opacity-20 rounded"
style={{ width: `${task.progress}%` }}
/>
)}
</div>
);
};
const isPhase = task.type === 'milestone' || task.is_milestone;
const renderTaskBar = () => {
if (!task.start_date || !task.end_date || !dateRange) return null;
// Calculate position and width for task bar
const totalDays = Math.ceil((dateRange.end.getTime() - dateRange.start.getTime()) / (1000 * 60 * 60 * 24));
const daysFromStart = Math.floor((task.start_date.getTime() - dateRange.start.getTime()) / (1000 * 60 * 60 * 24));
const taskDuration = Math.ceil((task.end_date.getTime() - task.start_date.getTime()) / (1000 * 60 * 60 * 24));
const left = Math.max(0, (daysFromStart / totalDays) * (columnsCount * columnWidth));
const width = Math.max(10, (taskDuration / totalDays) * (columnsCount * columnWidth));
return ( return (
<div <div
className="absolute top-1/2 transform -translate-y-1/2 h-6 rounded flex items-center px-2 text-xs text-white font-medium shadow-sm" className={`${isPhase ? 'min-h-[4.5rem]' : 'h-9'} relative border-b border-gray-100 dark:border-gray-700 transition-colors ${
style={{ !isPhase ? 'hover:bg-gray-50 dark:hover:bg-gray-750' : ''
left: `${left}px`, }`}
width: `${width}px`, style={
backgroundColor: task.color || '#6b7280' isPhase && task.color
}} ? {
title={`${task.name} - ${task.start_date.toLocaleDateString()} to ${task.end_date.toLocaleDateString()}`} backgroundColor: addAlphaToHex(task.color, 0.15),
}
: {}
}
> >
<div className="truncate">{task.name}</div> {isPhase ? renderMilestone() : renderTaskBar()}
{task.progress > 0 && (
<div
className="absolute top-0 left-0 h-full bg-black bg-opacity-20 rounded"
style={{ width: `${task.progress}%` }}
/>
)}
</div> </div>
); );
}; }
);
const isPhase = task.type === 'milestone' || task.is_milestone;
return (
<div
className={`${isPhase ? 'min-h-[4.5rem]' : 'h-9'} relative border-b border-gray-100 dark:border-gray-700 transition-colors ${
!isPhase ? 'hover:bg-gray-50 dark:hover:bg-gray-750' : ''
}`}
style={isPhase && task.color ? {
backgroundColor: addAlphaToHex(task.color, 0.15),
} : {}}
>
{isPhase ? renderMilestone() : renderTaskBar()}
</div>
);
});
TaskBarRow.displayName = 'TaskBarRow'; TaskBarRow.displayName = 'TaskBarRow';
const GanttChart = forwardRef<HTMLDivElement, GanttChartProps>(({ tasks, viewMode, onScroll, containerRef, dateRange, phases, expandedTasks }, ref) => { const GanttChart = forwardRef<HTMLDivElement, GanttChartProps>(
const columnsCount = useMemo(() => { ({ tasks, viewMode, onScroll, containerRef, dateRange, phases, expandedTasks }, ref) => {
if (!dateRange) { const columnsCount = useMemo(() => {
// Default counts if no date range if (!dateRange) {
// Default counts if no date range
switch (viewMode) {
case 'day':
return 30;
case 'week':
return 12;
case 'month':
return 12;
case 'quarter':
return 8;
case 'year':
return 5;
default:
return 12;
}
}
const { start, end } = dateRange;
const diffTime = Math.abs(end.getTime() - start.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
switch (viewMode) { switch (viewMode) {
case 'day': return 30; case 'day':
case 'week': return 12; return diffDays;
case 'month': return 12; case 'week':
case 'quarter': return 8; return Math.ceil(diffDays / 7);
case 'year': return 5; case 'month':
default: return 12; const startYear = start.getFullYear();
const startMonth = start.getMonth();
const endYear = end.getFullYear();
const endMonth = end.getMonth();
return (endYear - startYear) * 12 + (endMonth - startMonth) + 1;
case 'quarter':
const qStartYear = start.getFullYear();
const qStartQuarter = Math.ceil((start.getMonth() + 1) / 3);
const qEndYear = end.getFullYear();
const qEndQuarter = Math.ceil((end.getMonth() + 1) / 3);
return (qEndYear - qStartYear) * 4 + (qEndQuarter - qStartQuarter) + 1;
case 'year':
return end.getFullYear() - start.getFullYear() + 1;
default:
return 12;
} }
} }, [viewMode, dateRange]);
const { start, end } = dateRange;
const diffTime = Math.abs(end.getTime() - start.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
switch (viewMode) {
case 'day':
return diffDays;
case 'week':
return Math.ceil(diffDays / 7);
case 'month':
const startYear = start.getFullYear();
const startMonth = start.getMonth();
const endYear = end.getFullYear();
const endMonth = end.getMonth();
return (endYear - startYear) * 12 + (endMonth - startMonth) + 1;
case 'quarter':
const qStartYear = start.getFullYear();
const qStartQuarter = Math.ceil((start.getMonth() + 1) / 3);
const qEndYear = end.getFullYear();
const qEndQuarter = Math.ceil((end.getMonth() + 1) / 3);
return (qEndYear - qStartYear) * 4 + (qEndQuarter - qStartQuarter) + 1;
case 'year':
return end.getFullYear() - start.getFullYear() + 1;
default:
return 12;
}
}, [viewMode, dateRange]);
const { actualColumnWidth, totalWidth, shouldScroll } = useGanttDimensions( const { actualColumnWidth, totalWidth, shouldScroll } = useGanttDimensions(
viewMode, viewMode,
containerRef, containerRef,
columnsCount columnsCount
); );
const gridColumns = useMemo(() => const gridColumns = useMemo(
Array.from({ length: columnsCount }).map((_, index) => index) () => Array.from({ length: columnsCount }).map((_, index) => index),
, [columnsCount]); [columnsCount]
);
// Flatten tasks to match the same hierarchy as task list // Flatten tasks to match the same hierarchy as task list
// This should be synchronized with the task list component's expand/collapse state // This should be synchronized with the task list component's expand/collapse state
const flattenedTasks = useMemo(() => { const flattenedTasks = useMemo(() => {
const result: Array<GanttTask | { id: string; isEmptyRow: boolean }> = []; const result: Array<GanttTask | { id: string; isEmptyRow: boolean }> = [];
const processedIds = new Set<string>(); // Track processed task IDs to prevent duplicates
const processTask = (task: GanttTask) => {
result.push(task);
// Check if this is an expanded phase with no children
const isPhase = task.type === 'milestone' || task.is_milestone;
const isEmpty = isPhase && (!task.children || task.children.length === 0);
const isExpanded = expandedTasks ? expandedTasks.has(task.id) : (task.expanded !== false);
if (isEmpty && isExpanded) {
// Add an empty row for the "Add Task" button
result.push({ id: `${task.id}-empty`, isEmptyRow: true });
} else if (task.children && isExpanded) {
task.children.forEach(child => processTask(child));
}
};
tasks.forEach(processTask);
return result;
}, [tasks, expandedTasks]);
return ( const processTask = (task: GanttTask, level: number = 0) => {
<div const isPhase = task.type === 'milestone' || task.is_milestone;
ref={ref} const phaseId = isPhase
className={`flex-1 relative bg-white dark:bg-gray-800 overflow-y-auto ${ ? task.id === 'phase-unmapped'
shouldScroll ? 'overflow-x-auto' : 'overflow-x-hidden' ? 'unmapped'
} gantt-chart-scroll`} : task.phase_id || task.id.replace('phase-', '')
onScroll={onScroll} : task.id;
> const isExpanded = expandedTasks ? expandedTasks.has(phaseId) : task.expanded !== false;
<div
className="relative" // Avoid processing the same task multiple times
style={{ if (processedIds.has(task.id)) {
width: `${totalWidth}px`, return;
minHeight: '100%', }
minWidth: shouldScroll ? 'auto' : '100%' processedIds.add(task.id);
}}
// Set the correct level for nested tasks
const taskWithLevel = { ...task, level };
result.push(taskWithLevel);
if (isPhase && isExpanded) {
// Add children if they exist
if (task.children && task.children.length > 0) {
task.children.forEach(child => processTask(child, level + 1));
}
// Add an empty row for the "Add Task" button at the end (only if not already processed)
const addTaskId = `add-task-${task.id}-timeline`;
if (!processedIds.has(addTaskId)) {
processedIds.add(addTaskId);
result.push({ id: addTaskId, isEmptyRow: true });
}
} else if (!isPhase && task.children && expandedTasks && expandedTasks.has(task.id)) {
task.children.forEach(child => processTask(child, level + 1));
}
};
tasks.forEach(task => processTask(task, 0));
return result;
}, [tasks, expandedTasks]);
return (
<div
ref={ref}
className={`flex-1 relative bg-white dark:bg-gray-800 overflow-y-auto ${
shouldScroll ? 'overflow-x-auto' : 'overflow-x-hidden'
} gantt-chart-scroll`}
onScroll={onScroll}
> >
<div <div
className="absolute top-0 left-0 bottom-0 flex pointer-events-none" className="relative"
style={{ width: `${totalWidth}px` }} style={{
width: `${totalWidth}px`,
minHeight: '100%',
minWidth: shouldScroll ? 'auto' : '100%',
}}
> >
{/* Grid columns for timeline */} <div
{gridColumns.map(index => ( className="absolute top-0 left-0 bottom-0 flex pointer-events-none"
<GridColumn style={{ width: `${totalWidth}px` }}
key={`grid-col-${index}`} >
index={index} {/* Grid columns for timeline */}
columnWidth={actualColumnWidth} {gridColumns.map(index => (
/> <GridColumn key={`grid-col-${index}`} index={index} columnWidth={actualColumnWidth} />
))} ))}
</div> </div>
<div className="relative z-10"> <div className="relative z-10">
{flattenedTasks.map(item => { {flattenedTasks.map(item => {
if ('isEmptyRow' in item && item.isEmptyRow) { if ('isEmptyRow' in item && item.isEmptyRow) {
// Render empty row without "Add Task" button // Render empty row without "Add Task" button
return (
<div
key={item.id}
className="h-9 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"
/>
);
}
return ( return (
<div <TaskBarRow
key={item.id} key={item.id}
className="h-9 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800" task={item as GanttTask}
viewMode={viewMode}
columnWidth={actualColumnWidth}
columnsCount={columnsCount}
dateRange={dateRange}
/> />
); );
} })}
return ( {flattenedTasks.length === 0 && (
<TaskBarRow <div className="flex items-center justify-center h-64 text-gray-400 dark:text-gray-500">
key={item.id} No tasks to display
task={item as GanttTask} </div>
viewMode={viewMode} )}
columnWidth={actualColumnWidth} </div>
columnsCount={columnsCount}
dateRange={dateRange}
/>
);
})}
{flattenedTasks.length === 0 && (
<div className="flex items-center justify-center h-64 text-gray-400 dark:text-gray-500">
No tasks to display
</div>
)}
</div> </div>
</div> </div>
</div> );
); }
}); );
GanttChart.displayName = 'GanttChart'; GanttChart.displayName = 'GanttChart';
export default memo(GanttChart); export default memo(GanttChart);

View File

@@ -9,231 +9,241 @@ interface GanttTimelineProps {
dateRange?: { start: Date; end: Date }; dateRange?: { start: Date; end: Date };
} }
const GanttTimeline = forwardRef<HTMLDivElement, GanttTimelineProps>(({ viewMode, containerRef, dateRange }, ref) => { const GanttTimeline = forwardRef<HTMLDivElement, GanttTimelineProps>(
const { topHeaders, bottomHeaders } = useMemo(() => { ({ viewMode, containerRef, dateRange }, ref) => {
if (!dateRange) { const { topHeaders, bottomHeaders } = useMemo(() => {
return { topHeaders: [], bottomHeaders: [] }; if (!dateRange) {
} return { topHeaders: [], bottomHeaders: [] };
}
const { start, end } = dateRange;
const topHeaders: Array<{ label: string; key: string; span: number }> = []; const { start, end } = dateRange;
const bottomHeaders: Array<{ label: string; key: string }> = []; const topHeaders: Array<{ label: string; key: string; span: number }> = [];
const bottomHeaders: Array<{ label: string; key: string }> = [];
switch (viewMode) {
case 'month': switch (viewMode) {
// Top: Years, Bottom: Months case 'month':
const startYear = start.getFullYear(); // Top: Years, Bottom: Months
const startMonth = start.getMonth(); const startYear = start.getFullYear();
const endYear = end.getFullYear(); const startMonth = start.getMonth();
const endMonth = end.getMonth(); const endYear = end.getFullYear();
const endMonth = end.getMonth();
// Generate bottom headers (months)
let currentYear = startYear; // Generate bottom headers (months)
let currentMonth = startMonth; let currentYear = startYear;
let currentMonth = startMonth;
while (currentYear < endYear || (currentYear === endYear && currentMonth <= endMonth)) {
const date = new Date(currentYear, currentMonth, 1); while (currentYear < endYear || (currentYear === endYear && currentMonth <= endMonth)) {
bottomHeaders.push({ const date = new Date(currentYear, currentMonth, 1);
label: date.toLocaleDateString('en-US', { month: 'short' }), bottomHeaders.push({
key: `month-${currentYear}-${currentMonth}`, 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
}); });
}
} currentMonth++;
break; if (currentMonth > 11) {
currentMonth = 0;
case 'week': currentYear++;
// 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<string, number>();
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<string, number>();
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(); // Generate top headers (years)
const yearEnd = end.getFullYear(); for (let year = startYear; year <= endYear; year++) {
const monthsInYear = bottomHeaders.filter(h => h.key.includes(`-${year}-`)).length;
for (let year = yearStart; year <= yearEnd; year++) { if (monthsInYear > 0) {
result.push({ topHeaders.push({
label: `${year}`, label: `${year}`,
key: `year-${year}`, key: `year-${year}`,
span: monthsInYear,
}); });
} }
break; }
} break;
result.forEach(item => {
bottomHeaders.push(item);
});
break;
}
return { topHeaders, bottomHeaders };
}, [viewMode, dateRange]);
const { actualColumnWidth, totalWidth, shouldScroll } = useGanttDimensions( case 'week':
viewMode, // Top: Months, Bottom: Weeks
containerRef, const weekStart = new Date(start);
bottomHeaders.length const weekEnd = new Date(end);
); weekStart.setDate(weekStart.getDate() - weekStart.getDay());
const hasTopHeaders = topHeaders.length > 0; const weekDates: Date[] = [];
const tempDate = new Date(weekStart);
while (tempDate <= weekEnd) {
weekDates.push(new Date(tempDate));
tempDate.setDate(tempDate.getDate() + 7);
}
return ( // Generate bottom headers (weeks)
<div weekDates.forEach(date => {
ref={ref} const weekNum = TimelineUtils.getWeekNumber(date);
className={`${hasTopHeaders ? 'h-20' : 'h-10'} flex-shrink-0 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 overflow-y-hidden ${ bottomHeaders.push({
shouldScroll ? 'overflow-x-auto' : 'overflow-x-hidden' label: `W${weekNum}`,
} scrollbar-hide flex flex-col`} key: `week-${date.getFullYear()}-${weekNum}`,
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }} });
> });
{hasTopHeaders && (
<div className="flex h-10 border-b border-gray-200 dark:border-gray-700" style={{ width: `${totalWidth}px`, minWidth: shouldScroll ? 'auto' : '100%' }}> // Generate top headers (months)
{topHeaders.map(header => ( const monthGroups = new Map<string, number>();
<div weekDates.forEach(date => {
key={header.key} const monthKey = `${date.getFullYear()}-${date.getMonth()}`;
className="py-2.5 text-center border-r border-gray-200 dark:border-gray-700 text-sm font-semibold text-gray-800 dark:text-gray-200 flex-shrink-0 px-2 whitespace-nowrap bg-gray-50 dark:bg-gray-750" monthGroups.set(monthKey, (monthGroups.get(monthKey) || 0) + 1);
style={{ width: `${actualColumnWidth * header.span}px` }} });
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<string, number>();
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 (
<div
ref={ref}
className={`${hasTopHeaders ? 'h-20' : 'h-10'} flex-shrink-0 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 overflow-y-hidden ${
shouldScroll ? 'overflow-x-auto' : 'overflow-x-hidden'
} scrollbar-hide flex flex-col`}
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
{hasTopHeaders && (
<div
className="flex h-10 border-b border-gray-200 dark:border-gray-700"
style={{ width: `${totalWidth}px`, minWidth: shouldScroll ? 'auto' : '100%' }}
>
{topHeaders.map(header => (
<div
key={header.key}
className="py-2.5 text-center border-r border-gray-200 dark:border-gray-700 text-sm font-semibold text-gray-800 dark:text-gray-200 flex-shrink-0 px-2 whitespace-nowrap bg-gray-50 dark:bg-gray-750"
style={{ width: `${actualColumnWidth * header.span}px` }}
title={header.label}
>
{header.label}
</div>
))}
</div>
)}
<div
className="flex h-10"
style={{ width: `${totalWidth}px`, minWidth: shouldScroll ? 'auto' : '100%' }}
>
{bottomHeaders.map(header => (
<div
key={header.key}
className={`py-2.5 text-center border-r border-gray-200 dark:border-gray-700 text-sm font-medium text-gray-700 dark:text-gray-300 flex-shrink-0 ${
viewMode === 'day' ? 'px-1 text-xs' : 'px-2'
} ${
viewMode === 'day' && actualColumnWidth < 50
? 'whitespace-nowrap overflow-hidden text-ellipsis'
: 'whitespace-nowrap'
}`}
style={{ width: `${actualColumnWidth}px` }}
title={header.label} title={header.label}
> >
{header.label} {header.label}
</div> </div>
))} ))}
</div> </div>
)}
<div className="flex h-10" style={{ width: `${totalWidth}px`, minWidth: shouldScroll ? 'auto' : '100%' }}>
{bottomHeaders.map(header => (
<div
key={header.key}
className={`py-2.5 text-center border-r border-gray-200 dark:border-gray-700 text-sm font-medium text-gray-700 dark:text-gray-300 flex-shrink-0 ${
viewMode === 'day' ? 'px-1 text-xs' : 'px-2'
} ${
viewMode === 'day' && actualColumnWidth < 50 ? 'whitespace-nowrap overflow-hidden text-ellipsis' : 'whitespace-nowrap'
}`}
style={{ width: `${actualColumnWidth}px` }}
title={header.label}
>
{header.label}
</div>
))}
</div> </div>
</div> );
); }
}); );
GanttTimeline.displayName = 'GanttTimeline'; GanttTimeline.displayName = 'GanttTimeline';
export default memo(GanttTimeline); export default memo(GanttTimeline);

View File

@@ -1,6 +1,12 @@
import React, { memo } from 'react'; import React, { memo } from 'react';
import { Select, Button, Space, Divider } from 'antd'; import { Select, Button, Space, Divider } from 'antd';
import { ZoomInOutlined, ZoomOutOutlined, FullscreenOutlined, PlusOutlined, FlagOutlined } from '@ant-design/icons'; import {
ZoomInOutlined,
ZoomOutOutlined,
FullscreenOutlined,
PlusOutlined,
FlagOutlined,
} from '@ant-design/icons';
import { GanttViewMode } from '../../types/gantt-types'; import { GanttViewMode } from '../../types/gantt-types';
const { Option } = Select; const { Option } = Select;
@@ -13,58 +19,56 @@ interface GanttToolbarProps {
onCreateTask?: () => void; onCreateTask?: () => void;
} }
const GanttToolbar: React.FC<GanttToolbarProps> = memo(({ viewMode, onViewModeChange, dateRange, onCreatePhase, onCreateTask }) => { const GanttToolbar: React.FC<GanttToolbarProps> = memo(
return ( ({ viewMode, onViewModeChange, dateRange, onCreatePhase, onCreateTask }) => {
<div className="p-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center"> return (
<Space> <div className="p-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<Button <Space>
type="primary" <Button
icon={<FlagOutlined />} type="primary"
onClick={onCreatePhase} icon={<FlagOutlined />}
className="bg-blue-600 hover:bg-blue-700 border-blue-600" onClick={onCreatePhase}
> className="bg-blue-600 hover:bg-blue-700 border-blue-600"
Manage Phases >
</Button> Manage Phases
<Button </Button>
icon={<PlusOutlined />} <Button
onClick={onCreateTask} icon={<PlusOutlined />}
className="hover:text-blue-600 dark:hover:text-blue-400 hover:border-blue-600" onClick={onCreateTask}
> className="hover:text-blue-600 dark:hover:text-blue-400 hover:border-blue-600"
Add Task >
</Button> Add Task
<Divider type="vertical" className="bg-gray-300 dark:bg-gray-600" /> </Button>
<Select <Divider type="vertical" className="bg-gray-300 dark:bg-gray-600" />
value={viewMode} <Select value={viewMode} onChange={onViewModeChange} className="w-32">
onChange={onViewModeChange} <Option value="day">Day</Option>
className="w-32" <Option value="week">Week</Option>
> <Option value="month">Month</Option>
<Option value="day">Day</Option> <Option value="quarter">Quarter</Option>
<Option value="week">Week</Option> <Option value="year">Year</Option>
<Option value="month">Month</Option> </Select>
<Option value="quarter">Quarter</Option>
<Option value="year">Year</Option> <Button
</Select> icon={<ZoomInOutlined />}
title="Zoom In"
<Button className="hover:text-blue-600 dark:hover:text-blue-400"
icon={<ZoomInOutlined />} />
title="Zoom In" <Button
className="hover:text-blue-600 dark:hover:text-blue-400" icon={<ZoomOutOutlined />}
/> title="Zoom Out"
<Button className="hover:text-blue-600 dark:hover:text-blue-400"
icon={<ZoomOutOutlined />} />
title="Zoom Out" <Button
className="hover:text-blue-600 dark:hover:text-blue-400" icon={<FullscreenOutlined />}
/> title="Fullscreen"
<Button className="hover:text-blue-600 dark:hover:text-blue-400"
icon={<FullscreenOutlined />} />
title="Fullscreen" </Space>
className="hover:text-blue-600 dark:hover:text-blue-400" </div>
/> );
</Space> }
</div> );
);
});
GanttToolbar.displayName = 'GanttToolbar'; GanttToolbar.displayName = 'GanttToolbar';
export default GanttToolbar; export default GanttToolbar;

View File

@@ -15,4 +15,4 @@ export const getColumnWidth = (viewMode: string): number => {
default: default:
return 80; return 80;
} }
}; };

View File

@@ -16,4 +16,4 @@ export const useGanttContext = () => {
throw new Error('useGanttContext must be used within a GanttProvider'); throw new Error('useGanttContext must be used within a GanttProvider');
} }
return context; return context;
}; };

View File

@@ -5,8 +5,8 @@
/* Hide scrollbar for IE, Edge and Firefox */ /* Hide scrollbar for IE, Edge and Firefox */
.scrollbar-hide { .scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */ -ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */ scrollbar-width: none; /* Firefox */
} }
/* Gantt task list specific styles */ /* Gantt task list specific styles */
@@ -35,7 +35,6 @@
display: none; display: none;
} }
/* Gantt chart scrollbar - show both vertical and horizontal */ /* Gantt chart scrollbar - show both vertical and horizontal */
.gantt-chart-scroll::-webkit-scrollbar { .gantt-chart-scroll::-webkit-scrollbar {
width: 8px; width: 8px;
@@ -108,7 +107,7 @@
} }
.gantt-phase-row::before { .gantt-phase-row::before {
content: ''; content: "";
position: absolute; position: absolute;
left: 0; left: 0;
top: 0; top: 0;
@@ -141,7 +140,9 @@
/* Phase expansion transitions */ /* Phase expansion transitions */
.gantt-phase-children { .gantt-phase-children {
overflow: hidden; overflow: hidden;
transition: max-height 0.3s ease-in-out, opacity 0.2s ease-in-out; transition:
max-height 0.3s ease-in-out,
opacity 0.2s ease-in-out;
} }
.gantt-phase-children.collapsed { .gantt-phase-children.collapsed {
@@ -194,4 +195,4 @@
opacity: 1; opacity: 1;
transform: translateY(0); transform: translateY(0);
} }
} }

View File

@@ -23,15 +23,16 @@ export const useGanttDimensions = (
const baseColumnWidth = getColumnWidth(viewMode); const baseColumnWidth = getColumnWidth(viewMode);
const minTotalWidth = columnsCount * baseColumnWidth; const minTotalWidth = columnsCount * baseColumnWidth;
// For day/week views with many columns, always use base width to enable scrolling // For day/week views with many columns, always use base width to enable scrolling
// For month/quarter/year views, stretch to fill container if wider // For month/quarter/year views, stretch to fill container if wider
const shouldStretch = viewMode !== 'day' && viewMode !== 'week'; const shouldStretch = viewMode !== 'day' && viewMode !== 'week';
const actualColumnWidth = shouldStretch && containerWidth > minTotalWidth const actualColumnWidth =
? containerWidth / columnsCount shouldStretch && containerWidth > minTotalWidth
: baseColumnWidth; ? containerWidth / columnsCount
: baseColumnWidth;
const totalWidth = columnsCount * actualColumnWidth; const totalWidth = columnsCount * actualColumnWidth;
return { return {
@@ -39,6 +40,6 @@ export const useGanttDimensions = (
actualColumnWidth, actualColumnWidth,
totalWidth, totalWidth,
columnsCount, columnsCount,
shouldScroll: totalWidth > containerWidth shouldScroll: totalWidth > containerWidth,
}; };
}; };

View File

@@ -21,7 +21,11 @@ export interface RoadmapTasksResponse {
priority_name: string; priority_name: string;
priority_value: number; priority_value: number;
priority_color: string; priority_color: string;
phase_id: string | null; phases: Array<{
phase_id: string;
phase_name: string;
phase_color: string;
}>;
assignees: Array<{ assignees: Array<{
team_member_id: string; team_member_id: string;
assignee_name: string; assignee_name: string;
@@ -41,7 +45,7 @@ export interface RoadmapTasksResponse {
progress: number; progress: number;
roadmap_sort_order: number; roadmap_sort_order: number;
parent_task_id: string; parent_task_id: string;
phase_id: string | null; phase_id?: string | null; // Keep this for subtasks compatibility
}>; }>;
} }
@@ -52,6 +56,10 @@ export interface ProjectPhaseResponse {
start_date: string | null; start_date: string | null;
end_date: string | null; end_date: string | null;
sort_index: number; sort_index: number;
todo_progress: number;
doing_progress: number;
done_progress: number;
total_tasks: number;
} }
export interface UpdateTaskDatesRequest { export interface UpdateTaskDatesRequest {
@@ -108,10 +116,7 @@ export const ganttApi = createApi({
}), }),
tagTypes: ['GanttTasks', 'GanttPhases'], tagTypes: ['GanttTasks', 'GanttPhases'],
endpoints: builder => ({ endpoints: builder => ({
getRoadmapTasks: builder.query< getRoadmapTasks: builder.query<IServerResponse<RoadmapTasksResponse[]>, { projectId: string }>({
IServerResponse<RoadmapTasksResponse[]>,
{ projectId: string }
>({
query: ({ projectId }) => { query: ({ projectId }) => {
const params = new URLSearchParams({ const params = new URLSearchParams({
project_id: projectId, project_id: projectId,
@@ -124,40 +129,31 @@ export const ganttApi = createApi({
], ],
}), }),
getProjectPhases: builder.query< getProjectPhases: builder.query<IServerResponse<ProjectPhaseResponse[]>, { projectId: string }>(
IServerResponse<ProjectPhaseResponse[]>, {
{ projectId: string } query: ({ projectId }) => {
>({ const params = new URLSearchParams({
query: ({ projectId }) => { project_id: projectId,
const params = new URLSearchParams({ });
project_id: projectId, return `${rootUrl}/project-phases?${params.toString()}`;
}); },
return `${rootUrl}/project-phases?${params.toString()}`; providesTags: (result, error, { projectId }) => [
}, { type: 'GanttPhases', id: projectId },
providesTags: (result, error, { projectId }) => [ { type: 'GanttPhases', id: 'LIST' },
{ type: 'GanttPhases', id: projectId }, ],
{ type: 'GanttPhases', id: 'LIST' }, }
], ),
}),
updateTaskDates: builder.mutation< updateTaskDates: builder.mutation<IServerResponse<any>, UpdateTaskDatesRequest>({
IServerResponse<any>,
UpdateTaskDatesRequest
>({
query: body => ({ query: body => ({
url: `${rootUrl}/update-task-dates`, url: `${rootUrl}/update-task-dates`,
method: 'POST', method: 'POST',
body, body,
}), }),
invalidatesTags: (result, error, { task_id }) => [ invalidatesTags: (result, error, { task_id }) => [{ type: 'GanttTasks', id: 'LIST' }],
{ type: 'GanttTasks', id: 'LIST' },
],
}), }),
createPhase: builder.mutation< createPhase: builder.mutation<IServerResponse<ProjectPhaseResponse>, CreatePhaseRequest>({
IServerResponse<ProjectPhaseResponse>,
CreatePhaseRequest
>({
query: body => ({ query: body => ({
url: `${rootUrl}/create-phase`, url: `${rootUrl}/create-phase`,
method: 'POST', method: 'POST',
@@ -171,10 +167,7 @@ export const ganttApi = createApi({
], ],
}), }),
createTask: builder.mutation< createTask: builder.mutation<IServerResponse<RoadmapTasksResponse>, CreateTaskRequest>({
IServerResponse<RoadmapTasksResponse>,
CreateTaskRequest
>({
query: body => ({ query: body => ({
url: `${rootUrl}/create-task`, url: `${rootUrl}/create-task`,
method: 'POST', method: 'POST',
@@ -186,10 +179,7 @@ export const ganttApi = createApi({
], ],
}), }),
updatePhase: builder.mutation< updatePhase: builder.mutation<IServerResponse<ProjectPhaseResponse>, UpdatePhaseRequest>({
IServerResponse<ProjectPhaseResponse>,
UpdatePhaseRequest
>({
query: body => ({ query: body => ({
url: `${rootUrl}/update-phase`, url: `${rootUrl}/update-phase`,
method: 'PUT', method: 'PUT',
@@ -217,17 +207,23 @@ export const {
/** /**
* Transform API response to Gantt task format with phases as milestones * Transform API response to Gantt task format with phases as milestones
*/ */
export const transformToGanttTasks = (apiTasks: RoadmapTasksResponse[], apiPhases: ProjectPhaseResponse[]): GanttTask[] => { export const transformToGanttTasks = (
apiTasks: RoadmapTasksResponse[],
apiPhases: ProjectPhaseResponse[]
): GanttTask[] => {
// Group tasks by phase // Group tasks by phase
const tasksByPhase = new Map<string, RoadmapTasksResponse[]>(); const tasksByPhase = new Map<string, RoadmapTasksResponse[]>();
const unassignedTasks: RoadmapTasksResponse[] = []; const unassignedTasks: RoadmapTasksResponse[] = [];
apiTasks.forEach(task => { apiTasks.forEach(task => {
if (task.phase_id) { // Tasks now have phases array instead of direct phase_id
if (!tasksByPhase.has(task.phase_id)) { const taskPhaseId = task.phases.length > 0 ? task.phases[0].phase_id : null;
tasksByPhase.set(task.phase_id, []);
if (taskPhaseId) {
if (!tasksByPhase.has(taskPhaseId)) {
tasksByPhase.set(taskPhaseId, []);
} }
tasksByPhase.get(task.phase_id)!.push(task); tasksByPhase.get(taskPhaseId)!.push(task);
} else { } else {
unassignedTasks.push(task); unassignedTasks.push(task);
} }
@@ -240,7 +236,7 @@ export const transformToGanttTasks = (apiTasks: RoadmapTasksResponse[], apiPhase
.sort((a, b) => a.sort_index - b.sort_index) .sort((a, b) => a.sort_index - b.sort_index)
.forEach(phase => { .forEach(phase => {
const phaseTasks = tasksByPhase.get(phase.id) || []; const phaseTasks = tasksByPhase.get(phase.id) || [];
// Create phase milestone // Create phase milestone
const phaseMilestone: GanttTask = { const phaseMilestone: GanttTask = {
id: `phase-${phase.id}`, id: `phase-${phase.id}`,
@@ -254,7 +250,12 @@ export const transformToGanttTasks = (apiTasks: RoadmapTasksResponse[], apiPhase
type: 'milestone', type: 'milestone',
is_milestone: true, is_milestone: true,
phase_id: phase.id, phase_id: phase.id,
children: phaseTasks.map(task => transformTask(task, 1)) // Pass through phase progress data from backend
todo_progress: phase.todo_progress,
doing_progress: phase.doing_progress,
done_progress: phase.done_progress,
total_tasks: phase.total_tasks,
children: phaseTasks.map(task => transformTask(task, 1)),
}; };
result.push(phaseMilestone); result.push(phaseMilestone);
@@ -273,7 +274,7 @@ export const transformToGanttTasks = (apiTasks: RoadmapTasksResponse[], apiPhase
type: 'milestone', type: 'milestone',
is_milestone: true, is_milestone: true,
phase_id: null, phase_id: null,
children: unassignedTasks.map(task => transformTask(task, 1)) children: unassignedTasks.map(task => transformTask(task, 1)),
}; };
result.push(unmappedPhase); result.push(unmappedPhase);
@@ -284,36 +285,40 @@ export const transformToGanttTasks = (apiTasks: RoadmapTasksResponse[], apiPhase
/** /**
* Helper function to transform individual task * Helper function to transform individual task
*/ */
const transformTask = (task: RoadmapTasksResponse, level: number = 0): GanttTask => ({ const transformTask = (task: RoadmapTasksResponse, level: number = 0): GanttTask => {
id: task.id, const taskPhaseId = task.phases.length > 0 ? task.phases[0].phase_id : null;
name: task.name,
start_date: task.start_date ? new Date(task.start_date) : null, return {
end_date: task.end_date ? new Date(task.end_date) : null, id: task.id,
progress: task.progress, name: task.name,
dependencies: task.dependencies.map(dep => dep.related_task_id), start_date: task.start_date ? new Date(task.start_date) : null,
dependencyType: task.dependencies[0]?.dependency_type as any || 'blocked_by', end_date: task.end_date ? new Date(task.end_date) : null,
parent_id: task.parent_task_id, progress: task.progress,
children: task.subtasks.map(subtask => ({ dependencies: task.dependencies.map(dep => dep.related_task_id),
id: subtask.id, dependencyType: (task.dependencies[0]?.dependency_type as any) || 'blocked_by',
name: subtask.name, parent_id: task.parent_task_id,
start_date: subtask.start_date ? new Date(subtask.start_date) : null, children: task.subtasks.map(subtask => ({
end_date: subtask.end_date ? new Date(subtask.end_date) : null, id: subtask.id,
progress: subtask.progress, name: subtask.name,
parent_id: subtask.parent_task_id, start_date: subtask.start_date ? new Date(subtask.start_date) : null,
level: level + 1, end_date: subtask.end_date ? new Date(subtask.end_date) : null,
progress: subtask.progress,
parent_id: subtask.parent_task_id,
level: level + 1,
type: 'task',
phase_id: subtask.phase_id, // Subtasks might still use direct phase_id
})),
level,
expanded: true,
color: task.status_color || task.priority_color,
assignees: task.assignees.map(a => a.assignee_name),
priority: task.priority_name,
status: task.status_name,
phase_id: taskPhaseId,
is_milestone: false,
type: 'task', type: 'task',
phase_id: subtask.phase_id };
})), };
level,
expanded: true,
color: task.status_color || task.priority_color,
assignees: task.assignees.map(a => a.assignee_name),
priority: task.priority_name,
status: task.status_name,
phase_id: task.phase_id,
is_milestone: false,
type: 'task'
});
/** /**
* Transform API response to Gantt phases format * Transform API response to Gantt phases format
@@ -325,6 +330,10 @@ export const transformToGanttPhases = (apiPhases: ProjectPhaseResponse[]): Gantt
color_code: phase.color_code, color_code: phase.color_code,
start_date: phase.start_date ? new Date(phase.start_date) : null, start_date: phase.start_date ? new Date(phase.start_date) : null,
end_date: phase.end_date ? new Date(phase.end_date) : null, end_date: phase.end_date ? new Date(phase.end_date) : null,
sort_index: phase.sort_index sort_index: phase.sort_index,
todo_progress: phase.todo_progress,
doing_progress: phase.doing_progress,
done_progress: phase.done_progress,
total_tasks: phase.total_tasks,
})); }));
}; };

View File

@@ -1,6 +1,11 @@
export type GanttViewMode = 'day' | 'week' | 'month' | 'quarter' | 'year'; export type GanttViewMode = 'day' | 'week' | 'month' | 'quarter' | 'year';
export type DependencyType = 'blocked_by' | 'finish_to_start' | 'start_to_start' | 'finish_to_finish' | 'start_to_finish'; export type DependencyType =
| 'blocked_by'
| 'finish_to_start'
| 'start_to_start'
| 'finish_to_finish'
| 'start_to_finish';
export interface GanttTask { export interface GanttTask {
id: string; id: string;
@@ -20,7 +25,9 @@ export interface GanttTask {
status?: string; status?: string;
phase_id?: string; phase_id?: string;
is_milestone?: boolean; is_milestone?: boolean;
type?: 'task' | 'milestone' | 'phase'; type?: 'task' | 'milestone' | 'phase' | 'add-task-button';
// Add task row specific properties
parent_phase_id?: string;
} }
export interface GanttPhase { export interface GanttPhase {
@@ -53,4 +60,4 @@ export interface GanttContextType {
projectId: string; projectId: string;
dateRange: { start: Date; end: Date }; dateRange: { start: Date; end: Date };
onRefresh: () => void; onRefresh: () => void;
} }

View File

@@ -18,12 +18,7 @@ export class TimelineCalculator {
private columnWidth: number; private columnWidth: number;
private timelineBounds: TimelineBounds; private timelineBounds: TimelineBounds;
constructor( constructor(viewMode: GanttViewMode, columnWidth: number, startDate: Date, endDate: Date) {
viewMode: GanttViewMode,
columnWidth: number,
startDate: Date,
endDate: Date
) {
this.viewMode = viewMode; this.viewMode = viewMode;
this.columnWidth = columnWidth; this.columnWidth = columnWidth;
this.timelineBounds = this.calculateTimelineBounds(startDate, endDate); this.timelineBounds = this.calculateTimelineBounds(startDate, endDate);
@@ -42,7 +37,7 @@ export class TimelineCalculator {
startDate, startDate,
endDate, endDate,
totalDays, totalDays,
pixelsPerDay pixelsPerDay,
}; };
} }
@@ -51,12 +46,18 @@ export class TimelineCalculator {
*/ */
private getColumnsCount(): number { private getColumnsCount(): number {
switch (this.viewMode) { switch (this.viewMode) {
case 'day': return 30; case 'day':
case 'week': return 12; return 30;
case 'month': return 12; case 'week':
case 'quarter': return 8; return 12;
case 'year': return 5; case 'month':
default: return 12; return 12;
case 'quarter':
return 8;
case 'year':
return 5;
default:
return 12;
} }
} }
@@ -70,14 +71,20 @@ export class TimelineCalculator {
const taskStart = new Date(task.start_date); const taskStart = new Date(task.start_date);
const taskEnd = new Date(task.end_date); const taskEnd = new Date(task.end_date);
// Ensure task dates are within timeline bounds // Ensure task dates are within timeline bounds
const clampedStart = new Date(Math.max(taskStart.getTime(), this.timelineBounds.startDate.getTime())); const clampedStart = new Date(
Math.max(taskStart.getTime(), this.timelineBounds.startDate.getTime())
);
const clampedEnd = new Date(Math.min(taskEnd.getTime(), this.timelineBounds.endDate.getTime())); const clampedEnd = new Date(Math.min(taskEnd.getTime(), this.timelineBounds.endDate.getTime()));
// Calculate days from timeline start // Calculate days from timeline start
const daysFromStart = Math.floor((clampedStart.getTime() - this.timelineBounds.startDate.getTime()) / (1000 * 60 * 60 * 24)); const daysFromStart = Math.floor(
const taskDuration = Math.ceil((clampedEnd.getTime() - clampedStart.getTime()) / (1000 * 60 * 60 * 24)); (clampedStart.getTime() - this.timelineBounds.startDate.getTime()) / (1000 * 60 * 60 * 24)
);
const taskDuration = Math.ceil(
(clampedEnd.getTime() - clampedStart.getTime()) / (1000 * 60 * 60 * 24)
);
// Calculate pixel positions // Calculate pixel positions
const left = daysFromStart * this.timelineBounds.pixelsPerDay; const left = daysFromStart * this.timelineBounds.pixelsPerDay;
@@ -86,7 +93,7 @@ export class TimelineCalculator {
return { return {
left: Math.max(0, left), left: Math.max(0, left),
width, width,
isValid: true isValid: true,
}; };
} }
@@ -99,18 +106,23 @@ export class TimelineCalculator {
} }
const milestoneDate = new Date(date); const milestoneDate = new Date(date);
// Check if milestone is within timeline bounds // Check if milestone is within timeline bounds
if (milestoneDate < this.timelineBounds.startDate || milestoneDate > this.timelineBounds.endDate) { if (
milestoneDate < this.timelineBounds.startDate ||
milestoneDate > this.timelineBounds.endDate
) {
return { left: 0, isValid: false }; return { left: 0, isValid: false };
} }
const daysFromStart = Math.floor((milestoneDate.getTime() - this.timelineBounds.startDate.getTime()) / (1000 * 60 * 60 * 24)); const daysFromStart = Math.floor(
(milestoneDate.getTime() - this.timelineBounds.startDate.getTime()) / (1000 * 60 * 60 * 24)
);
const left = daysFromStart * this.timelineBounds.pixelsPerDay; const left = daysFromStart * this.timelineBounds.pixelsPerDay;
return { return {
left: Math.max(0, left), left: Math.max(0, left),
isValid: true isValid: true,
}; };
} }
@@ -118,8 +130,8 @@ export class TimelineCalculator {
* Calculate dependency line coordinates * Calculate dependency line coordinates
*/ */
calculateDependencyLine( calculateDependencyLine(
fromTask: GanttTask, fromTask: GanttTask,
toTask: GanttTask, toTask: GanttTask,
rowHeight: number = 36 rowHeight: number = 36
): { ): {
x1: number; x1: number;
@@ -144,7 +156,7 @@ export class TimelineCalculator {
y1: fromY + rowHeight / 2, y1: fromY + rowHeight / 2,
x2: toPosition.left, // Start of target task x2: toPosition.left, // Start of target task
y2: toY + rowHeight / 2, y2: toY + rowHeight / 2,
isValid: true isValid: true,
}; };
} }
@@ -164,10 +176,10 @@ export class TimelineCalculator {
getTodayLinePosition(): { left: number; isVisible: boolean } { getTodayLinePosition(): { left: number; isVisible: boolean } {
const today = new Date(); const today = new Date();
const position = this.calculateMilestonePosition(today); const position = this.calculateMilestonePosition(today);
return { return {
left: position.left, left: position.left,
isVisible: position.isValid isVisible: position.isValid,
}; };
} }
@@ -177,7 +189,7 @@ export class TimelineCalculator {
getWeekendAreas(): Array<{ left: number; width: number }> { getWeekendAreas(): Array<{ left: number; width: number }> {
const weekendAreas: Array<{ left: number; width: number }> = []; const weekendAreas: Array<{ left: number; width: number }> = [];
const current = new Date(this.timelineBounds.startDate); const current = new Date(this.timelineBounds.startDate);
while (current <= this.timelineBounds.endDate) { while (current <= this.timelineBounds.endDate) {
// Saturday (6) and Sunday (0) // Saturday (6) and Sunday (0)
if (current.getDay() === 0 || current.getDay() === 6) { if (current.getDay() === 0 || current.getDay() === 6) {
@@ -185,13 +197,13 @@ export class TimelineCalculator {
if (position.isValid) { if (position.isValid) {
weekendAreas.push({ weekendAreas.push({
left: position.left, left: position.left,
width: this.timelineBounds.pixelsPerDay width: this.timelineBounds.pixelsPerDay,
}); });
} }
} }
current.setDate(current.getDate() + 1); current.setDate(current.getDate() + 1);
} }
return weekendAreas; return weekendAreas;
} }
@@ -205,7 +217,12 @@ export class TimelineCalculator {
/** /**
* Update calculator with new parameters * Update calculator with new parameters
*/ */
updateParameters(viewMode: GanttViewMode, columnWidth: number, startDate: Date, endDate: Date): void { updateParameters(
viewMode: GanttViewMode,
columnWidth: number,
startDate: Date,
endDate: Date
): void {
this.viewMode = viewMode; this.viewMode = viewMode;
this.columnWidth = columnWidth; this.columnWidth = columnWidth;
this.timelineBounds = this.calculateTimelineBounds(startDate, endDate); this.timelineBounds = this.calculateTimelineBounds(startDate, endDate);
@@ -244,7 +261,7 @@ export const TimelineUtils = {
latestEnd = task.end_date; latestEnd = task.end_date;
} }
} }
// Check subtasks too // Check subtasks too
if (task.children) { if (task.children) {
task.children.forEach(subtask => { task.children.forEach(subtask => {
@@ -316,6 +333,6 @@ export const TimelineUtils = {
const dayNum = d.getUTCDay() || 7; const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum); d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
} },
}; };

View File

@@ -188,16 +188,16 @@ export interface AdvancedGanttProps {
// Data // Data
tasks: GanttTask[]; tasks: GanttTask[];
columns?: ColumnConfig[]; columns?: ColumnConfig[];
// Configuration // Configuration
timelineConfig?: Partial<TimelineConfig>; timelineConfig?: Partial<TimelineConfig>;
virtualScrollConfig?: Partial<VirtualScrollConfig>; virtualScrollConfig?: Partial<VirtualScrollConfig>;
zoomLevels?: ZoomLevel[]; zoomLevels?: ZoomLevel[];
// Initial State // Initial State
initialViewState?: Partial<GanttViewState>; initialViewState?: Partial<GanttViewState>;
initialSelection?: string[]; initialSelection?: string[];
// Event Handlers // Event Handlers
onTaskUpdate?: (taskId: string, updates: Partial<GanttTask>) => void; onTaskUpdate?: (taskId: string, updates: Partial<GanttTask>) => void;
onTaskCreate?: (task: Omit<GanttTask, 'id'>) => void; onTaskCreate?: (task: Omit<GanttTask, 'id'>) => void;
@@ -209,13 +209,13 @@ export interface AdvancedGanttProps {
onColumnResize?: ColumnResizeHandler; onColumnResize?: ColumnResizeHandler;
onDependencyCreate?: (fromTaskId: string, toTaskId: string) => void; onDependencyCreate?: (fromTaskId: string, toTaskId: string) => void;
onDependencyDelete?: (fromTaskId: string, toTaskId: string) => void; onDependencyDelete?: (fromTaskId: string, toTaskId: string) => void;
// UI Customization // UI Customization
className?: string; className?: string;
style?: React.CSSProperties; style?: React.CSSProperties;
theme?: 'light' | 'dark' | 'auto'; theme?: 'light' | 'dark' | 'auto';
locale?: string; locale?: string;
// Feature Flags // Feature Flags
enableDragDrop?: boolean; enableDragDrop?: boolean;
enableResize?: boolean; enableResize?: boolean;
@@ -225,7 +225,7 @@ export interface AdvancedGanttProps {
enableTooltips?: boolean; enableTooltips?: boolean;
enableExport?: boolean; enableExport?: boolean;
enablePrint?: boolean; enablePrint?: boolean;
// Performance Options // Performance Options
enableVirtualScrolling?: boolean; enableVirtualScrolling?: boolean;
enableDebouncing?: boolean; enableDebouncing?: boolean;
@@ -258,7 +258,14 @@ export interface ExportOptions {
// Filter and Search // Filter and Search
export interface FilterConfig { export interface FilterConfig {
field: string; field: string;
operator: 'equals' | 'contains' | 'startsWith' | 'endsWith' | 'greaterThan' | 'lessThan' | 'between'; operator:
| 'equals'
| 'contains'
| 'startsWith'
| 'endsWith'
| 'greaterThan'
| 'lessThan'
| 'between';
value: any; value: any;
logic?: 'and' | 'or'; logic?: 'and' | 'or';
} }
@@ -304,4 +311,4 @@ export interface KeyboardShortcut {
action: string; action: string;
description: string; description: string;
handler: (event: KeyboardEvent) => void; handler: (event: KeyboardEvent) => void;
} }

View File

@@ -2,37 +2,37 @@ import { useMemo, useCallback, useRef, useEffect } from 'react';
import { GanttTask, PerformanceMetrics } from '../types/advanced-gantt.types'; import { GanttTask, PerformanceMetrics } from '../types/advanced-gantt.types';
// Debounce utility for drag operations // Debounce utility for drag operations
export function useDebounce<T extends (...args: any[]) => any>( export function useDebounce<T extends (...args: any[]) => any>(callback: T, delay: number): T {
callback: T,
delay: number
): T {
const timeoutRef = useRef<NodeJS.Timeout>(); const timeoutRef = useRef<NodeJS.Timeout>();
return useCallback((...args: Parameters<T>) => { return useCallback(
if (timeoutRef.current) { (...args: Parameters<T>) => {
clearTimeout(timeoutRef.current); if (timeoutRef.current) {
} clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args); timeoutRef.current = setTimeout(() => {
}, delay); callback(...args);
}, [callback, delay]) as T; }, delay);
},
[callback, delay]
) as T;
} }
// Throttle utility for scroll events // Throttle utility for scroll events
export function useThrottle<T extends (...args: any[]) => any>( export function useThrottle<T extends (...args: any[]) => any>(callback: T, delay: number): T {
callback: T,
delay: number
): T {
const lastCall = useRef<number>(0); const lastCall = useRef<number>(0);
return useCallback((...args: Parameters<T>) => { return useCallback(
const now = Date.now(); (...args: Parameters<T>) => {
if (now - lastCall.current >= delay) { const now = Date.now();
lastCall.current = now; if (now - lastCall.current >= delay) {
callback(...args); lastCall.current = now;
} callback(...args);
}, [callback, delay]) as T; }
},
[callback, delay]
) as T;
} }
// Memoized task calculations // Memoized task calculations
@@ -41,23 +41,23 @@ export const useTaskCalculations = (tasks: GanttTask[]) => {
const taskMap = new Map<string, GanttTask>(); const taskMap = new Map<string, GanttTask>();
const parentChildMap = new Map<string, string[]>(); const parentChildMap = new Map<string, string[]>();
const dependencyMap = new Map<string, string[]>(); const dependencyMap = new Map<string, string[]>();
// Build maps for efficient lookups // Build maps for efficient lookups
tasks.forEach(task => { tasks.forEach(task => {
taskMap.set(task.id, task); taskMap.set(task.id, task);
if (task.parent) { if (task.parent) {
if (!parentChildMap.has(task.parent)) { if (!parentChildMap.has(task.parent)) {
parentChildMap.set(task.parent, []); parentChildMap.set(task.parent, []);
} }
parentChildMap.get(task.parent)!.push(task.id); parentChildMap.get(task.parent)!.push(task.id);
} }
if (task.dependencies) { if (task.dependencies) {
dependencyMap.set(task.id, task.dependencies); dependencyMap.set(task.id, task.dependencies);
} }
}); });
return { return {
taskMap, taskMap,
parentChildMap, parentChildMap,
@@ -95,7 +95,7 @@ export const useVirtualScrolling = (
); );
const visibleItems = tasks.slice(startIndex, endIndex + 1); const visibleItems = tasks.slice(startIndex, endIndex + 1);
const offsetY = startIndex * itemHeight; const offsetY = startIndex * itemHeight;
return { return {
startIndex, startIndex,
endIndex, endIndex,
@@ -124,29 +124,31 @@ export const useTimelineVirtualScrolling = (
overscan: number = 10 overscan: number = 10
): TimelineVirtualData => { ): TimelineVirtualData => {
return useMemo(() => { return useMemo(() => {
const totalDays = Math.ceil((projectEndDate.getTime() - projectStartDate.getTime()) / (1000 * 60 * 60 * 24)); const totalDays = Math.ceil(
(projectEndDate.getTime() - projectStartDate.getTime()) / (1000 * 60 * 60 * 24)
);
const totalWidth = totalDays * dayWidth; const totalWidth = totalDays * dayWidth;
const startDayIndex = Math.max(0, Math.floor(scrollLeft / dayWidth) - overscan); const startDayIndex = Math.max(0, Math.floor(scrollLeft / dayWidth) - overscan);
const endDayIndex = Math.min( const endDayIndex = Math.min(
totalDays - 1, totalDays - 1,
Math.ceil((scrollLeft + containerWidth) / dayWidth) + overscan Math.ceil((scrollLeft + containerWidth) / dayWidth) + overscan
); );
const visibleDays: Date[] = []; const visibleDays: Date[] = [];
for (let i = startDayIndex; i <= endDayIndex; i++) { for (let i = startDayIndex; i <= endDayIndex; i++) {
const date = new Date(projectStartDate); const date = new Date(projectStartDate);
date.setDate(date.getDate() + i); date.setDate(date.getDate() + i);
visibleDays.push(date); visibleDays.push(date);
} }
const offsetX = startDayIndex * dayWidth; const offsetX = startDayIndex * dayWidth;
const startDate = new Date(projectStartDate); const startDate = new Date(projectStartDate);
startDate.setDate(startDate.getDate() + startDayIndex); startDate.setDate(startDate.getDate() + startDayIndex);
const endDate = new Date(projectStartDate); const endDate = new Date(projectStartDate);
endDate.setDate(endDate.getDate() + endDayIndex); endDate.setDate(endDate.getDate() + endDayIndex);
return { return {
startDate, startDate,
endDate, endDate,
@@ -169,25 +171,25 @@ export const usePerformanceMonitoring = (): {
taskCount: 0, taskCount: 0,
visibleTaskCount: 0, visibleTaskCount: 0,
}); });
const measurementsRef = useRef<Map<string, number>>(new Map()); const measurementsRef = useRef<Map<string, number>>(new Map());
const startMeasure = useCallback((name: string) => { const startMeasure = useCallback((name: string) => {
measurementsRef.current.set(name, performance.now()); measurementsRef.current.set(name, performance.now());
}, []); }, []);
const endMeasure = useCallback((name: string) => { const endMeasure = useCallback((name: string) => {
const startTime = measurementsRef.current.get(name); const startTime = measurementsRef.current.get(name);
if (startTime) { if (startTime) {
const duration = performance.now() - startTime; const duration = performance.now() - startTime;
measurementsRef.current.delete(name); measurementsRef.current.delete(name);
if (name === 'render') { if (name === 'render') {
metricsRef.current.renderTime = duration; metricsRef.current.renderTime = duration;
} }
} }
}, []); }, []);
const recordMetric = useCallback((name: string, value: number) => { const recordMetric = useCallback((name: string, value: number) => {
switch (name) { switch (name) {
case 'taskCount': case 'taskCount':
@@ -204,7 +206,7 @@ export const usePerformanceMonitoring = (): {
break; break;
} }
}, []); }, []);
return { return {
metrics: metricsRef.current, metrics: metricsRef.current,
startMeasure, startMeasure,
@@ -220,26 +222,26 @@ export const useIntersectionObserver = (
) => { ) => {
const targetRef = useRef<HTMLElement>(null); const targetRef = useRef<HTMLElement>(null);
const observerRef = useRef<IntersectionObserver>(); const observerRef = useRef<IntersectionObserver>();
useEffect(() => { useEffect(() => {
if (!targetRef.current) return; if (!targetRef.current) return;
observerRef.current = new IntersectionObserver(callback, { observerRef.current = new IntersectionObserver(callback, {
root: null, root: null,
rootMargin: '100px', rootMargin: '100px',
threshold: 0.1, threshold: 0.1,
...options, ...options,
}); });
observerRef.current.observe(targetRef.current); observerRef.current.observe(targetRef.current);
return () => { return () => {
if (observerRef.current) { if (observerRef.current) {
observerRef.current.disconnect(); observerRef.current.disconnect();
} }
}; };
}, [callback, options]); }, [callback, options]);
return targetRef; return targetRef;
}; };
@@ -247,47 +249,47 @@ export const useIntersectionObserver = (
export const useDateCalculations = () => { export const useDateCalculations = () => {
return useMemo(() => { return useMemo(() => {
const cache = new Map<string, number>(); const cache = new Map<string, number>();
const getDaysBetween = (start: Date, end: Date): number => { const getDaysBetween = (start: Date, end: Date): number => {
const key = `${start.getTime()}-${end.getTime()}`; const key = `${start.getTime()}-${end.getTime()}`;
if (cache.has(key)) { if (cache.has(key)) {
return cache.get(key)!; return cache.get(key)!;
} }
const days = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)); const days = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24));
cache.set(key, days); cache.set(key, days);
return days; return days;
}; };
const addDays = (date: Date, days: number): Date => { const addDays = (date: Date, days: number): Date => {
const result = new Date(date); const result = new Date(date);
result.setDate(result.getDate() + days); result.setDate(result.getDate() + days);
return result; return result;
}; };
const isWeekend = (date: Date): boolean => { const isWeekend = (date: Date): boolean => {
const day = date.getDay(); const day = date.getDay();
return day === 0 || day === 6; // Sunday or Saturday return day === 0 || day === 6; // Sunday or Saturday
}; };
const isWorkingDay = (date: Date, workingDays: number[]): boolean => { const isWorkingDay = (date: Date, workingDays: number[]): boolean => {
return workingDays.includes(date.getDay()); return workingDays.includes(date.getDay());
}; };
const getWorkingDaysBetween = (start: Date, end: Date, workingDays: number[]): number => { const getWorkingDaysBetween = (start: Date, end: Date, workingDays: number[]): number => {
let count = 0; let count = 0;
const current = new Date(start); const current = new Date(start);
while (current <= end) { while (current <= end) {
if (isWorkingDay(current, workingDays)) { if (isWorkingDay(current, workingDays)) {
count++; count++;
} }
current.setDate(current.getDate() + 1); current.setDate(current.getDate() + 1);
} }
return count; return count;
}; };
return { return {
getDaysBetween, getDaysBetween,
addDays, addDays,
@@ -300,25 +302,25 @@ export const useDateCalculations = () => {
}; };
// Task position calculations // Task position calculations
export const useTaskPositions = ( export const useTaskPositions = (tasks: GanttTask[], timelineStart: Date, dayWidth: number) => {
tasks: GanttTask[],
timelineStart: Date,
dayWidth: number
) => {
return useMemo(() => { return useMemo(() => {
const positions = new Map<string, { x: number; width: number; y: number }>(); const positions = new Map<string, { x: number; width: number; y: number }>();
tasks.forEach((task, index) => { tasks.forEach((task, index) => {
const startDays = Math.floor((task.startDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24)); const startDays = Math.floor(
const endDays = Math.floor((task.endDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24)); (task.startDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24)
);
const endDays = Math.floor(
(task.endDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24)
);
positions.set(task.id, { positions.set(task.id, {
x: startDays * dayWidth, x: startDays * dayWidth,
width: Math.max(1, (endDays - startDays) * dayWidth), width: Math.max(1, (endDays - startDays) * dayWidth),
y: index * 40, // Assuming 40px row height y: index * 40, // Assuming 40px row height
}); });
}); });
return positions; return positions;
}, [tasks, timelineStart, dayWidth]); }, [tasks, timelineStart, dayWidth]);
}; };
@@ -326,57 +328,57 @@ export const useTaskPositions = (
// Memory management utilities // Memory management utilities
export const useMemoryManagement = () => { export const useMemoryManagement = () => {
const cleanupFunctions = useRef<Array<() => void>>([]); const cleanupFunctions = useRef<Array<() => void>>([]);
const addCleanup = useCallback((cleanup: () => void) => { const addCleanup = useCallback((cleanup: () => void) => {
cleanupFunctions.current.push(cleanup); cleanupFunctions.current.push(cleanup);
}, []); }, []);
const runCleanup = useCallback(() => { const runCleanup = useCallback(() => {
cleanupFunctions.current.forEach(cleanup => cleanup()); cleanupFunctions.current.forEach(cleanup => cleanup());
cleanupFunctions.current = []; cleanupFunctions.current = [];
}, []); }, []);
useEffect(() => { useEffect(() => {
return runCleanup; return runCleanup;
}, [runCleanup]); }, [runCleanup]);
return { addCleanup, runCleanup }; return { addCleanup, runCleanup };
}; };
// Batch update utility for multiple task changes // Batch update utility for multiple task changes
export const useBatchUpdates = <T>( export const useBatchUpdates = <T>(updateFunction: (updates: T[]) => void, delay: number = 100) => {
updateFunction: (updates: T[]) => void,
delay: number = 100
) => {
const batchRef = useRef<T[]>([]); const batchRef = useRef<T[]>([]);
const timeoutRef = useRef<NodeJS.Timeout>(); const timeoutRef = useRef<NodeJS.Timeout>();
const addUpdate = useCallback((update: T) => { const addUpdate = useCallback(
batchRef.current.push(update); (update: T) => {
batchRef.current.push(update);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current); if (timeoutRef.current) {
} clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
if (batchRef.current.length > 0) {
updateFunction([...batchRef.current]);
batchRef.current = [];
} }
}, delay);
}, [updateFunction, delay]); timeoutRef.current = setTimeout(() => {
if (batchRef.current.length > 0) {
updateFunction([...batchRef.current]);
batchRef.current = [];
}
}, delay);
},
[updateFunction, delay]
);
const flushUpdates = useCallback(() => { const flushUpdates = useCallback(() => {
if (timeoutRef.current) { if (timeoutRef.current) {
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
} }
if (batchRef.current.length > 0) { if (batchRef.current.length > 0) {
updateFunction([...batchRef.current]); updateFunction([...batchRef.current]);
batchRef.current = []; batchRef.current = [];
} }
}, [updateFunction]); }, [updateFunction]);
return { addUpdate, flushUpdates }; return { addUpdate, flushUpdates };
}; };
@@ -385,24 +387,24 @@ export const useFPSMonitoring = () => {
const fpsRef = useRef<number>(0); const fpsRef = useRef<number>(0);
const frameCountRef = useRef<number>(0); const frameCountRef = useRef<number>(0);
const lastTimeRef = useRef<number>(performance.now()); const lastTimeRef = useRef<number>(performance.now());
const measureFPS = useCallback(() => { const measureFPS = useCallback(() => {
frameCountRef.current++; frameCountRef.current++;
const now = performance.now(); const now = performance.now();
if (now - lastTimeRef.current >= 1000) { if (now - lastTimeRef.current >= 1000) {
fpsRef.current = Math.round((frameCountRef.current * 1000) / (now - lastTimeRef.current)); fpsRef.current = Math.round((frameCountRef.current * 1000) / (now - lastTimeRef.current));
frameCountRef.current = 0; frameCountRef.current = 0;
lastTimeRef.current = now; lastTimeRef.current = now;
} }
requestAnimationFrame(measureFPS); requestAnimationFrame(measureFPS);
}, []); }, []);
useEffect(() => { useEffect(() => {
const rafId = requestAnimationFrame(measureFPS); const rafId = requestAnimationFrame(measureFPS);
return () => cancelAnimationFrame(rafId); return () => cancelAnimationFrame(rafId);
}, [measureFPS]); }, [measureFPS]);
return fpsRef.current; return fpsRef.current;
}; };