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:
@@ -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()
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -15,4 +15,4 @@ export const getColumnWidth = (viewMode: string): number => {
|
|||||||
default:
|
default:
|
||||||
return 80;
|
return 80;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
}));
|
}));
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user