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

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

View File

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

View File

@@ -1,4 +1,4 @@
import React, { useState, useCallback, useRef, useMemo } from 'react'; import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { Spin, message } from '@/shared/antd-imports'; import { Spin, message } from '@/shared/antd-imports';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import GanttTimeline from './components/gantt-timeline/GanttTimeline'; import GanttTimeline from './components/gantt-timeline/GanttTimeline';
@@ -20,7 +20,9 @@ import {
setShowTaskDrawer, setShowTaskDrawer,
setSelectedTaskId, setSelectedTaskId,
setTaskFormViewModel, setTaskFormViewModel,
fetchTask,
} from '@features/task-drawer/task-drawer.slice'; } from '@features/task-drawer/task-drawer.slice';
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
import { DEFAULT_TASK_NAME } from '@/shared/constants'; import { DEFAULT_TASK_NAME } from '@/shared/constants';
import './gantt-styles.css'; import './gantt-styles.css';
@@ -62,12 +64,17 @@ const ProjectViewGantt: React.FC = React.memo(() => {
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
}); });
}); });
} }
@@ -96,6 +103,11 @@ const ProjectViewGantt: React.FC = React.memo(() => {
const loading = tasksLoading || phasesLoading; const loading = tasksLoading || phasesLoading;
// Load priorities for task drawer functionality
useEffect(() => {
dispatch(fetchPriorities());
}, [dispatch]);
const handleViewModeChange = useCallback((mode: GanttViewMode) => { const handleViewModeChange = useCallback((mode: GanttViewMode) => {
setViewMode(mode); setViewMode(mode);
}, []); }, []);
@@ -156,8 +168,13 @@ const ProjectViewGantt: React.FC = React.memo(() => {
dispatch(setSelectedTaskId(taskId)); dispatch(setSelectedTaskId(taskId));
dispatch(setTaskFormViewModel(null)); // Clear form view model for existing task dispatch(setTaskFormViewModel(null)); // Clear form view model for existing task
dispatch(setShowTaskDrawer(true)); dispatch(setShowTaskDrawer(true));
// Fetch the complete task data including priorities
if (projectId) {
dispatch(fetchTask({ taskId, projectId }));
}
}, },
[dispatch] [dispatch, projectId]
); );
const handleClosePhaseModal = useCallback(() => { const handleClosePhaseModal = useCallback(() => {
@@ -172,14 +189,12 @@ const ProjectViewGantt: React.FC = React.memo(() => {
const handleCreateQuickTask = useCallback( const handleCreateQuickTask = useCallback(
(taskName: string, phaseId?: string) => { (taskName: string, phaseId?: string) => {
// Refresh the Gantt data after task creation // Refresh the Gantt data after task creation to show the new task
refetchTasks(); refetchTasks();
message.success(`Task "${taskName}" created successfully!`);
}, },
[refetchTasks] [refetchTasks]
); );
// Handle errors // Handle errors
if (tasksError || phasesError) { if (tasksError || phasesError) {
message.error('Failed to load Gantt chart data'); message.error('Failed to load Gantt chart data');

View File

@@ -50,21 +50,22 @@ 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(
({ task, viewMode, columnWidth, columnsCount, dateRange }) => {
const renderMilestone = () => { const renderMilestone = () => {
if (!task.start_date || !dateRange) return null; if (!task.start_date || !dateRange) return null;
// Calculate position for milestone diamond // Calculate position for milestone diamond based on view mode
const totalDays = Math.ceil((dateRange.end.getTime() - dateRange.start.getTime()) / (1000 * 60 * 60 * 24)); const totalTimeSpan = dateRange.end.getTime() - dateRange.start.getTime();
const daysFromStart = Math.floor((task.start_date.getTime() - dateRange.start.getTime()) / (1000 * 60 * 60 * 24)); const timeFromStart = task.start_date.getTime() - dateRange.start.getTime();
const left = Math.max(0, (daysFromStart / totalDays) * (columnsCount * columnWidth)); const left = Math.max(0, (timeFromStart / totalTimeSpan) * (columnsCount * columnWidth));
return ( return (
<div <div
className="absolute top-1/2 transform -translate-y-1/2 -translate-x-1/2 w-4 h-4 rotate-45 z-10 shadow-sm" className="absolute top-1/2 transform -translate-y-1/2 -translate-x-1/2 w-4 h-4 rotate-45 z-10 shadow-sm"
style={{ style={{
left: `${left}px`, left: `${left}px`,
backgroundColor: task.color || '#3b82f6' backgroundColor: task.color || '#3b82f6',
}} }}
title={`${task.name} - ${task.start_date.toLocaleDateString()}`} title={`${task.name} - ${task.start_date.toLocaleDateString()}`}
/> />
@@ -74,13 +75,14 @@ const TaskBarRow: React.FC<TaskBarRowProps> = memo(({ task, viewMode, columnWidt
const renderTaskBar = () => { const renderTaskBar = () => {
if (!task.start_date || !task.end_date || !dateRange) return null; if (!task.start_date || !task.end_date || !dateRange) return null;
// Calculate position and width for task bar // Calculate position and width for task bar based on time ratios
const totalDays = Math.ceil((dateRange.end.getTime() - dateRange.start.getTime()) / (1000 * 60 * 60 * 24)); const totalTimeSpan = dateRange.end.getTime() - dateRange.start.getTime();
const daysFromStart = Math.floor((task.start_date.getTime() - dateRange.start.getTime()) / (1000 * 60 * 60 * 24)); const timeFromStart = task.start_date.getTime() - dateRange.start.getTime();
const taskDuration = Math.ceil((task.end_date.getTime() - task.start_date.getTime()) / (1000 * 60 * 60 * 24)); const taskDuration = task.end_date.getTime() - task.start_date.getTime();
const left = Math.max(0, (daysFromStart / totalDays) * (columnsCount * columnWidth)); const totalWidth = columnsCount * columnWidth;
const width = Math.max(10, (taskDuration / totalDays) * (columnsCount * columnWidth)); const left = Math.max(0, (timeFromStart / totalTimeSpan) * totalWidth);
const width = Math.max(10, (taskDuration / totalTimeSpan) * totalWidth);
return ( return (
<div <div
@@ -88,7 +90,7 @@ const TaskBarRow: React.FC<TaskBarRowProps> = memo(({ task, viewMode, columnWidt
style={{ style={{
left: `${left}px`, left: `${left}px`,
width: `${width}px`, width: `${width}px`,
backgroundColor: task.color || '#6b7280' backgroundColor: task.color || '#6b7280',
}} }}
title={`${task.name} - ${task.start_date.toLocaleDateString()} to ${task.end_date.toLocaleDateString()}`} title={`${task.name} - ${task.start_date.toLocaleDateString()} to ${task.end_date.toLocaleDateString()}`}
> >
@@ -110,28 +112,40 @@ const TaskBarRow: React.FC<TaskBarRowProps> = memo(({ task, viewMode, columnWidt
className={`${isPhase ? 'min-h-[4.5rem]' : 'h-9'} relative border-b border-gray-100 dark:border-gray-700 transition-colors ${ 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' : '' !isPhase ? 'hover:bg-gray-50 dark:hover:bg-gray-750' : ''
}`} }`}
style={isPhase && task.color ? { style={
isPhase && task.color
? {
backgroundColor: addAlphaToHex(task.color, 0.15), backgroundColor: addAlphaToHex(task.color, 0.15),
} : {}} }
: {}
}
> >
{isPhase ? renderMilestone() : renderTaskBar()} {isPhase ? renderMilestone() : renderTaskBar()}
</div> </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>(
({ tasks, viewMode, onScroll, containerRef, dateRange, phases, expandedTasks }, ref) => {
const columnsCount = useMemo(() => { const columnsCount = useMemo(() => {
if (!dateRange) { if (!dateRange) {
// Default counts if no date range // Default counts if no date range
switch (viewMode) { switch (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;
} }
} }
@@ -169,32 +183,53 @@ const GanttChart = forwardRef<HTMLDivElement, GanttChartProps>(({ tasks, viewMod
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) => { const processTask = (task: GanttTask, level: number = 0) => {
result.push(task);
// Check if this is an expanded phase with no children
const isPhase = task.type === 'milestone' || task.is_milestone; const isPhase = task.type === 'milestone' || task.is_milestone;
const isEmpty = isPhase && (!task.children || task.children.length === 0); const phaseId = isPhase
const isExpanded = expandedTasks ? expandedTasks.has(task.id) : (task.expanded !== false); ? task.id === 'phase-unmapped'
? 'unmapped'
: task.phase_id || task.id.replace('phase-', '')
: task.id;
const isExpanded = expandedTasks ? expandedTasks.has(phaseId) : task.expanded !== false;
if (isEmpty && isExpanded) { // Avoid processing the same task multiple times
// Add an empty row for the "Add Task" button if (processedIds.has(task.id)) {
result.push({ id: `${task.id}-empty`, isEmptyRow: true }); return;
} else if (task.children && isExpanded) { }
task.children.forEach(child => processTask(child)); 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(processTask); tasks.forEach(task => processTask(task, 0));
return result; return result;
}, [tasks, expandedTasks]); }, [tasks, expandedTasks]);
@@ -211,7 +246,7 @@ const GanttChart = forwardRef<HTMLDivElement, GanttChartProps>(({ tasks, viewMod
style={{ style={{
width: `${totalWidth}px`, width: `${totalWidth}px`,
minHeight: '100%', minHeight: '100%',
minWidth: shouldScroll ? 'auto' : '100%' minWidth: shouldScroll ? 'auto' : '100%',
}} }}
> >
<div <div
@@ -220,11 +255,7 @@ const GanttChart = forwardRef<HTMLDivElement, GanttChartProps>(({ tasks, viewMod
> >
{/* Grid columns for timeline */} {/* Grid columns for timeline */}
{gridColumns.map(index => ( {gridColumns.map(index => (
<GridColumn <GridColumn key={`grid-col-${index}`} index={index} columnWidth={actualColumnWidth} />
key={`grid-col-${index}`}
index={index}
columnWidth={actualColumnWidth}
/>
))} ))}
</div> </div>
<div className="relative z-10"> <div className="relative z-10">
@@ -258,7 +289,8 @@ const GanttChart = forwardRef<HTMLDivElement, GanttChartProps>(({ tasks, viewMod
</div> </div>
</div> </div>
); );
}); }
);
GanttChart.displayName = 'GanttChart'; GanttChart.displayName = 'GanttChart';

View File

@@ -1,8 +1,21 @@
import React, { memo, useCallback, useState, forwardRef, useRef, useEffect, useMemo } from 'react'; import React, { memo, useCallback, useState, forwardRef, useRef, useEffect, useMemo } from 'react';
import { RightOutlined, DownOutlined, PlusOutlined, HolderOutlined, CalendarOutlined } from '@ant-design/icons'; import {
RightOutlined,
DownOutlined,
PlusOutlined,
HolderOutlined,
CalendarOutlined,
} from '@ant-design/icons';
import { Button, Tooltip, Input, DatePicker, Space, message } from 'antd'; import { Button, Tooltip, Input, DatePicker, Space, message } from 'antd';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import { DndContext, DragEndEvent, DragOverEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core'; import {
DndContext,
DragEndEvent,
DragOverEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import { GanttTask, GanttViewMode } from '../../types/gantt-types'; import { GanttTask, GanttViewMode } from '../../types/gantt-types';
@@ -60,15 +73,10 @@ interface SortableTaskRowProps extends TaskRowProps {
} }
// Sortable wrapper for phase milestones // Sortable wrapper for phase milestones
const SortableTaskRow: React.FC<SortableTaskRowProps> = memo((props) => { const SortableTaskRow: React.FC<SortableTaskRowProps> = memo(props => {
const { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
attributes, id: props.id,
listeners, });
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: props.id });
const style = { const style = {
transform: CSS.Transform.toString(transform), transform: CSS.Transform.toString(transform),
@@ -90,7 +98,8 @@ const SortableTaskRow: React.FC<SortableTaskRowProps> = memo((props) => {
SortableTaskRow.displayName = 'SortableTaskRow'; SortableTaskRow.displayName = 'SortableTaskRow';
const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: any }> = memo(({ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: any }> = memo(
({
task, task,
projectId, projectId,
onToggle, onToggle,
@@ -102,8 +111,8 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
activeId, activeId,
overId, overId,
dragAttributes, dragAttributes,
dragListeners dragListeners,
}) => { }) => {
const [showInlineInput, setShowInlineInput] = useState(false); const [showInlineInput, setShowInlineInput] = useState(false);
const [taskName, setTaskName] = useState(''); const [taskName, setTaskName] = useState('');
const [showDatePickers, setShowDatePickers] = useState(false); const [showDatePickers, setShowDatePickers] = useState(false);
@@ -124,7 +133,11 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
const isPhase = task.type === 'milestone' || task.is_milestone; const isPhase = task.type === 'milestone' || task.is_milestone;
const hasChildren = task.children && task.children.length > 0; const hasChildren = task.children && task.children.length > 0;
// For phases, use phase_id for expansion state, for tasks use task.id // For phases, use phase_id for expansion state, for tasks use task.id
const phaseId = isPhase ? (task.id === 'phase-unmapped' ? 'unmapped' : task.phase_id || task.id.replace('phase-', '')) : task.id; const phaseId = isPhase
? task.id === 'phase-unmapped'
? 'unmapped'
: task.phase_id || task.id.replace('phase-', '')
: task.id;
const isExpanded = expandedTasks.has(phaseId); const isExpanded = expandedTasks.has(phaseId);
const indentLevel = (task.level || 0) * 20; const indentLevel = (task.level || 0) * 20;
@@ -171,7 +184,8 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
}; };
// Handle inline task creation // Handle inline task creation
const handleQuickTaskCreation = useCallback((taskName: string) => { const handleQuickTaskCreation = useCallback(
(taskName: string) => {
if (!connected || !socket || !projectId) return; if (!connected || !socket || !projectId) return;
const currentSession = JSON.parse(localStorage.getItem('session') || '{}'); const currentSession = JSON.parse(localStorage.getItem('session') || '{}');
@@ -199,22 +213,28 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
// Reset input state // Reset input state
setTaskName(''); setTaskName('');
setShowInlineInput(false); setShowInlineInput(false);
}, [connected, socket, projectId, task.type, task.phase_id, onCreateQuickTask]); },
[connected, socket, projectId, task.type, task.phase_id, onCreateQuickTask]
);
const handleKeyPress = useCallback((e: React.KeyboardEvent) => { const handleKeyPress = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && taskName.trim()) { if (e.key === 'Enter' && taskName.trim()) {
handleQuickTaskCreation(taskName); handleQuickTaskCreation(taskName);
} else if (e.key === 'Escape') { } else if (e.key === 'Escape') {
setTaskName(''); setTaskName('');
setShowInlineInput(false); setShowInlineInput(false);
} }
}, [taskName, handleQuickTaskCreation]); },
[taskName, handleQuickTaskCreation]
);
const handleShowInlineInput = useCallback(() => { const handleShowInlineInput = useCallback(() => {
setShowInlineInput(true); setShowInlineInput(true);
}, []); }, []);
const handlePhaseDateUpdate = useCallback(async (startDate: Date, endDate: Date) => { const handlePhaseDateUpdate = useCallback(
async (startDate: Date, endDate: Date) => {
if (!projectId || !task.phase_id) return; if (!projectId || !task.phase_id) return;
try { try {
@@ -231,7 +251,9 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
console.error('Failed to update phase dates:', error); console.error('Failed to update phase dates:', error);
message.error('Failed to update phase dates'); message.error('Failed to update phase dates');
} }
}, [projectId, task.phase_id, updatePhase]); },
[projectId, task.phase_id, updatePhase]
);
const isEmpty = isPhase && (!task.children || task.children.length === 0); const isEmpty = isPhase && (!task.children || task.children.length === 0);
@@ -271,14 +293,20 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
<> <>
<div <div
className={`group flex ${isPhase ? 'min-h-[4.5rem] gantt-phase-row' : 'h-9 gantt-task-row'} border-b border-gray-100 dark:border-gray-700 transition-colors ${ className={`group flex ${isPhase ? 'min-h-[4.5rem] gantt-phase-row' : 'h-9 gantt-task-row'} border-b border-gray-100 dark:border-gray-700 transition-colors ${
!isPhase ? 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 cursor-pointer' : '' !isPhase
? 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 cursor-pointer'
: ''
} ${isDraggable && !isPhase ? 'cursor-grab active:cursor-grabbing' : ''} ${ } ${isDraggable && !isPhase ? 'cursor-grab active:cursor-grabbing' : ''} ${
activeId === task.id ? 'opacity-50' : '' activeId === task.id ? 'opacity-50' : ''
} ${overId === task.id && overId !== activeId ? 'ring-2 ring-blue-500 ring-inset' : ''}`} } ${overId === task.id && overId !== activeId ? 'ring-2 ring-blue-500 ring-inset' : ''}`}
style={isPhase && task.color ? { style={
isPhase && task.color
? {
backgroundColor: addAlphaToHex(task.color, 0.15), backgroundColor: addAlphaToHex(task.color, 0.15),
color: task.color color: task.color,
} : {}} }
: {}
}
onClick={!isPhase ? handleTaskClick : undefined} onClick={!isPhase ? handleTaskClick : undefined}
{...(!isPhase && isDraggable ? dragAttributes : {})} {...(!isPhase && isDraggable ? dragAttributes : {})}
{...(!isPhase && isDraggable ? dragListeners : {})} {...(!isPhase && isDraggable ? dragListeners : {})}
@@ -287,7 +315,7 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
className={`w-full px-2 py-2 text-sm ${isPhase ? '' : 'text-gray-800 dark:text-gray-200'} flex items-center justify-between`} className={`w-full px-2 py-2 text-sm ${isPhase ? '' : 'text-gray-800 dark:text-gray-200'} flex items-center justify-between`}
style={{ style={{
paddingLeft: `${8 + indentLevel + (isPhase && task.id === 'phase-unmapped' ? 28 : 0)}px`, paddingLeft: `${8 + indentLevel + (isPhase && task.id === 'phase-unmapped' ? 28 : 0)}px`,
color: isPhase && task.color ? task.color : undefined color: isPhase && task.color ? task.color : undefined,
}} }}
> >
<div className="flex items-center gap-2 truncate flex-1"> <div className="flex items-center gap-2 truncate flex-1">
@@ -309,9 +337,7 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
<div className="flex items-center gap-2 ml-1 truncate flex-1"> <div className="flex items-center gap-2 ml-1 truncate flex-1">
{getTaskIcon()} {getTaskIcon()}
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1">
<span <span className={`truncate ${task.type === 'milestone' ? 'font-semibold' : ''}`}>
className={`truncate ${task.type === 'milestone' ? 'font-semibold' : ''}`}
>
{task.name} {task.name}
</span> </span>
{isPhase && ( {isPhase && (
@@ -327,7 +353,10 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
> >
<CalendarOutlined className="text-[10px]" /> <CalendarOutlined className="text-[10px]" />
{task.start_date && task.end_date ? ( {task.start_date && task.end_date ? (
<>{dayjs(task.start_date).format('MMM D')} - {dayjs(task.end_date).format('MMM D, YYYY')}</> <>
{dayjs(task.start_date).format('MMM D')} -{' '}
{dayjs(task.end_date).format('MMM D, YYYY')}
</>
) : ( ) : (
'Set dates' 'Set dates'
)} )}
@@ -339,14 +368,14 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
size="small" size="small"
value={[ value={[
task.start_date ? dayjs(task.start_date) : null, task.start_date ? dayjs(task.start_date) : null,
task.end_date ? dayjs(task.end_date) : null task.end_date ? dayjs(task.end_date) : null,
]} ]}
onChange={(dates) => { onChange={dates => {
if (dates && dates[0] && dates[1]) { if (dates && dates[0] && dates[1]) {
handlePhaseDateUpdate(dates[0].toDate(), dates[1].toDate()); handlePhaseDateUpdate(dates[0].toDate(), dates[1].toDate());
} }
}} }}
onOpenChange={(open) => { onOpenChange={open => {
if (!open) { if (!open) {
setShowDatePickers(false); setShowDatePickers(false);
} }
@@ -373,12 +402,87 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
</span> </span>
</div> </div>
)} )}
</div> </div>
</div> </div>
</>
);
}
);
{/* Inline task creation for all expanded phases */} TaskRow.displayName = 'TaskRow';
{isPhase && isExpanded && (
// Add Task Row Component
interface AddTaskRowProps {
task: GanttTask;
projectId: string;
onCreateQuickTask?: (taskName: string, phaseId?: string) => void;
}
const AddTaskRow: React.FC<AddTaskRowProps> = memo(({ task, projectId, onCreateQuickTask }) => {
const [showInlineInput, setShowInlineInput] = useState(false);
const [taskName, setTaskName] = useState('');
const { socket, connected } = useSocket();
const authService = useAuthService();
// Handle inline task creation
const handleQuickTaskCreation = useCallback(
(taskName: string) => {
if (!connected || !socket || !projectId) return;
const currentSession = authService.getCurrentSession();
if (!currentSession) {
console.error('No current session found');
return;
}
// Get the correct phase ID
let phaseId: string | null | undefined = task.parent_phase_id;
if (phaseId === 'unmapped') {
phaseId = null; // Unmapped tasks have no phase
}
const requestBody = {
project_id: projectId,
name: taskName.trim(),
reporter_id: currentSession.id,
team_id: currentSession.team_id,
phase_id: phaseId,
};
socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(requestBody));
// Handle the response and update UI
socket.once(SocketEvents.QUICK_TASK.toString(), (response: any) => {
if (response) {
// Immediately refresh the Gantt data to show the new task
onCreateQuickTask?.(taskName, phaseId);
}
});
// Reset input state
setTaskName('');
setShowInlineInput(false);
},
[connected, socket, projectId, task.parent_phase_id, onCreateQuickTask, authService]
);
const handleKeyPress = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && taskName.trim()) {
handleQuickTaskCreation(taskName);
} else if (e.key === 'Escape') {
setTaskName('');
setShowInlineInput(false);
}
},
[taskName, handleQuickTaskCreation]
);
const handleShowInlineInput = useCallback(() => {
setShowInlineInput(true);
}, []);
return (
<div className="gantt-add-task-inline flex h-9 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"> <div className="gantt-add-task-inline flex h-9 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
<div <div
className="w-full px-2 py-2 text-sm flex items-center" className="w-full px-2 py-2 text-sm flex items-center"
@@ -389,7 +493,7 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
size="small" size="small"
placeholder="Enter task name..." placeholder="Enter task name..."
value={taskName} value={taskName}
onChange={(e) => setTaskName(e.target.value)} onChange={e => setTaskName(e.target.value)}
onKeyDown={handleKeyPress} onKeyDown={handleKeyPress}
onBlur={() => { onBlur={() => {
if (!taskName.trim()) { if (!taskName.trim()) {
@@ -412,14 +516,14 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
)} )}
</div> </div>
</div> </div>
)}
</>
); );
}); });
TaskRow.displayName = 'TaskRow'; AddTaskRow.displayName = 'AddTaskRow';
const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({ const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(
(
{
tasks, tasks,
projectId, projectId,
viewMode, viewMode,
@@ -430,8 +534,10 @@ const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({
onPhaseReorder, onPhaseReorder,
onScroll, onScroll,
expandedTasks: expandedTasksProp, expandedTasks: expandedTasksProp,
onExpandedTasksChange onExpandedTasksChange,
}, ref) => { },
ref
) => {
const [localExpandedTasks, setLocalExpandedTasks] = useState<Set<string>>( const [localExpandedTasks, setLocalExpandedTasks] = useState<Set<string>>(
() => new Set(tasks.filter(t => t.expanded).map(t => t.id)) () => new Set(tasks.filter(t => t.expanded).map(t => t.id))
); );
@@ -455,7 +561,8 @@ const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({
}) })
); );
const handleTaskToggle = useCallback((taskId: string) => { const handleTaskToggle = useCallback(
(taskId: string) => {
const updateExpanded = (prev: Set<string>) => { const updateExpanded = (prev: Set<string>) => {
const newSet = new Set(prev); const newSet = new Set(prev);
if (newSet.has(taskId)) { if (newSet.has(taskId)) {
@@ -473,23 +580,66 @@ const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({
} }
onTaskToggle?.(taskId); onTaskToggle?.(taskId);
}, [expandedTasks, onExpandedTasksChange, onTaskToggle]); },
[expandedTasks, onExpandedTasksChange, onTaskToggle]
);
// Flatten tasks based on expand/collapse state // Flatten tasks based on expand/collapse state
const flattenTasks = useCallback((taskList: GanttTask[]): GanttTask[] => { const flattenTasks = useCallback(
(taskList: GanttTask[]): GanttTask[] => {
const result: GanttTask[] = []; const result: GanttTask[] = [];
const processedIds = new Set<string>(); // Track processed task IDs to prevent duplicates
const processTask = (task: GanttTask) => { const processTask = (task: GanttTask, level: number = 0) => {
result.push(task); const isPhase = task.type === 'milestone' || task.is_milestone;
const phaseId = isPhase
? task.id === 'phase-unmapped'
? 'unmapped'
: task.phase_id || task.id.replace('phase-', '')
: task.id;
const isExpanded = expandedTasks.has(phaseId);
if (task.children && expandedTasks.has(task.id)) { // Avoid processing the same task multiple times
task.children.forEach(child => processTask(child)); if (processedIds.has(task.id)) {
return;
}
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 a special "add task" row at the end (only if not already processed)
const addTaskId = `add-task-${task.id}`;
if (!processedIds.has(addTaskId)) {
processedIds.add(addTaskId);
result.push({
id: addTaskId,
name: 'Add Task',
type: 'add-task-button' as any,
phase_id: task.phase_id,
parent_phase_id: phaseId,
level: level + 1,
start_date: null,
end_date: null,
progress: 0,
} as GanttTask);
}
} else if (!isPhase && task.children && expandedTasks.has(task.id)) {
task.children.forEach(child => processTask(child, level + 1));
} }
}; };
taskList.forEach(processTask); taskList.forEach(task => processTask(task, 0));
return result; return result;
}, [expandedTasks]); },
[expandedTasks]
);
const visibleTasks = flattenTasks(tasks); const visibleTasks = flattenTasks(tasks);
@@ -506,11 +656,13 @@ const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({
const socketData = { const socketData = {
project_id: projectId, project_id: projectId,
group_by: 'phase', group_by: 'phase',
task_updates: [{ task_updates: [
{
task_id: taskId, task_id: taskId,
sort_order: sortOrder, sort_order: sortOrder,
phase_id: toPhaseId phase_id: toPhaseId,
}], },
],
from_group: fromPhaseId || 'unmapped', from_group: fromPhaseId || 'unmapped',
to_group: toPhaseId || 'unmapped', to_group: toPhaseId || 'unmapped',
task: { task: {
@@ -542,7 +694,8 @@ const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({
setOverId(over.id as string); setOverId(over.id as string);
}, []); }, []);
const handleDragEnd = useCallback((event: DragEndEvent) => { const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event; const { active, over } = event;
setActiveId(null); setActiveId(null);
setOverId(null); setOverId(null);
@@ -553,7 +706,11 @@ const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({
const overTask = visibleTasks.find(t => t.id === over.id); const overTask = visibleTasks.find(t => t.id === over.id);
// Handle phase reordering (existing functionality) // Handle phase reordering (existing functionality)
if (activeTask && (activeTask.type === 'milestone' || activeTask.is_milestone) && onPhaseReorder) { if (
activeTask &&
(activeTask.type === 'milestone' || activeTask.is_milestone) &&
onPhaseReorder
) {
const phases = tasks.filter(task => task.type === 'milestone' || task.is_milestone); const phases = tasks.filter(task => task.type === 'milestone' || task.is_milestone);
const oldIndex = phases.findIndex(phase => phase.id === active.id); const oldIndex = phases.findIndex(phase => phase.id === active.id);
const newIndex = phases.findIndex(phase => phase.id === over.id); const newIndex = phases.findIndex(phase => phase.id === over.id);
@@ -587,14 +744,16 @@ const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({
emitTaskPhaseChange(activeTask.id, currentPhaseId, targetPhaseId, 0); emitTaskPhaseChange(activeTask.id, currentPhaseId, targetPhaseId, 0);
} }
} }
}, [tasks, visibleTasks, onPhaseReorder, emitTaskPhaseChange]); },
[tasks, visibleTasks, onPhaseReorder, emitTaskPhaseChange]
);
// Separate phases and tasks for drag and drop (exclude unmapped phase) // Separate phases and tasks for drag and drop (exclude unmapped phase)
const phases = visibleTasks.filter(task => const phases = visibleTasks.filter(
(task.type === 'milestone' || task.is_milestone) && task.id !== 'phase-unmapped' task => (task.type === 'milestone' || task.is_milestone) && task.id !== 'phase-unmapped'
); );
const regularTasks = visibleTasks.filter(task => const regularTasks = visibleTasks.filter(
!(task.type === 'milestone' || task.is_milestone) task => !(task.type === 'milestone' || task.is_milestone)
); );
// All draggable items (phases + tasks) // All draggable items (phases + tasks)
@@ -607,10 +766,10 @@ const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({
return ( return (
<div className="w-[444px] min-w-[444px] max-w-[444px] h-full flex flex-col bg-gray-50 dark:bg-gray-900 gantt-task-list-container"> <div className="w-[444px] min-w-[444px] max-w-[444px] h-full flex flex-col bg-gray-50 dark:bg-gray-900 gantt-task-list-container">
<div className={`flex ${headerHeight} border-b border-gray-200 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 font-medium text-sm flex-shrink-0 items-center`}> <div
<div className="w-full px-4 text-gray-700 dark:text-gray-300"> className={`flex ${headerHeight} border-b border-gray-200 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 font-medium text-sm flex-shrink-0 items-center`}
Task Name >
</div> <div className="w-full px-4 text-gray-700 dark:text-gray-300">Task Name</div>
</div> </div>
<div className="flex-1 gantt-task-list-scroll relative" ref={ref} onScroll={onScroll}> <div className="flex-1 gantt-task-list-scroll relative" ref={ref} onScroll={onScroll}>
{visibleTasks.length === 0 && ( {visibleTasks.length === 0 && (
@@ -625,15 +784,22 @@ const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
> >
<SortableContext <SortableContext items={allDraggableItems} strategy={verticalListSortingStrategy}>
items={allDraggableItems}
strategy={verticalListSortingStrategy}
>
{visibleTasks.map((task, index) => { {visibleTasks.map((task, index) => {
const isPhase = task.type === 'milestone' || task.is_milestone; const isPhase = task.type === 'milestone' || task.is_milestone;
const isUnmappedPhase = task.id === 'phase-unmapped'; const isUnmappedPhase = task.id === 'phase-unmapped';
const isAddTaskButton = task.type === 'add-task-button';
if (isPhase && !isUnmappedPhase) { if (isAddTaskButton) {
return (
<AddTaskRow
key={task.id}
task={task}
projectId={projectId}
onCreateQuickTask={onCreateQuickTask}
/>
);
} else if (isPhase && !isUnmappedPhase) {
return ( return (
<SortableTaskRow <SortableTaskRow
key={task.id} key={task.id}
@@ -692,7 +858,8 @@ const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({
</div> </div>
</div> </div>
); );
}); }
);
GanttTaskList.displayName = 'GanttTaskList'; GanttTaskList.displayName = 'GanttTaskList';

View File

@@ -9,7 +9,8 @@ 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>(
({ viewMode, containerRef, dateRange }, ref) => {
const { topHeaders, bottomHeaders } = useMemo(() => { const { topHeaders, bottomHeaders } = useMemo(() => {
if (!dateRange) { if (!dateRange) {
return { topHeaders: [], bottomHeaders: [] }; return { topHeaders: [], bottomHeaders: [] };
@@ -52,7 +53,7 @@ const GanttTimeline = forwardRef<HTMLDivElement, GanttTimelineProps>(({ viewMode
topHeaders.push({ topHeaders.push({
label: `${year}`, label: `${year}`,
key: `year-${year}`, key: `year-${year}`,
span: monthsInYear span: monthsInYear,
}); });
} }
} }
@@ -93,7 +94,7 @@ const GanttTimeline = forwardRef<HTMLDivElement, GanttTimelineProps>(({ viewMode
topHeaders.push({ topHeaders.push({
label: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }), label: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }),
key: `month-${monthKey}`, key: `month-${monthKey}`,
span: count span: count,
}); });
}); });
break; break;
@@ -131,7 +132,7 @@ const GanttTimeline = forwardRef<HTMLDivElement, GanttTimelineProps>(({ viewMode
topHeaders.push({ topHeaders.push({
label: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }), label: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }),
key: `month-${monthKey}`, key: `month-${monthKey}`,
span: count span: count,
}); });
}); });
break; break;
@@ -201,7 +202,10 @@ const GanttTimeline = forwardRef<HTMLDivElement, GanttTimelineProps>(({ viewMode
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }} style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
> >
{hasTopHeaders && ( {hasTopHeaders && (
<div className="flex h-10 border-b border-gray-200 dark:border-gray-700" style={{ width: `${totalWidth}px`, minWidth: shouldScroll ? 'auto' : '100%' }}> <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 => ( {topHeaders.map(header => (
<div <div
key={header.key} key={header.key}
@@ -214,14 +218,19 @@ const GanttTimeline = forwardRef<HTMLDivElement, GanttTimelineProps>(({ viewMode
))} ))}
</div> </div>
)} )}
<div className="flex h-10" style={{ width: `${totalWidth}px`, minWidth: shouldScroll ? 'auto' : '100%' }}> <div
className="flex h-10"
style={{ width: `${totalWidth}px`, minWidth: shouldScroll ? 'auto' : '100%' }}
>
{bottomHeaders.map(header => ( {bottomHeaders.map(header => (
<div <div
key={header.key} 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 ${ 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' ? 'px-1 text-xs' : 'px-2'
} ${ } ${
viewMode === 'day' && actualColumnWidth < 50 ? 'whitespace-nowrap overflow-hidden text-ellipsis' : 'whitespace-nowrap' viewMode === 'day' && actualColumnWidth < 50
? 'whitespace-nowrap overflow-hidden text-ellipsis'
: 'whitespace-nowrap'
}`} }`}
style={{ width: `${actualColumnWidth}px` }} style={{ width: `${actualColumnWidth}px` }}
title={header.label} title={header.label}
@@ -232,7 +241,8 @@ const GanttTimeline = forwardRef<HTMLDivElement, GanttTimelineProps>(({ viewMode
</div> </div>
</div> </div>
); );
}); }
);
GanttTimeline.displayName = 'GanttTimeline'; GanttTimeline.displayName = 'GanttTimeline';

View File

@@ -1,6 +1,12 @@
import React, { memo } from 'react'; import React, { memo } from 'react';
import { Select, Button, Space, Divider } from 'antd'; import { Select, Button, Space, Divider } from 'antd';
import { ZoomInOutlined, ZoomOutOutlined, FullscreenOutlined, PlusOutlined, FlagOutlined } from '@ant-design/icons'; import {
ZoomInOutlined,
ZoomOutOutlined,
FullscreenOutlined,
PlusOutlined,
FlagOutlined,
} from '@ant-design/icons';
import { GanttViewMode } from '../../types/gantt-types'; import { GanttViewMode } from '../../types/gantt-types';
const { Option } = Select; const { Option } = Select;
@@ -13,7 +19,8 @@ interface GanttToolbarProps {
onCreateTask?: () => void; onCreateTask?: () => void;
} }
const GanttToolbar: React.FC<GanttToolbarProps> = memo(({ viewMode, onViewModeChange, dateRange, onCreatePhase, onCreateTask }) => { const GanttToolbar: React.FC<GanttToolbarProps> = memo(
({ viewMode, onViewModeChange, dateRange, onCreatePhase, onCreateTask }) => {
return ( return (
<div className="p-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center"> <div className="p-4 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex justify-between items-center">
<Space> <Space>
@@ -33,11 +40,7 @@ const GanttToolbar: React.FC<GanttToolbarProps> = memo(({ viewMode, onViewModeCh
Add Task Add Task
</Button> </Button>
<Divider type="vertical" className="bg-gray-300 dark:bg-gray-600" /> <Divider type="vertical" className="bg-gray-300 dark:bg-gray-600" />
<Select <Select value={viewMode} onChange={onViewModeChange} className="w-32">
value={viewMode}
onChange={onViewModeChange}
className="w-32"
>
<Option value="day">Day</Option> <Option value="day">Day</Option>
<Option value="week">Week</Option> <Option value="week">Week</Option>
<Option value="month">Month</Option> <Option value="month">Month</Option>
@@ -63,7 +66,8 @@ const GanttToolbar: React.FC<GanttToolbarProps> = memo(({ viewMode, onViewModeCh
</Space> </Space>
</div> </div>
); );
}); }
);
GanttToolbar.displayName = 'GanttToolbar'; GanttToolbar.displayName = 'GanttToolbar';

View File

@@ -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 {

View File

@@ -28,7 +28,8 @@ export const useGanttDimensions = (
// 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 =
shouldStretch && containerWidth > minTotalWidth
? containerWidth / columnsCount ? containerWidth / columnsCount
: baseColumnWidth; : baseColumnWidth;
@@ -39,6 +40,6 @@ export const useGanttDimensions = (
actualColumnWidth, actualColumnWidth,
totalWidth, totalWidth,
columnsCount, columnsCount,
shouldScroll: totalWidth > containerWidth shouldScroll: totalWidth > containerWidth,
}; };
}; };

View File

@@ -21,7 +21,11 @@ export interface RoadmapTasksResponse {
priority_name: string; priority_name: string;
priority_value: number; priority_value: number;
priority_color: string; priority_color: string;
phase_id: string | null; phases: Array<{
phase_id: string;
phase_name: string;
phase_color: string;
}>;
assignees: Array<{ assignees: Array<{
team_member_id: string; team_member_id: string;
assignee_name: string; assignee_name: string;
@@ -41,7 +45,7 @@ export interface RoadmapTasksResponse {
progress: number; progress: number;
roadmap_sort_order: number; roadmap_sort_order: number;
parent_task_id: string; parent_task_id: string;
phase_id: string | null; phase_id?: string | null; // Keep this for subtasks compatibility
}>; }>;
} }
@@ -52,6 +56,10 @@ export interface ProjectPhaseResponse {
start_date: string | null; start_date: string | null;
end_date: string | null; end_date: string | null;
sort_index: number; sort_index: number;
todo_progress: number;
doing_progress: number;
done_progress: number;
total_tasks: number;
} }
export interface UpdateTaskDatesRequest { export interface UpdateTaskDatesRequest {
@@ -108,10 +116,7 @@ export const ganttApi = createApi({
}), }),
tagTypes: ['GanttTasks', 'GanttPhases'], tagTypes: ['GanttTasks', 'GanttPhases'],
endpoints: builder => ({ endpoints: builder => ({
getRoadmapTasks: builder.query< getRoadmapTasks: builder.query<IServerResponse<RoadmapTasksResponse[]>, { projectId: string }>({
IServerResponse<RoadmapTasksResponse[]>,
{ projectId: string }
>({
query: ({ projectId }) => { query: ({ projectId }) => {
const params = new URLSearchParams({ const params = new URLSearchParams({
project_id: projectId, project_id: projectId,
@@ -124,10 +129,8 @@ export const ganttApi = createApi({
], ],
}), }),
getProjectPhases: builder.query< getProjectPhases: builder.query<IServerResponse<ProjectPhaseResponse[]>, { projectId: string }>(
IServerResponse<ProjectPhaseResponse[]>, {
{ projectId: string }
>({
query: ({ projectId }) => { query: ({ projectId }) => {
const params = new URLSearchParams({ const params = new URLSearchParams({
project_id: projectId, project_id: projectId,
@@ -138,26 +141,19 @@ export const ganttApi = createApi({
{ type: 'GanttPhases', id: projectId }, { type: 'GanttPhases', id: projectId },
{ type: 'GanttPhases', id: 'LIST' }, { 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);
} }
@@ -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,14 +285,17 @@ 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 => {
const taskPhaseId = task.phases.length > 0 ? task.phases[0].phase_id : null;
return {
id: task.id, id: task.id,
name: task.name, name: task.name,
start_date: task.start_date ? new Date(task.start_date) : null, start_date: task.start_date ? new Date(task.start_date) : null,
end_date: task.end_date ? new Date(task.end_date) : null, end_date: task.end_date ? new Date(task.end_date) : null,
progress: task.progress, progress: task.progress,
dependencies: task.dependencies.map(dep => dep.related_task_id), dependencies: task.dependencies.map(dep => dep.related_task_id),
dependencyType: task.dependencies[0]?.dependency_type as any || 'blocked_by', dependencyType: (task.dependencies[0]?.dependency_type as any) || 'blocked_by',
parent_id: task.parent_task_id, parent_id: task.parent_task_id,
children: task.subtasks.map(subtask => ({ children: task.subtasks.map(subtask => ({
id: subtask.id, id: subtask.id,
@@ -302,7 +306,7 @@ const transformTask = (task: RoadmapTasksResponse, level: number = 0): GanttTask
parent_id: subtask.parent_task_id, parent_id: subtask.parent_task_id,
level: level + 1, level: level + 1,
type: 'task', type: 'task',
phase_id: subtask.phase_id phase_id: subtask.phase_id, // Subtasks might still use direct phase_id
})), })),
level, level,
expanded: true, expanded: true,
@@ -310,10 +314,11 @@ const transformTask = (task: RoadmapTasksResponse, level: number = 0): GanttTask
assignees: task.assignees.map(a => a.assignee_name), assignees: task.assignees.map(a => a.assignee_name),
priority: task.priority_name, priority: task.priority_name,
status: task.status_name, status: task.status_name,
phase_id: task.phase_id, phase_id: taskPhaseId,
is_milestone: false, is_milestone: false,
type: 'task' type: 'task',
}); };
};
/** /**
* Transform API response to Gantt phases format * Transform API response to Gantt phases format
@@ -325,6 +330,10 @@ export const transformToGanttPhases = (apiPhases: ProjectPhaseResponse[]): Gantt
color_code: phase.color_code, color_code: phase.color_code,
start_date: phase.start_date ? new Date(phase.start_date) : null, start_date: phase.start_date ? new Date(phase.start_date) : null,
end_date: phase.end_date ? new Date(phase.end_date) : null, end_date: phase.end_date ? new Date(phase.end_date) : null,
sort_index: phase.sort_index sort_index: phase.sort_index,
todo_progress: phase.todo_progress,
doing_progress: phase.doing_progress,
done_progress: phase.done_progress,
total_tasks: phase.total_tasks,
})); }));
}; };

View File

@@ -1,6 +1,11 @@
export type GanttViewMode = 'day' | 'week' | 'month' | 'quarter' | 'year'; export type GanttViewMode = 'day' | 'week' | 'month' | 'quarter' | 'year';
export type DependencyType = 'blocked_by' | 'finish_to_start' | 'start_to_start' | 'finish_to_finish' | 'start_to_finish'; export type DependencyType =
| 'blocked_by'
| 'finish_to_start'
| 'start_to_start'
| 'finish_to_finish'
| 'start_to_finish';
export interface GanttTask { export interface GanttTask {
id: string; id: string;
@@ -20,7 +25,9 @@ export interface GanttTask {
status?: string; status?: string;
phase_id?: string; phase_id?: string;
is_milestone?: boolean; is_milestone?: boolean;
type?: 'task' | 'milestone' | 'phase'; type?: 'task' | 'milestone' | 'phase' | 'add-task-button';
// Add task row specific properties
parent_phase_id?: string;
} }
export interface GanttPhase { export interface GanttPhase {

View File

@@ -18,12 +18,7 @@ export class TimelineCalculator {
private columnWidth: number; private columnWidth: number;
private timelineBounds: TimelineBounds; private timelineBounds: TimelineBounds;
constructor( constructor(viewMode: GanttViewMode, columnWidth: number, startDate: Date, endDate: Date) {
viewMode: GanttViewMode,
columnWidth: number,
startDate: Date,
endDate: Date
) {
this.viewMode = viewMode; this.viewMode = viewMode;
this.columnWidth = columnWidth; this.columnWidth = columnWidth;
this.timelineBounds = this.calculateTimelineBounds(startDate, endDate); this.timelineBounds = this.calculateTimelineBounds(startDate, endDate);
@@ -42,7 +37,7 @@ export class TimelineCalculator {
startDate, startDate,
endDate, endDate,
totalDays, totalDays,
pixelsPerDay pixelsPerDay,
}; };
} }
@@ -51,12 +46,18 @@ export class TimelineCalculator {
*/ */
private getColumnsCount(): number { private getColumnsCount(): number {
switch (this.viewMode) { switch (this.viewMode) {
case 'day': return 30; case 'day':
case 'week': return 12; return 30;
case 'month': return 12; case 'week':
case 'quarter': return 8; return 12;
case 'year': return 5; case 'month':
default: return 12; return 12;
case 'quarter':
return 8;
case 'year':
return 5;
default:
return 12;
} }
} }
@@ -72,12 +73,18 @@ export class TimelineCalculator {
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,
}; };
} }
@@ -101,16 +108,21 @@ 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,
}; };
} }
@@ -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,
}; };
} }
@@ -167,7 +179,7 @@ export class TimelineCalculator {
return { return {
left: position.left, left: position.left,
isVisible: position.isValid isVisible: position.isValid,
}; };
} }
@@ -185,7 +197,7 @@ 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,
}); });
} }
} }
@@ -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);
@@ -316,6 +333,6 @@ export const TimelineUtils = {
const dayNum = d.getUTCDay() || 7; const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum); d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
} },
}; };

View File

@@ -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';
} }

View File

@@ -2,13 +2,11 @@ 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(
(...args: Parameters<T>) => {
if (timeoutRef.current) { if (timeoutRef.current) {
clearTimeout(timeoutRef.current); clearTimeout(timeoutRef.current);
} }
@@ -16,23 +14,25 @@ export function useDebounce<T extends (...args: any[]) => any>(
timeoutRef.current = setTimeout(() => { timeoutRef.current = setTimeout(() => {
callback(...args); callback(...args);
}, delay); }, delay);
}, [callback, delay]) as T; },
[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(
(...args: Parameters<T>) => {
const now = Date.now(); const now = Date.now();
if (now - lastCall.current >= delay) { if (now - lastCall.current >= delay) {
lastCall.current = now; lastCall.current = now;
callback(...args); callback(...args);
} }
}, [callback, delay]) as T; },
[callback, delay]
) as T;
} }
// Memoized task calculations // Memoized task calculations
@@ -124,7 +124,9 @@ 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);
@@ -300,17 +302,17 @@ 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,
@@ -344,14 +346,12 @@ export const useMemoryManagement = () => {
}; };
// 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(
(update: T) => {
batchRef.current.push(update); batchRef.current.push(update);
if (timeoutRef.current) { if (timeoutRef.current) {
@@ -364,7 +364,9 @@ export const useBatchUpdates = <T>(
batchRef.current = []; batchRef.current = [];
} }
}, delay); }, delay);
}, [updateFunction, delay]); },
[updateFunction, delay]
);
const flushUpdates = useCallback(() => { const flushUpdates = useCallback(() => {
if (timeoutRef.current) { if (timeoutRef.current) {