Compare commits

...

5 Commits

Author SHA1 Message Date
Chamika J
da3728024d feat(gantt): enhance Gantt chart with task creation and phase updates
- Added a task creation popover for quick task entry within the Gantt chart.
- Implemented phase update handling to refresh task and phase data after modifications.
- Enhanced GanttChart and GanttTaskList components to support new task creation and phase management features.
- Updated GanttToolbar to streamline user interactions for task and phase management.
- Improved phase details modal for better editing capabilities and user feedback.
2025-08-06 17:11:45 +05:30
Chamika J
69ec445a8a feat(gantt): enhance Gantt chart interactivity and animations
- Added phase details modal for improved phase management and user interaction.
- Implemented hover effects and animations for task and phase rows in Gantt chart.
- Updated Gantt components to support phase click events and task animations.
- Enhanced CSS for smoother transitions and visual feedback during task interactions.
- Refactored GanttTaskList and GanttChart components to incorporate new animation logic.
2025-08-05 16:44:12 +05:30
Chamika J
ad7eb505b5 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.
2025-08-05 16:02:07 +05:30
Chamika J
d33a7db253 refactor(gantt): restructure Gantt components and update styles
- Deleted obsolete Gantt components including GanttChart, GanttTaskList, GanttTimeline, GanttToolbar, and their associated styles.
- Renamed ProjectViewGantt component for consistency in naming conventions.
- Updated CSS for task list scrollbar to hide the scrollbar and improve visual aesthetics.
- Introduced new styles for consistent row heights, improved hover states, and enhanced button interactions.
- Added functionality for phase management and task animations to enhance user experience.
2025-08-05 12:21:08 +05:30
chamiakJ
9c4293e7a9 feat(gantt): implement Gantt chart functionality with roadmap tasks and project phases
- Added GanttController with endpoints for fetching roadmap tasks and project phases.
- Implemented task date update functionality in the GanttController.
- Created Gantt components including GanttChart, GanttTaskList, GanttTimeline, and GanttToolbar for rendering the Gantt interface.
- Integrated Redux Toolkit Query for API interactions related to Gantt tasks and phases.
- Established context for Gantt state management and utility functions for timeline calculations.
- Enhanced styling for Gantt components and added responsive design features.
- Introduced drag-and-drop functionality for task management within the Gantt chart.
2025-08-04 07:22:56 +05:30
27 changed files with 4754 additions and 107 deletions

3
.gitignore vendored
View File

@@ -78,4 +78,7 @@ $RECYCLE.BIN/
# TypeScript
*.tsbuildinfo
# Claude
CLAUDE.md

View File

@@ -94,4 +94,194 @@ export default class GanttController extends WorklenzControllerBase {
}
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async getRoadmapTasks(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const projectId = req.query.project_id;
const q = `
SELECT
t.id,
t.name,
t.start_date,
t.end_date,
t.done,
t.roadmap_sort_order,
t.parent_task_id,
CASE WHEN t.done THEN 100 ELSE 0 END as progress,
ts.name as status_name,
tsc.color_code as status_color,
tp.name as priority_name,
tp.value as priority_value,
tp.color_code as priority_color,
(
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(assignee_info))), '[]'::JSON)
FROM (
SELECT
tm.id as team_member_id,
u.name as assignee_name,
u.avatar_url
FROM tasks_assignees ta
JOIN team_members tm ON ta.team_member_id = tm.id
JOIN users u ON tm.user_id = u.id
WHERE ta.task_id = t.id
) assignee_info
) as assignees,
(
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(phase_info))), '[]'::JSON)
FROM (
SELECT
pp.id as phase_id,
pp.name as phase_name,
pp.color_code as phase_color
FROM task_phase tp
JOIN project_phases pp ON tp.phase_id = pp.id
WHERE tp.task_id = t.id
) phase_info
) as phases,
(
SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(dependency_info))), '[]'::JSON)
FROM (
SELECT
td.related_task_id,
td.dependency_type,
rt.name as related_task_name
FROM task_dependencies td
JOIN tasks rt ON td.related_task_id = rt.id
WHERE td.task_id = t.id
) dependency_info
) as dependencies
FROM tasks t
LEFT JOIN task_statuses ts ON t.status_id = ts.id
LEFT JOIN sys_task_status_categories tsc ON ts.category_id = tsc.id
LEFT JOIN task_priorities tp ON t.priority_id = tp.id
WHERE t.project_id = $1
AND t.archived = FALSE
AND t.parent_task_id IS NULL
ORDER BY t.roadmap_sort_order, t.created_at DESC;
`;
const result = await db.query(q, [projectId]);
// Get subtasks for each parent task
for (const task of result.rows) {
const subtasksQuery = `
SELECT
id,
name,
start_date,
end_date,
done,
roadmap_sort_order,
parent_task_id,
CASE WHEN done THEN 100 ELSE 0 END as progress
FROM tasks
WHERE parent_task_id = $1
AND archived = FALSE
ORDER BY roadmap_sort_order, created_at DESC;
`;
const subtasksResult = await db.query(subtasksQuery, [task.id]);
task.subtasks = subtasksResult.rows;
}
return res.status(200).send(new ServerResponse(true, result.rows));
}
@HandleExceptions()
public static async getProjectPhases(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const projectId = req.query.project_id;
const q = `
SELECT
pp.id,
pp.name,
pp.color_code,
pp.start_date,
pp.end_date,
pp.sort_index,
-- Calculate task counts by status category for progress
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_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]);
// 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()
public static async updateTaskDates(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
const { task_id, start_date, end_date } = req.body;
const q = `
UPDATE tasks
SET start_date = $2, end_date = $3, updated_at = NOW()
WHERE id = $1
RETURNING id, start_date, end_date;
`;
const result = await db.query(q, [task_id, start_date, end_date]);
return res.status(200).send(new ServerResponse(true, result.rows[0]));
}
}

View File

@@ -12,4 +12,9 @@ ganttApiRouter.get("/project-phases/:id", safeControllerFunction(GanttController
ganttApiRouter.get("/project-workload", safeControllerFunction(GanttController.getWorkload));
// New roadmap Gantt APIs
ganttApiRouter.get("/roadmap-tasks", safeControllerFunction(GanttController.getRoadmapTasks));
ganttApiRouter.get("/project-phases", safeControllerFunction(GanttController.getProjectPhases));
ganttApiRouter.post("/update-task-dates", safeControllerFunction(GanttController.updateTaskDates));
export default ganttApiRouter;

View File

@@ -0,0 +1,36 @@
{
"title": "Phase Details",
"overview": {
"title": "Overview",
"totalTasks": "Total Tasks",
"completion": "Completion",
"progress": "Progress"
},
"timeline": {
"title": "Timeline",
"startDate": "Start Date",
"endDate": "End Date",
"status": "Status",
"notSet": "Not set",
"statusLabels": {
"upcoming": "Upcoming",
"active": "In Progress",
"overdue": "Overdue",
"notScheduled": "Not Scheduled"
}
},
"taskBreakdown": {
"title": "Task Breakdown",
"completed": "Completed",
"pending": "Pending",
"overdue": "Overdue"
},
"phaseColor": {
"title": "Phase Color",
"description": "Phase identifier color"
},
"tasksInPhase": {
"title": "Tasks in this Phase",
"noTasks": "No tasks in this phase"
}
}

View File

@@ -0,0 +1,36 @@
{
"title": "Phase Details",
"overview": {
"title": "Overview",
"totalTasks": "Total Tasks",
"completion": "Completion",
"progress": "Progress"
},
"timeline": {
"title": "Timeline",
"startDate": "Start Date",
"endDate": "End Date",
"status": "Status",
"notSet": "Not set",
"statusLabels": {
"upcoming": "Upcoming",
"active": "In Progress",
"overdue": "Overdue",
"notScheduled": "Not Scheduled"
}
},
"taskBreakdown": {
"title": "Task Breakdown",
"completed": "Completed",
"pending": "Pending",
"overdue": "Overdue"
},
"phaseColor": {
"title": "Phase Color",
"description": "Phase identifier color"
},
"tasksInPhase": {
"title": "Tasks in this Phase",
"noTasks": "No tasks in this phase"
}
}

View File

@@ -0,0 +1,42 @@
{
"title": "Phase Details",
"overview": {
"title": "Overview",
"totalTasks": "Total Tasks",
"completion": "Completion",
"progress": "Progress"
},
"timeline": {
"title": "Timeline",
"startDate": "Start Date",
"endDate": "End Date",
"status": "Status",
"notSet": "Not set",
"statusLabels": {
"upcoming": "Upcoming",
"active": "In Progress",
"overdue": "Overdue",
"notScheduled": "Not Scheduled"
}
},
"taskBreakdown": {
"title": "Task Breakdown",
"completed": "Completed",
"pending": "Pending",
"overdue": "Overdue"
},
"phaseColor": {
"title": "Phase Color",
"description": "Phase identifier color"
},
"tasksInPhase": {
"title": "Tasks in this Phase",
"noTasks": "No tasks in this phase",
"priority": "Priority",
"assignees": "Assignees",
"dueDate": "Due Date",
"startDate": "Start Date",
"noAssignees": "Unassigned",
"noDueDate": "No due date"
}
}

View File

@@ -0,0 +1,36 @@
{
"title": "Phase Details",
"overview": {
"title": "Overview",
"totalTasks": "Total Tasks",
"completion": "Completion",
"progress": "Progress"
},
"timeline": {
"title": "Timeline",
"startDate": "Start Date",
"endDate": "End Date",
"status": "Status",
"notSet": "Not set",
"statusLabels": {
"upcoming": "Upcoming",
"active": "In Progress",
"overdue": "Overdue",
"notScheduled": "Not Scheduled"
}
},
"taskBreakdown": {
"title": "Task Breakdown",
"completed": "Completed",
"pending": "Pending",
"overdue": "Overdue"
},
"phaseColor": {
"title": "Phase Color",
"description": "Phase identifier color"
},
"tasksInPhase": {
"title": "Tasks in this Phase",
"noTasks": "No tasks in this phase"
}
}

View File

@@ -0,0 +1,36 @@
{
"title": "Phase Details",
"overview": {
"title": "Overview",
"totalTasks": "Total Tasks",
"completion": "Completion",
"progress": "Progress"
},
"timeline": {
"title": "Timeline",
"startDate": "Start Date",
"endDate": "End Date",
"status": "Status",
"notSet": "Not set",
"statusLabels": {
"upcoming": "Upcoming",
"active": "In Progress",
"overdue": "Overdue",
"notScheduled": "Not Scheduled"
}
},
"taskBreakdown": {
"title": "Task Breakdown",
"completed": "Completed",
"pending": "Pending",
"overdue": "Overdue"
},
"phaseColor": {
"title": "Phase Color",
"description": "Phase identifier color"
},
"tasksInPhase": {
"title": "Tasks in this Phase",
"noTasks": "No tasks in this phase"
}
}

View File

@@ -0,0 +1,36 @@
{
"title": "Phase Details",
"overview": {
"title": "Overview",
"totalTasks": "Total Tasks",
"completion": "Completion",
"progress": "Progress"
},
"timeline": {
"title": "Timeline",
"startDate": "Start Date",
"endDate": "End Date",
"status": "Status",
"notSet": "Not set",
"statusLabels": {
"upcoming": "Upcoming",
"active": "In Progress",
"overdue": "Overdue",
"notScheduled": "Not Scheduled"
}
},
"taskBreakdown": {
"title": "Task Breakdown",
"completed": "Completed",
"pending": "Pending",
"overdue": "Overdue"
},
"phaseColor": {
"title": "Phase Color",
"description": "Phase identifier color"
},
"tasksInPhase": {
"title": "Tasks in this Phase",
"noTasks": "No tasks in this phase"
}
}

View File

@@ -84,6 +84,7 @@ import selectionReducer from '@/features/task-management/selection.slice';
import homePageApiService from '@/api/home-page/home-page.api.service';
import { projectsApi } from '@/api/projects/projects.v1.api.service';
import { userActivityApiService } from '@/api/home-page/user-activity.api.service';
import { ganttApi } from '@/pages/projects/projectView/gantt/services/gantt-api.service';
import projectViewReducer from '@features/project/project-view-slice';
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
@@ -92,7 +93,7 @@ export const store = configureStore({
middleware: getDefaultMiddleware =>
getDefaultMiddleware({
serializableCheck: false,
}).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware),
}).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware, ganttApi.middleware),
reducer: {
// Auth & User
auth: authReducer,
@@ -105,6 +106,7 @@ export const store = configureStore({
homePageReducer: homePageReducer,
[homePageApiService.reducerPath]: homePageApiService.reducer,
[projectsApi.reducerPath]: projectsApi.reducer,
[ganttApi.reducerPath]: ganttApi.reducer,
userActivityReducer: userActivityReducer,
[userActivityApiService.reducerPath]: userActivityApiService.reducer,

View File

@@ -19,6 +19,9 @@ const ProjectViewMembers = React.lazy(
const ProjectViewUpdates = React.lazy(
() => import('@/pages/projects/project-view-1/updates/project-view-updates')
);
const ProjectViewGantt = React.lazy(
() => import('@/pages/projects/projectView/gantt/ProjectViewGantt')
);
// type of a tab items
type TabItems = {
@@ -43,6 +46,7 @@ const getTabLabel = (key: string): string => {
files: 'Files',
members: 'Members',
updates: 'Updates',
gantt: 'Gantt Chart',
};
return fallbacks[key] || key;
}
@@ -117,6 +121,16 @@ export const tabItems: TabItems[] = [
React.createElement(ProjectViewUpdates)
),
},
{
index: 6,
key: 'gantt',
label: getTabLabel('gantt'),
element: React.createElement(
Suspense,
{ fallback: React.createElement(InlineSuspenseFallback) },
React.createElement(ProjectViewGantt)
),
},
];
// Function to update tab labels when language changes
@@ -142,6 +156,9 @@ export const updateTabLabels = () => {
case 'updates':
item.label = getTabLabel('updates');
break;
case 'gantt':
item.label = getTabLabel('gantt');
break;
}
});
} catch (error) {

View File

@@ -0,0 +1,361 @@
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
import { Spin, message } from '@/shared/antd-imports';
import { useParams } from 'react-router-dom';
import GanttTimeline from './components/gantt-timeline/GanttTimeline';
import GanttTaskList from './components/gantt-task-list/GanttTaskList';
import GanttChart from './components/gantt-chart/GanttChart';
import GanttToolbar from './components/gantt-toolbar/GanttToolbar';
import ManagePhaseModal from '@components/task-management/ManagePhaseModal';
import PhaseDetailsModal from './components/phase-details-modal/PhaseDetailsModal';
import { GanttProvider } from './context/gantt-context';
import { GanttViewMode } from './types/gantt-types';
import {
useGetRoadmapTasksQuery,
useGetProjectPhasesQuery,
transformToGanttTasks,
transformToGanttPhases,
} from './services/gantt-api.service';
import { TimelineUtils } from './utils/timeline-calculator';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
setShowTaskDrawer,
setSelectedTaskId,
setTaskFormViewModel,
fetchTask,
} from '@features/task-drawer/task-drawer.slice';
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
import { DEFAULT_TASK_NAME } from '@/shared/constants';
import './gantt-styles.css';
const ProjectViewGantt: React.FC = React.memo(() => {
const { projectId } = useParams<{ projectId: string }>();
const dispatch = useAppDispatch();
const [viewMode, setViewMode] = useState<GanttViewMode>('month');
const [showPhaseModal, setShowPhaseModal] = useState(false);
const [showPhaseDetailsModal, setShowPhaseDetailsModal] = useState(false);
const [selectedPhase, setSelectedPhase] = useState<any>(null);
const [expandedTasks, setExpandedTasks] = useState<Set<string>>(new Set());
const [animatingTasks, setAnimatingTasks] = useState<Set<string>>(new Set());
const [prevExpandedTasks, setPrevExpandedTasks] = useState<Set<string>>(new Set());
const timelineRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<HTMLDivElement>(null);
const taskListRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
// RTK Query hooks
const {
data: tasksResponse,
error: tasksError,
isLoading: tasksLoading,
refetch: refetchTasks,
} = useGetRoadmapTasksQuery({ projectId: projectId || '' }, { skip: !projectId });
const {
data: phasesResponse,
error: phasesError,
isLoading: phasesLoading,
refetch: refetchPhases,
} = useGetProjectPhasesQuery({ projectId: projectId || '' }, { skip: !projectId });
// Transform API data to component format
const tasks = useMemo(() => {
if (tasksResponse?.body && phasesResponse?.body) {
const transformedTasks = transformToGanttTasks(tasksResponse.body, phasesResponse.body);
const result: any[] = [];
transformedTasks.forEach(task => {
// Always show phase milestones
if (task.type === 'milestone' || task.is_milestone) {
result.push(task);
// If this phase is expanded, show its children tasks
const phaseId =
task.id === 'phase-unmapped'
? 'unmapped'
: task.phase_id || task.id.replace('phase-', '');
const isExpanded = expandedTasks.has(phaseId);
if (isExpanded && task.children) {
task.children.forEach((child: any) => {
result.push({
...child,
phase_id: task.phase_id, // Ensure child has correct phase_id
});
});
}
}
});
return result;
}
return [];
}, [tasksResponse, phasesResponse, expandedTasks]);
const phases = useMemo(() => {
if (phasesResponse?.body) {
return transformToGanttPhases(phasesResponse.body);
}
return [];
}, [phasesResponse]);
// Calculate date range based on tasks
const dateRange = useMemo(() => {
if (tasks.length > 0) {
return TimelineUtils.getSmartDateRange(tasks, viewMode);
}
return { start: new Date(), end: new Date() };
}, [tasks, viewMode]);
const loading = tasksLoading || phasesLoading;
// Load priorities for task drawer functionality
useEffect(() => {
dispatch(fetchPriorities());
}, [dispatch]);
// Track expansion changes for animations
useEffect(() => {
const currentExpanded = expandedTasks;
const previousExpanded = prevExpandedTasks;
// Find newly expanded or collapsed phases
const newlyExpanded = new Set([...currentExpanded].filter(id => !previousExpanded.has(id)));
const newlyCollapsed = new Set([...previousExpanded].filter(id => !currentExpanded.has(id)));
if (newlyExpanded.size > 0 || newlyCollapsed.size > 0) {
// Set animation state for newly changed phases
setAnimatingTasks(new Set([...newlyExpanded, ...newlyCollapsed]));
// Clear animation state after animation completes
const timeout = setTimeout(() => {
setAnimatingTasks(new Set());
}, 400); // Match CSS animation duration
setPrevExpandedTasks(new Set(currentExpanded));
return () => clearTimeout(timeout);
}
}, [expandedTasks, prevExpandedTasks]);
const handleViewModeChange = useCallback((mode: GanttViewMode) => {
setViewMode(mode);
}, []);
const handleChartScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
// Sync horizontal scroll with timeline
if (timelineRef.current) {
timelineRef.current.scrollLeft = target.scrollLeft;
}
// Sync vertical scroll with task list
if (taskListRef.current) {
taskListRef.current.scrollTop = target.scrollTop;
}
}, []);
const handleTaskListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
// Sync vertical scroll with chart
if (chartRef.current) {
chartRef.current.scrollTop = target.scrollTop;
}
}, []);
const handleRefresh = useCallback(() => {
refetchTasks();
refetchPhases();
}, [refetchTasks, refetchPhases]);
const handleCreatePhase = useCallback(() => {
setShowPhaseModal(true);
}, []);
const handleCreateTask = useCallback(
(phaseId?: string) => {
// Create a new task using the task drawer
const newTaskViewModel = {
id: null,
name: DEFAULT_TASK_NAME,
project_id: projectId,
phase_id: phaseId || null,
// Add other default properties as needed
};
dispatch(setSelectedTaskId(null));
dispatch(setTaskFormViewModel(newTaskViewModel));
dispatch(setShowTaskDrawer(true));
},
[dispatch, projectId]
);
const handleTaskClick = useCallback(
(taskId: string) => {
// Open existing task in the task drawer
dispatch(setSelectedTaskId(taskId));
dispatch(setTaskFormViewModel(null)); // Clear form view model for existing task
dispatch(setShowTaskDrawer(true));
// Fetch the complete task data including priorities
if (projectId) {
dispatch(fetchTask({ taskId, projectId }));
}
},
[dispatch, projectId]
);
const handleClosePhaseModal = useCallback(() => {
setShowPhaseModal(false);
}, []);
const handlePhaseClick = useCallback((phase: any) => {
setSelectedPhase(phase);
setShowPhaseDetailsModal(true);
}, []);
const handleClosePhaseDetailsModal = useCallback(() => {
setShowPhaseDetailsModal(false);
setSelectedPhase(null);
}, []);
const handlePhaseUpdate = useCallback(
(updatedPhase: any) => {
// Refresh the data after phase update
refetchTasks();
refetchPhases();
},
[refetchTasks, refetchPhases]
);
const handlePhaseReorder = useCallback((oldIndex: number, newIndex: number) => {
// TODO: Implement phase reordering API call
console.log('Reorder phases:', { oldIndex, newIndex });
message.info('Phase reordering will be implemented with the backend API');
}, []);
const handleCreateQuickTask = useCallback(
(taskName: string, phaseId?: string, startDate?: Date) => {
// For now, just refresh the Gantt data after task creation
// The actual task creation will happen through existing mechanisms
// and the refresh will show the new task
console.log('Task created:', { taskName, phaseId, startDate });
// Show success message
message.success(`Task "${taskName}" created successfully`);
// Refresh the Gantt data to show the new task
refetchTasks();
refetchPhases();
},
[refetchTasks, refetchPhases]
);
// Handle errors
if (tasksError || phasesError) {
message.error('Failed to load Gantt chart data');
}
if (loading) {
return (
<div className="flex justify-center items-center h-full w-full">
<Spin size="large" />
</div>
);
}
return (
<GanttProvider
value={{
tasks,
phases,
viewMode,
projectId: projectId || '',
dateRange,
onRefresh: handleRefresh,
}}
>
<div
className="flex flex-col h-full w-full bg-gray-50 dark:bg-gray-900"
style={{ height: 'calc(100vh - 64px)' }}
>
<GanttToolbar
viewMode={viewMode}
onViewModeChange={handleViewModeChange}
dateRange={dateRange}
/>
<div className="flex flex-1 overflow-hidden border border-gray-200 dark:border-gray-700 rounded-md bg-white dark:bg-gray-800">
<div className="relative flex w-full h-full">
{/* Fixed Task List - positioned absolutely to avoid scrollbar interference */}
<div className="absolute left-0 top-0 bottom-0 z-20 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700">
<GanttTaskList
tasks={tasks}
projectId={projectId || ''}
viewMode={viewMode}
onTaskClick={handleTaskClick}
onPhaseClick={handlePhaseClick}
onCreateTask={handleCreateTask}
onCreateQuickTask={handleCreateQuickTask}
onCreatePhase={handleCreatePhase}
onPhaseReorder={handlePhaseReorder}
ref={taskListRef}
onScroll={handleTaskListScroll}
expandedTasks={expandedTasks}
onExpandedTasksChange={setExpandedTasks}
animatingTasks={animatingTasks}
/>
</div>
{/* Scrollable Timeline and Chart - with left margin for task list */}
<div
className="flex-1 flex flex-col overflow-hidden gantt-timeline-container"
style={{ marginLeft: '444px' }}
ref={containerRef}
>
<GanttTimeline
viewMode={viewMode}
ref={timelineRef}
containerRef={containerRef}
dateRange={dateRange}
/>
<GanttChart
tasks={tasks}
viewMode={viewMode}
ref={chartRef}
onScroll={handleChartScroll}
onPhaseClick={handlePhaseClick}
containerRef={containerRef}
dateRange={dateRange}
phases={phases}
expandedTasks={expandedTasks}
animatingTasks={animatingTasks}
onCreateQuickTask={handleCreateQuickTask}
projectId={projectId || ''}
/>
</div>
</div>
</div>
</div>
{/* Phase Management Modal */}
<ManagePhaseModal
open={showPhaseModal}
onClose={handleClosePhaseModal}
projectId={projectId}
/>
{/* Phase Details Modal */}
<PhaseDetailsModal
open={showPhaseDetailsModal}
onClose={handleClosePhaseDetailsModal}
phase={selectedPhase}
onPhaseUpdate={handlePhaseUpdate}
/>
</GanttProvider>
);
});
ProjectViewGantt.displayName = 'ProjectViewGantt';
export default ProjectViewGantt;

View File

@@ -0,0 +1,819 @@
import React, { memo, useMemo, forwardRef, RefObject, useState, useCallback } from 'react';
import ReactDOM from 'react-dom';
import { Input } from 'antd';
import { GanttTask, GanttViewMode, GanttPhase } from '../../types/gantt-types';
import { useGanttDimensions } from '../../hooks/useGanttDimensions';
// Utility function to add alpha channel to hex color
const addAlphaToHex = (hex: string, alpha: number): string => {
// Remove # if present
const cleanHex = hex.replace('#', '');
// Convert hex to RGB
const r = parseInt(cleanHex.substring(0, 2), 16);
const g = parseInt(cleanHex.substring(2, 4), 16);
const b = parseInt(cleanHex.substring(4, 6), 16);
// Return rgba string
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
interface GanttChartProps {
tasks: GanttTask[];
viewMode: GanttViewMode;
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
onPhaseClick?: (phase: GanttTask) => void;
containerRef: RefObject<HTMLDivElement | null>;
dateRange?: { start: Date; end: Date };
phases?: GanttPhase[];
expandedTasks?: Set<string>;
animatingTasks?: Set<string>;
onCreateQuickTask?: (taskName: string, phaseId?: string, startDate?: Date) => void;
projectId?: string;
}
interface GridColumnProps {
index: number;
columnWidth: number;
}
const GridColumn: React.FC<GridColumnProps> = memo(({ index, columnWidth }) => (
<div
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' : ''
}`}
style={{ width: `${columnWidth}px` }}
/>
));
GridColumn.displayName = 'GridColumn';
interface TaskBarRowProps {
task: GanttTask;
viewMode: GanttViewMode;
columnWidth: number;
columnsCount: number;
dateRange?: { start: Date; end: Date };
animationClass?: string;
onPhaseClick?: (phase: GanttTask) => void;
}
const TaskBarRow: React.FC<TaskBarRowProps> = memo(
({ task, viewMode, columnWidth, columnsCount, dateRange, animationClass = '', onPhaseClick }) => {
const renderMilestone = () => {
if (!task.start_date || !dateRange) return null;
// Calculate position for milestone diamond based on view mode
const totalTimeSpan = dateRange.end.getTime() - dateRange.start.getTime();
const timeFromStart = task.start_date.getTime() - dateRange.start.getTime();
const left = Math.max(0, (timeFromStart / totalTimeSpan) * (columnsCount * columnWidth));
return (
<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"
style={{
left: `${left}px`,
backgroundColor: task.color || '#3b82f6',
}}
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 handleClick = () => {
if (isPhase && onPhaseClick) {
onPhaseClick(task);
}
};
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' : onPhaseClick ? 'cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-750' : ''
} ${animationClass}`}
onClick={isPhase && onPhaseClick ? handleClick : undefined}
style={{
...(isPhase && task.color ? { backgroundColor: addAlphaToHex(task.color, 0.15) } : {}),
// Set lower z-index when no phase click handler so parent can receive clicks
...(isPhase && !onPhaseClick ? { position: 'relative', zIndex: 1 } : {}),
}}
>
{isPhase ? renderMilestone() : renderTaskBar()}
</div>
);
}
);
TaskBarRow.displayName = 'TaskBarRow';
// Task Creation Popover Component
const TaskCreationPopover: React.FC<{
taskPopover: {
taskName: string;
date: Date;
phaseId: string | null;
position: { x: number; y: number };
visible: boolean;
};
onTaskNameChange: (name: string) => void;
onCreateTask: () => void;
onCancel: () => void;
}> = ({ taskPopover, onTaskNameChange, onCreateTask, onCancel }) => {
if (!taskPopover.visible) {
return null;
}
return ReactDOM.createPortal(
<>
{/* Click outside overlay to close popover */}
<div
className="fixed inset-0 z-[9999] bg-black/5"
onClick={onCancel}
/>
{/* Popover */}
<div
className="fixed z-[10000]"
style={{
left: `${taskPopover.position.x - 100}px`,
top: `${taskPopover.position.y - 30}px`,
}}
>
<div className="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-600 rounded-lg shadow-lg p-3 min-w-[250px]">
<div className="text-xs text-gray-500 dark:text-gray-400 mb-2">
Add task for {taskPopover.date.toLocaleDateString()}
</div>
<Input
value={taskPopover.taskName}
onChange={(e) => onTaskNameChange(e.target.value)}
onPressEnter={onCreateTask}
onKeyDown={(e) => {
if (e.key === 'Escape') {
onCancel();
}
}}
placeholder="Enter task name..."
autoFocus
size="small"
className="mb-2"
/>
<div className="text-xs text-gray-400 dark:text-gray-500">
Press Enter to create Esc to cancel
</div>
</div>
</div>
</>,
document.body
);
};
const GanttChart = forwardRef<HTMLDivElement, GanttChartProps>(
({ tasks, viewMode, onScroll, onPhaseClick, containerRef, dateRange, phases, expandedTasks, animatingTasks, onCreateQuickTask, projectId }, ref) => {
// State for popover task creation
const [taskPopover, setTaskPopover] = useState<{
taskName: string;
date: Date;
phaseId: string | null;
position: { x: number; y: number };
visible: boolean;
} | null>(null);
const columnsCount = useMemo(() => {
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));
let baseColumnsCount = 0;
switch (viewMode) {
case 'day':
baseColumnsCount = diffDays;
break;
case 'week':
baseColumnsCount = Math.ceil(diffDays / 7);
break;
case 'month':
const startYear = start.getFullYear();
const startMonth = start.getMonth();
const endYear = end.getFullYear();
const endMonth = end.getMonth();
baseColumnsCount = (endYear - startYear) * 12 + (endMonth - startMonth) + 1;
break;
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);
baseColumnsCount = (qEndYear - qStartYear) * 4 + (qEndQuarter - qStartQuarter) + 1;
break;
case 'year':
baseColumnsCount = end.getFullYear() - start.getFullYear() + 1;
break;
default:
baseColumnsCount = 12;
}
return baseColumnsCount;
}, [viewMode, dateRange]);
// Calculate exact date from mouse position within timeline columns
const calculateDateFromPosition = useCallback((x: number, columnWidth: number): Date => {
if (!dateRange) return new Date();
// Calculate which column was clicked and position within that column
const columnIndex = Math.floor(x / columnWidth);
const positionWithinColumn = (x % columnWidth) / columnWidth; // 0 to 1
const { start, end } = dateRange;
let targetDate = new Date(start);
// Handle virtual columns beyond the actual date range
const actualColumnsInRange = columnsCount;
const isVirtualColumn = columnIndex >= actualColumnsInRange;
// If it's a virtual column, extend the date by calculating based on the end date
if (isVirtualColumn) {
const virtualColumnIndex = columnIndex - actualColumnsInRange;
targetDate = new Date(end);
switch (viewMode) {
case 'day':
targetDate.setDate(targetDate.getDate() + virtualColumnIndex + 1);
targetDate.setHours(Math.min(Math.floor(positionWithinColumn * 24), 23), 0, 0, 0);
break;
case 'week':
targetDate.setDate(targetDate.getDate() + (virtualColumnIndex + 1) * 7);
const dayWithinVirtualWeek = Math.min(Math.floor(positionWithinColumn * 7), 6);
targetDate.setDate(targetDate.getDate() + dayWithinVirtualWeek);
targetDate.setHours(0, 0, 0, 0);
break;
case 'month':
targetDate.setMonth(targetDate.getMonth() + virtualColumnIndex + 1);
const daysInVirtualMonth = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0).getDate();
const dayWithinVirtualMonth = Math.max(1, Math.min(Math.ceil(positionWithinColumn * daysInVirtualMonth), daysInVirtualMonth));
targetDate.setDate(dayWithinVirtualMonth);
targetDate.setHours(0, 0, 0, 0);
break;
case 'quarter':
const quartersToAdd = virtualColumnIndex + 1;
targetDate.setMonth(targetDate.getMonth() + (quartersToAdd * 3));
const quarterStartMonth = Math.floor(targetDate.getMonth() / 3) * 3;
targetDate.setMonth(quarterStartMonth, 1);
const quarterEndDate = new Date(targetDate.getFullYear(), quarterStartMonth + 3, 0);
const daysInVirtualQuarter = Math.floor((quarterEndDate.getTime() - targetDate.getTime()) / (1000 * 60 * 60 * 24)) + 1;
const dayWithinVirtualQuarter = Math.min(Math.floor(positionWithinColumn * daysInVirtualQuarter), daysInVirtualQuarter - 1);
targetDate.setDate(targetDate.getDate() + dayWithinVirtualQuarter);
targetDate.setHours(0, 0, 0, 0);
break;
case 'year':
targetDate.setFullYear(targetDate.getFullYear() + virtualColumnIndex + 1);
const isLeapYear = (targetDate.getFullYear() % 4 === 0 && targetDate.getFullYear() % 100 !== 0) || (targetDate.getFullYear() % 400 === 0);
const daysInVirtualYear = isLeapYear ? 366 : 365;
const dayWithinVirtualYear = Math.min(Math.floor(positionWithinColumn * daysInVirtualYear), daysInVirtualYear - 1);
targetDate = new Date(targetDate.getFullYear(), 0, 1 + dayWithinVirtualYear);
targetDate.setHours(0, 0, 0, 0);
break;
default:
targetDate.setDate(targetDate.getDate() + virtualColumnIndex + 1);
targetDate.setHours(0, 0, 0, 0);
break;
}
return targetDate;
}
switch (viewMode) {
case 'day':
// Timeline shows individual days - each column is one day
const dayStart = new Date(start);
const dayDates: Date[] = [];
const tempDayDate = new Date(dayStart);
while (tempDayDate <= end && dayDates.length <= columnIndex) {
dayDates.push(new Date(tempDayDate));
tempDayDate.setDate(tempDayDate.getDate() + 1);
}
if (dayDates[columnIndex]) {
targetDate = new Date(dayDates[columnIndex]);
// For day view, add hours based on position within column (0-23 hours)
const hour = Math.min(Math.floor(positionWithinColumn * 24), 23);
targetDate.setHours(hour, 0, 0, 0);
} else if (dayDates.length > 0) {
// Fallback to last available day if index is out of bounds
targetDate = new Date(dayDates[dayDates.length - 1]);
targetDate.setHours(23, 59, 59, 999);
}
break;
case 'week':
// Timeline shows weeks - calculate specific day within the week
const weekStart = new Date(start);
weekStart.setDate(weekStart.getDate() - weekStart.getDay()); // Start of week (Sunday)
const weekDates: Date[] = [];
const tempWeekDate = new Date(weekStart);
while (tempWeekDate <= end && weekDates.length <= columnIndex) {
weekDates.push(new Date(tempWeekDate));
tempWeekDate.setDate(tempWeekDate.getDate() + 7);
}
if (weekDates[columnIndex]) {
targetDate = new Date(weekDates[columnIndex]);
// Add days within the week (0-6 days from Sunday)
const dayWithinWeek = Math.min(Math.floor(positionWithinColumn * 7), 6);
targetDate.setDate(targetDate.getDate() + dayWithinWeek);
targetDate.setHours(0, 0, 0, 0);
} else if (weekDates.length > 0) {
// Fallback to last available week if index is out of bounds
targetDate = new Date(weekDates[weekDates.length - 1]);
targetDate.setDate(targetDate.getDate() + 6); // End of week
targetDate.setHours(23, 59, 59, 999);
}
break;
case 'month':
// Timeline shows months - calculate specific day within the month
const startYear = start.getFullYear();
const startMonth = start.getMonth();
const endYear = end.getFullYear();
const endMonth = end.getMonth();
const monthDates: Date[] = [];
let currentYear = startYear;
let currentMonth = startMonth;
while ((currentYear < endYear || (currentYear === endYear && currentMonth <= endMonth))
&& monthDates.length <= columnIndex) {
monthDates.push(new Date(currentYear, currentMonth, 1));
currentMonth++;
if (currentMonth > 11) {
currentMonth = 0;
currentYear++;
}
}
if (monthDates[columnIndex]) {
targetDate = new Date(monthDates[columnIndex]);
// Calculate days in this month
const daysInMonth = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0).getDate();
// Add days within the month (1-daysInMonth)
const dayWithinMonth = Math.max(1, Math.min(Math.ceil(positionWithinColumn * daysInMonth), daysInMonth));
targetDate.setDate(dayWithinMonth);
targetDate.setHours(0, 0, 0, 0);
} else if (monthDates.length > 0) {
// Fallback to last available month if index is out of bounds
targetDate = new Date(monthDates[monthDates.length - 1]);
const daysInMonth = new Date(targetDate.getFullYear(), targetDate.getMonth() + 1, 0).getDate();
targetDate.setDate(daysInMonth);
targetDate.setHours(23, 59, 59, 999);
}
break;
case 'quarter':
// Timeline shows quarters - calculate specific month and day within 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);
const quarterDates: Date[] = [];
let qYear = qStartYear;
let qQuarter = qStartQuarter;
while ((qYear < qEndYear || (qYear === qEndYear && qQuarter <= qEndQuarter))
&& quarterDates.length <= columnIndex) {
const quarterStartMonth = (qQuarter - 1) * 3;
quarterDates.push(new Date(qYear, quarterStartMonth, 1));
qQuarter++;
if (qQuarter > 4) {
qQuarter = 1;
qYear++;
}
}
if (quarterDates[columnIndex]) {
targetDate = new Date(quarterDates[columnIndex]);
// Calculate exact days in this quarter
const quarterStartMonth = targetDate.getMonth();
const quarterEndMonth = Math.min(quarterStartMonth + 2, 11);
const quarterEndDate = new Date(targetDate.getFullYear(), quarterEndMonth + 1, 0);
const daysInQuarter = Math.floor((quarterEndDate.getTime() - targetDate.getTime()) / (1000 * 60 * 60 * 24)) + 1;
const dayWithinQuarter = Math.min(Math.floor(positionWithinColumn * daysInQuarter), daysInQuarter - 1);
targetDate.setDate(targetDate.getDate() + dayWithinQuarter);
targetDate.setHours(0, 0, 0, 0);
} else if (quarterDates.length > 0) {
// Fallback to last available quarter if index is out of bounds
targetDate = new Date(quarterDates[quarterDates.length - 1]);
const quarterStartMonth = targetDate.getMonth();
const quarterEndMonth = Math.min(quarterStartMonth + 2, 11);
targetDate.setMonth(quarterEndMonth);
const daysInMonth = new Date(targetDate.getFullYear(), quarterEndMonth + 1, 0).getDate();
targetDate.setDate(daysInMonth);
targetDate.setHours(23, 59, 59, 999);
}
break;
case 'year':
// Timeline shows years - calculate specific month and day within year
const yearStart = start.getFullYear();
const yearEnd = end.getFullYear();
const yearDates: Date[] = [];
for (let year = yearStart; year <= yearEnd && yearDates.length <= columnIndex; year++) {
yearDates.push(new Date(year, 0, 1));
}
if (yearDates[columnIndex]) {
targetDate = new Date(yearDates[columnIndex]);
// Calculate exact days in this year
const isLeapYear = (targetDate.getFullYear() % 4 === 0 && targetDate.getFullYear() % 100 !== 0) || (targetDate.getFullYear() % 400 === 0);
const daysInYear = isLeapYear ? 366 : 365;
const dayWithinYear = Math.min(Math.floor(positionWithinColumn * daysInYear), daysInYear - 1);
// Add days carefully to avoid month overflow
const tempDate = new Date(targetDate.getFullYear(), 0, 1 + dayWithinYear);
targetDate = tempDate;
targetDate.setHours(0, 0, 0, 0);
} else if (yearDates.length > 0) {
// Fallback to last available year if index is out of bounds
targetDate = new Date(yearDates[yearDates.length - 1]);
targetDate.setMonth(11, 31); // December 31st
targetDate.setHours(23, 59, 59, 999);
}
break;
default:
// Default to day precision
targetDate = new Date(start);
targetDate.setDate(start.getDate() + columnIndex);
targetDate.setHours(0, 0, 0, 0);
break;
}
// Final safety check - ensure we have a valid date
if (isNaN(targetDate.getTime())) {
console.warn('Invalid date calculated, falling back to start date');
targetDate = new Date(start);
targetDate.setHours(0, 0, 0, 0);
}
// Ensure date is within the dateRange bounds
if (targetDate < start) {
targetDate = new Date(start);
targetDate.setHours(0, 0, 0, 0);
} else if (targetDate > end) {
targetDate = new Date(end);
targetDate.setHours(23, 59, 59, 999);
}
return targetDate;
}, [dateRange, viewMode, columnsCount]);
// First get basic dimensions to access containerWidth
const basicDimensions = useGanttDimensions(
viewMode,
containerRef,
columnsCount
);
// Calculate effective columns count that ensures container coverage
const effectiveColumnsCount = useMemo(() => {
if (!basicDimensions.containerWidth || basicDimensions.containerWidth === 0) {
return columnsCount;
}
// Import the column width calculation
const getBaseColumnWidth = (mode: GanttViewMode): number => {
switch (mode) {
case 'day':
return 40;
case 'week':
return 60;
case 'month':
return 80;
case 'quarter':
return 120;
case 'year':
return 160;
default:
return 80;
}
};
const baseColumnWidth = getBaseColumnWidth(viewMode);
const minColumnsNeeded = Math.ceil(basicDimensions.containerWidth / baseColumnWidth);
// For views that should stretch (month, quarter, year), ensure we have enough columns
// but don't add too many extra columns for day/week views
const shouldEnsureMinimum = viewMode !== 'day' && viewMode !== 'week';
if (shouldEnsureMinimum) {
return Math.max(columnsCount, minColumnsNeeded);
} else {
// For day/week views, we want scrolling, so just use calculated columns
// But ensure we have at least enough to fill a reasonable portion
return Math.max(columnsCount, Math.min(minColumnsNeeded, columnsCount * 2));
}
}, [columnsCount, basicDimensions.containerWidth, viewMode]);
// Get final dimensions with effective column count
const { actualColumnWidth, totalWidth, shouldScroll, containerWidth } = useGanttDimensions(
viewMode,
containerRef,
effectiveColumnsCount
);
const gridColumns = useMemo(
() => Array.from({ length: effectiveColumnsCount }).map((_, index) => index),
[effectiveColumnsCount]
);
// Flatten tasks to match the same hierarchy as task list
// This should be synchronized with the task list component's expand/collapse state
const flattenedTasks = useMemo(() => {
const result: Array<GanttTask | { id: string; isEmptyRow: boolean; isAddPhaseRow?: boolean }> = [];
const processedIds = new Set<string>(); // Track processed task IDs to prevent duplicates
const processTask = (task: GanttTask, level: number = 0) => {
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 ? expandedTasks.has(phaseId) : task.expanded !== false;
// Avoid processing the same task multiple times
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 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));
// Add the "Add Phase" row at the end
result.push({ id: 'add-phase-timeline', isEmptyRow: true, isAddPhaseRow: true });
return result;
}, [tasks, expandedTasks]);
// Use flattenedTasks directly since we're using popover instead of inline rows
const finalTasks = flattenedTasks;
// Handle timeline click - defined after flattenedTasks
const handleTimelineClick = useCallback((e: React.MouseEvent, rowIndex: number) => {
if (!dateRange || !onCreateQuickTask) return;
// Get the click position relative to the timeline
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const x = e.clientX - rect.left;
// Calculate which date was clicked based on column position
const clickedDate = calculateDateFromPosition(x, actualColumnWidth);
// Find which phase this row belongs to
const task = flattenedTasks[rowIndex];
let phaseId: string | null = null;
if (task && 'phase_id' in task) {
phaseId = task.phase_id || null;
} else {
// Find the nearest phase above this row
for (let i = rowIndex - 1; i >= 0; i--) {
const prevTask = flattenedTasks[i];
if (prevTask && 'is_milestone' in prevTask && prevTask.is_milestone) {
phaseId = prevTask.phase_id || prevTask.id.replace('phase-', '');
break;
}
}
}
// Get the click position relative to the viewport for popover positioning
const clickX = e.clientX;
const clickY = e.clientY;
const newPopoverState = {
taskName: '',
date: clickedDate,
phaseId,
position: { x: clickX, y: clickY },
visible: true,
};
setTaskPopover(newPopoverState);
}, [dateRange, onCreateQuickTask, flattenedTasks, calculateDateFromPosition, actualColumnWidth]);
// Handle task creation
const handleCreateTask = useCallback(() => {
if (taskPopover && onCreateQuickTask && taskPopover.taskName.trim()) {
onCreateQuickTask(taskPopover.taskName.trim(), taskPopover.phaseId || undefined, taskPopover.date);
setTaskPopover(null);
}
}, [taskPopover, onCreateQuickTask]);
// Handle cancel
const handleCancel = useCallback(() => {
setTaskPopover(null);
}, []);
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
className="relative"
style={{
width: `${totalWidth}px`,
minHeight: '100%',
minWidth: shouldScroll ? 'auto' : '100%',
}}
>
<div
className="absolute top-0 left-0 bottom-0 flex pointer-events-none"
style={{ width: `${totalWidth}px` }}
>
{/* Grid columns for timeline */}
{gridColumns.map(index => (
<GridColumn key={`grid-col-${index}`} index={index} columnWidth={actualColumnWidth} />
))}
</div>
<div className="relative z-10">
{finalTasks.map((item, index) => {
if ('isEmptyRow' in item && item.isEmptyRow) {
// Check if this is the Add Phase row
if ('isAddPhaseRow' in item && item.isAddPhaseRow) {
return (
<div
key={item.id}
className="min-h-[4.5rem] border-b border-gray-100 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20"
/>
);
}
// Regular add-task row - determine animation classes
const addTaskPhaseId = item.id.replace('add-task-', '').replace('-timeline', '');
const shouldAnimate = animatingTasks ? animatingTasks.has(addTaskPhaseId) : false;
const staggerIndex = Math.min((index - 1) % 5, 4);
const animationClass = shouldAnimate
? `gantt-task-slide-in gantt-task-stagger-${staggerIndex + 1}`
: '';
// Render empty row for add-task
return (
<div
key={item.id}
className={`h-9 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 ${animationClass}`}
/>
);
}
const task = item as GanttTask;
const isPhase = task.type === 'milestone' || task.is_milestone;
// Determine if this task should have animation classes
let parentPhaseId = '';
if (isPhase) {
parentPhaseId = task.id === 'phase-unmapped' ? 'unmapped' : task.phase_id || task.id.replace('phase-', '');
} else {
parentPhaseId = task.phase_id || '';
}
const shouldAnimate = !isPhase && animatingTasks ? animatingTasks.has(parentPhaseId) : false;
const staggerIndex = Math.min((index - 1) % 5, 4);
const animationClass = shouldAnimate
? `gantt-task-slide-in gantt-task-stagger-${staggerIndex + 1}`
: '';
return (
<div
key={item.id}
className={`relative cursor-pointer hover:bg-blue-50/30 dark:hover:bg-blue-900/10 transition-colors ${animationClass}`}
onClick={(e) => {
handleTimelineClick(e, index);
}}
style={{
height: isPhase ? '4.5rem' : '2.25rem',
zIndex: 10,
}}
>
<div style={{ position: 'absolute', inset: 0, zIndex: 1 }}>
<TaskBarRow
task={task}
viewMode={viewMode}
columnWidth={actualColumnWidth}
columnsCount={columnsCount}
dateRange={dateRange}
animationClass=""
onPhaseClick={undefined}
/>
</div>
</div>
);
})}
{finalTasks.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>
{/* Task Creation Popover */}
{taskPopover && taskPopover.visible && (
<TaskCreationPopover
taskPopover={taskPopover}
onTaskNameChange={(name) => setTaskPopover(prev => prev ? { ...prev, taskName: name } : null)}
onCreateTask={handleCreateTask}
onCancel={handleCancel}
/>
)}
</>
);
}
);
GanttChart.displayName = 'GanttChart';
export default memo(GanttChart);

View File

@@ -0,0 +1,953 @@
import React, { memo, useCallback, useState, forwardRef, useRef, useEffect, useMemo } from 'react';
import {
RightOutlined,
DownOutlined,
PlusOutlined,
HolderOutlined,
CalendarOutlined,
} from '@ant-design/icons';
import { Button, Tooltip, Input, DatePicker, Space, message } from 'antd';
import dayjs, { Dayjs } from 'dayjs';
import {
DndContext,
DragEndEvent,
DragOverEvent,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { GanttTask, GanttViewMode } from '../../types/gantt-types';
import { useSocket } from '../../../../../../socket/socketContext';
import { SocketEvents } from '../../../../../../shared/socket-events';
import { useAppDispatch } from '../../../../../../hooks/useAppDispatch';
import { addTask } from '../../../../../../features/task-management/task-management.slice';
import { useAuthService } from '../../../../../../hooks/useAuth';
import { useUpdatePhaseMutation } from '../../services/gantt-api.service';
// Utility function to add alpha channel to hex color
const addAlphaToHex = (hex: string, alpha: number): string => {
// Remove # if present
const cleanHex = hex.replace('#', '');
// Convert hex to RGB
const r = parseInt(cleanHex.substring(0, 2), 16);
const g = parseInt(cleanHex.substring(2, 4), 16);
const b = parseInt(cleanHex.substring(4, 6), 16);
// Return rgba string
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
};
interface GanttTaskListProps {
tasks: GanttTask[];
projectId: string;
viewMode: GanttViewMode;
onTaskToggle?: (taskId: string) => void;
onTaskClick?: (taskId: string) => void;
onPhaseClick?: (phase: GanttTask) => void;
onCreateTask?: (phaseId?: string) => void;
onCreateQuickTask?: (taskName: string, phaseId?: string) => void;
onCreatePhase?: () => void;
onPhaseReorder?: (oldIndex: number, newIndex: number) => void;
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
expandedTasks?: Set<string>;
onExpandedTasksChange?: (expanded: Set<string>) => void;
animatingTasks?: Set<string>;
}
interface TaskRowProps {
task: GanttTask;
index: number;
projectId: string;
onToggle?: (taskId: string) => void;
onTaskClick?: (taskId: string) => void;
onPhaseClick?: (phase: GanttTask) => void;
expandedTasks: Set<string>;
onCreateTask?: (phaseId?: string) => void;
onCreateQuickTask?: (taskName: string, phaseId?: string) => void;
isDraggable?: boolean;
activeId?: string | null;
overId?: string | null;
animationClass?: string;
}
interface SortableTaskRowProps extends TaskRowProps {
id: string;
}
// Sortable wrapper for phase milestones
const SortableTaskRow: React.FC<SortableTaskRowProps> = memo(props => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: props.id,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.7 : 1,
};
return (
<div ref={setNodeRef} style={style}>
<TaskRow
{...props}
isDraggable={true}
dragAttributes={attributes}
dragListeners={listeners}
/>
</div>
);
});
SortableTaskRow.displayName = 'SortableTaskRow';
const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: any }> = memo(
({
task,
projectId,
onToggle,
onTaskClick,
onPhaseClick,
expandedTasks,
onCreateTask,
onCreateQuickTask,
isDraggable = false,
activeId,
overId,
dragAttributes,
dragListeners,
animationClass = '',
}) => {
const [showInlineInput, setShowInlineInput] = useState(false);
const [taskName, setTaskName] = useState('');
const [showDatePickers, setShowDatePickers] = useState(false);
const datePickerRef = useRef<HTMLDivElement>(null);
const { socket, connected } = useSocket();
const dispatch = useAppDispatch();
const [updatePhase] = useUpdatePhaseMutation();
const formatDateRange = useCallback(() => {
if (!task.start_date || !task.end_date) {
return <span className="text-gray-400 dark:text-gray-500">Not scheduled</span>;
}
const start = new Date(task.start_date).toLocaleDateString();
const end = new Date(task.end_date).toLocaleDateString();
return `${start} - ${end}`;
}, [task.start_date, task.end_date]);
const isPhase = task.type === 'milestone' || task.is_milestone;
const hasChildren = task.children && task.children.length > 0;
// For phases, use phase_id for expansion state, for tasks use task.id
const phaseId = isPhase
? task.id === 'phase-unmapped'
? 'unmapped'
: task.phase_id || task.id.replace('phase-', '')
: task.id;
const isExpanded = expandedTasks.has(phaseId);
const indentLevel = (task.level || 0) * 20;
const handleToggle = useCallback(() => {
// For phases, always allow toggle (regardless of having children)
// Use the standard onToggle handler which will call handleTaskToggle in GanttTaskList
if (isPhase && onToggle) {
onToggle(phaseId);
} else if (hasChildren && onToggle) {
onToggle(task.id);
}
}, [isPhase, hasChildren, onToggle, task.id, phaseId]);
const getTaskIcon = () => {
// No icon for phases
return null;
};
const getExpandIcon = () => {
// All phases should be expandable (with or without children)
if (isPhase) {
return (
<button
onClick={handleToggle}
className={`w-4 h-4 flex items-center justify-center rounded gantt-expand-icon ${
isExpanded ? 'expanded' : ''
} hover:bg-black/10`}
style={task.color ? { color: task.color } : {}}
>
<RightOutlined className="text-xs transition-transform duration-200" />
</button>
);
}
return <div className="w-4 h-4" />;
};
const handleCreateTask = () => {
if (onCreateTask) {
// For phase milestones, pass the phase ID
const phaseId = task.type === 'milestone' && task.phase_id ? task.phase_id : undefined;
onCreateTask(phaseId);
}
};
// Handle inline task creation
const handleQuickTaskCreation = useCallback(
(taskName: string) => {
if (!connected || !socket || !projectId) return;
const currentSession = JSON.parse(localStorage.getItem('session') || '{}');
const phaseId = task.type === 'milestone' && task.phase_id ? task.phase_id : undefined;
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) {
// The task will be automatically added to the task management slice
// via global socket handlers, but we need to refresh the Gantt data
onCreateQuickTask?.(taskName, phaseId);
}
});
// Reset input state
setTaskName('');
setShowInlineInput(false);
},
[connected, socket, projectId, task.type, task.phase_id, onCreateQuickTask]
);
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);
}, []);
const handlePhaseDateUpdate = useCallback(
async (startDate: Date, endDate: Date) => {
if (!projectId || !task.phase_id) return;
try {
await updatePhase({
project_id: projectId,
phase_id: task.phase_id,
start_date: startDate.toISOString(),
end_date: endDate.toISOString(),
}).unwrap();
message.success('Phase dates updated successfully');
setShowDatePickers(false);
} catch (error) {
console.error('Failed to update phase dates:', error);
message.error('Failed to update phase dates');
}
},
[projectId, task.phase_id, updatePhase]
);
const isEmpty = isPhase && (!task.children || task.children.length === 0);
// Calculate phase completion percentage
const phaseCompletion = useMemo(() => {
if (!isPhase || !task.children || task.children.length === 0) {
return 0;
}
const totalTasks = task.children.length;
const completedTasks = task.children.filter(child => child.progress === 100).length;
return Math.round((completedTasks / totalTasks) * 100);
}, [isPhase, task.children]);
const handleTaskClick = useCallback(() => {
if (!isPhase && onTaskClick) {
onTaskClick(task.id);
}
}, [isPhase, onTaskClick, task.id]);
const handlePhaseClick = useCallback(() => {
if (isPhase && onPhaseClick) {
onPhaseClick(task);
}
}, [isPhase, onPhaseClick, task]);
// Handle click outside to close date picker
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) {
setShowDatePickers(false);
}
};
if (showDatePickers) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [showDatePickers]);
return (
<>
<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 ${
!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' : ''} ${
activeId === task.id ? 'opacity-50' : ''
} ${overId === task.id && overId !== activeId ? 'ring-2 ring-blue-500 ring-inset' : ''} ${animationClass}`}
style={
isPhase && task.color
? {
backgroundColor: addAlphaToHex(task.color, 0.15),
color: task.color,
}
: {}
}
onClick={!isPhase ? handleTaskClick : undefined}
{...(!isPhase && isDraggable ? dragAttributes : {})}
{...(!isPhase && isDraggable ? dragListeners : {})}
>
<div
className={`w-full px-2 py-2 text-sm ${isPhase ? '' : 'text-gray-800 dark:text-gray-200'} flex items-center justify-between`}
style={{
paddingLeft: `${8 + indentLevel + (isPhase && task.id === 'phase-unmapped' ? 28 : 0)}px`,
color: isPhase && task.color ? task.color : undefined,
}}
>
<div className="flex items-center gap-2 truncate flex-1">
{/* Drag handle for phases */}
{isPhase && isDraggable && (
<button
{...dragAttributes}
{...dragListeners}
className="opacity-50 hover:opacity-100 cursor-grab active:cursor-grabbing p-1 rounded hover:bg-black/10"
style={{ color: task.color }}
title="Drag to reorder phase"
>
<HolderOutlined className="text-xs" />
</button>
)}
{getExpandIcon()}
<div className="flex items-center gap-2 ml-1 truncate flex-1">
{getTaskIcon()}
<div className="flex flex-col flex-1">
<span
className={`truncate ${task.type === 'milestone' ? 'font-semibold cursor-pointer hover:opacity-80' : ''}`}
onClick={isPhase ? handlePhaseClick : undefined}
>
{task.name}
</span>
{isPhase && (
<div className="flex flex-col gap-0.5">
<span className="text-xs" style={{ color: task.color, opacity: 0.8 }}>
{task.children?.length || 0} tasks
</span>
{!showDatePickers && (
<button
onClick={() => setShowDatePickers(true)}
className="text-xs flex items-center gap-1 transition-colors"
style={{ color: task.color, opacity: 0.7 }}
>
<CalendarOutlined className="text-[10px]" />
{task.start_date && task.end_date ? (
<>
{dayjs(task.start_date).format('MMM D')} -{' '}
{dayjs(task.end_date).format('MMM D, YYYY')}
</>
) : (
'Set dates'
)}
</button>
)}
{showDatePickers && isPhase && (
<div ref={datePickerRef} className="flex items-center gap-1 mt-2 -ml-1">
<DatePicker.RangePicker
size="small"
value={[
task.start_date ? dayjs(task.start_date) : null,
task.end_date ? dayjs(task.end_date) : null,
]}
onChange={dates => {
if (dates && dates[0] && dates[1]) {
handlePhaseDateUpdate(dates[0].toDate(), dates[1].toDate());
}
}}
onOpenChange={open => {
if (!open) {
setShowDatePickers(false);
}
}}
className="text-xs"
style={{ width: 180 }}
format="MMM D, YYYY"
placeholder={['Start date', 'End date']}
autoFocus
/>
</div>
)}
</div>
)}
</div>
</div>
</div>
{/* Phase completion percentage on the right side */}
{isPhase && task.children && task.children.length > 0 && (
<div className="flex-shrink-0 mr-2">
<span className="text-xs font-medium" style={{ color: task.color, opacity: 0.9 }}>
{phaseCompletion}%
</span>
</div>
)}
</div>
</div>
</>
);
}
);
TaskRow.displayName = 'TaskRow';
// 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="w-full px-2 py-2 text-sm flex items-center"
style={{ paddingLeft: `${8 + 40}px` }} // Extra indent for child
>
{showInlineInput ? (
<Input
size="small"
placeholder="Enter task name..."
value={taskName}
onChange={e => setTaskName(e.target.value)}
onKeyDown={handleKeyPress}
onBlur={() => {
if (!taskName.trim()) {
setShowInlineInput(false);
}
}}
autoFocus
className="text-xs dark:bg-gray-700 dark:border-gray-600 dark:text-gray-100"
/>
) : (
<Button
type="text"
size="small"
icon={<PlusOutlined />}
onClick={handleShowInlineInput}
className="text-xs text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 gantt-add-task-btn"
>
Add Task
</Button>
)}
</div>
</div>
);
});
AddTaskRow.displayName = 'AddTaskRow';
// Add Phase Row Component
interface AddPhaseRowProps {
projectId: string;
onCreatePhase?: () => void;
}
const AddPhaseRow: React.FC<AddPhaseRowProps> = memo(({ projectId, onCreatePhase }) => {
return (
<div className="gantt-add-phase-row flex min-h-[4.5rem] border-b border-gray-100 dark:border-gray-700 bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors cursor-pointer">
<div
className="w-full px-2 py-2 text-sm flex items-center"
style={{ paddingLeft: `8px` }}
onClick={onCreatePhase}
>
<div className="flex items-center gap-3">
<div className="w-4 h-4 flex items-center justify-center rounded bg-blue-500 text-white">
<PlusOutlined className="text-xs" />
</div>
<div className="flex flex-col">
<span className="font-semibold text-blue-600 dark:text-blue-400">
Add New Phase
</span>
<span className="text-xs text-blue-500 dark:text-blue-300 opacity-80">
Click to create a new project phase
</span>
</div>
</div>
</div>
</div>
);
});
AddPhaseRow.displayName = 'AddPhaseRow';
const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(
(
{
tasks,
projectId,
viewMode,
onTaskToggle,
onTaskClick,
onPhaseClick,
onCreateTask,
onCreateQuickTask,
onCreatePhase,
onPhaseReorder,
onScroll,
expandedTasks: expandedTasksProp,
onExpandedTasksChange,
animatingTasks: animatingTasksProp,
},
ref
) => {
const [localExpandedTasks, setLocalExpandedTasks] = useState<Set<string>>(
() => new Set(tasks.filter(t => t.expanded).map(t => t.id))
);
const expandedTasks = expandedTasksProp || localExpandedTasks;
const animatingTasks = animatingTasksProp || new Set();
// Drag and drop state
const [activeId, setActiveId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
// Socket and auth
const { socket, connected } = useSocket();
const currentSession = useAuthService().getCurrentSession();
// DnD sensors
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
})
);
const handleTaskToggle = useCallback(
(taskId: string) => {
const updateExpanded = (prev: Set<string>) => {
const newSet = new Set(prev);
if (newSet.has(taskId)) {
newSet.delete(taskId);
} else {
newSet.add(taskId);
}
return newSet;
};
if (onExpandedTasksChange) {
onExpandedTasksChange(updateExpanded(expandedTasks));
} else {
setLocalExpandedTasks(updateExpanded);
}
onTaskToggle?.(taskId);
},
[expandedTasks, onExpandedTasksChange, onTaskToggle]
);
// Flatten tasks based on expand/collapse state
const flattenTasks = useCallback(
(taskList: GanttTask[]): GanttTask[] => {
const result: GanttTask[] = [];
const processedIds = new Set<string>(); // Track processed task IDs to prevent duplicates
const processTask = (task: GanttTask, level: number = 0) => {
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);
// Avoid processing the same task multiple times
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(task => processTask(task, 0));
return result;
},
[expandedTasks]
);
const visibleTasks = flattenTasks(tasks);
// Emit task sort change via socket for moving tasks between phases
const emitTaskPhaseChange = useCallback(
(taskId: string, fromPhaseId: string | null, toPhaseId: string | null, sortOrder: number) => {
if (!socket || !connected || !projectId) return;
const task = visibleTasks.find(t => t.id === taskId);
if (!task || task.type === 'milestone' || task.is_milestone) return;
const teamId = currentSession?.team_id || '';
const socketData = {
project_id: projectId,
group_by: 'phase',
task_updates: [
{
task_id: taskId,
sort_order: sortOrder,
phase_id: toPhaseId,
},
],
from_group: fromPhaseId || 'unmapped',
to_group: toPhaseId || 'unmapped',
task: {
id: task.id,
project_id: projectId,
status: '',
priority: '',
},
team_id: teamId,
};
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData);
},
[socket, connected, projectId, visibleTasks, currentSession]
);
const handleDragStart = useCallback((event: any) => {
setActiveId(event.active.id as string);
}, []);
const handleDragOver = useCallback((event: DragOverEvent) => {
const { active, over } = event;
if (!over) {
setOverId(null);
return;
}
setOverId(over.id as string);
}, []);
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
setOverId(null);
if (!over || active.id === over.id) return;
const activeTask = visibleTasks.find(t => t.id === active.id);
const overTask = visibleTasks.find(t => t.id === over.id);
// Handle phase reordering (existing functionality)
if (
activeTask &&
(activeTask.type === 'milestone' || activeTask.is_milestone) &&
onPhaseReorder
) {
const phases = tasks.filter(task => task.type === 'milestone' || task.is_milestone);
const oldIndex = phases.findIndex(phase => phase.id === active.id);
const newIndex = phases.findIndex(phase => phase.id === over.id);
if (oldIndex !== -1 && newIndex !== -1) {
onPhaseReorder(oldIndex, newIndex);
}
return;
}
// Handle task moving between phases
if (activeTask && !(activeTask.type === 'milestone' || activeTask.is_milestone)) {
let targetPhaseId: string | null = null;
// If dropped on a phase, move to that phase
if (overTask && (overTask.type === 'milestone' || overTask.is_milestone)) {
targetPhaseId = overTask.phase_id || overTask.id.replace('phase-', '');
if (overTask.id === 'phase-unmapped') {
targetPhaseId = null;
}
} else if (overTask) {
// If dropped on another task, move to that task's phase
targetPhaseId = overTask.phase_id;
}
// Find current phase
const currentPhaseId = activeTask.phase_id;
// Only emit if phase actually changed
if (currentPhaseId !== targetPhaseId) {
emitTaskPhaseChange(activeTask.id, currentPhaseId, targetPhaseId, 0);
}
}
},
[tasks, visibleTasks, onPhaseReorder, emitTaskPhaseChange]
);
// Separate phases and tasks for drag and drop (exclude unmapped phase)
const phases = visibleTasks.filter(
task => (task.type === 'milestone' || task.is_milestone) && task.id !== 'phase-unmapped'
);
const regularTasks = visibleTasks.filter(
task => !(task.type === 'milestone' || task.is_milestone)
);
// All draggable items (phases + tasks)
const allDraggableItems = [...phases.map(p => p.id), ...regularTasks.map(t => t.id)];
const phasesSet = new Set(phases.map(p => p.id));
// Determine if the timeline has dual headers
const hasDualHeaders = ['month', 'week', 'day'].includes(viewMode);
const headerHeight = hasDualHeaders ? 'h-20' : 'h-10';
return (
<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 className="w-full px-4 text-gray-700 dark:text-gray-300">Task Name</div>
</div>
<div className="flex-1 gantt-task-list-scroll relative" ref={ref} onScroll={onScroll}>
{visibleTasks.length === 0 && (
<div className="p-8 text-center text-gray-500 dark:text-gray-400">
No tasks available
</div>
)}
<DndContext
sensors={sensors}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<SortableContext items={allDraggableItems} strategy={verticalListSortingStrategy}>
{visibleTasks.map((task, index) => {
const isPhase = task.type === 'milestone' || task.is_milestone;
const isUnmappedPhase = task.id === 'phase-unmapped';
const isAddTaskButton = task.type === 'add-task-button';
// Determine if this task should have animation classes
let parentPhaseId = '';
if (isPhase) {
parentPhaseId = task.id === 'phase-unmapped' ? 'unmapped' : task.phase_id || task.id.replace('phase-', '');
} else if (isAddTaskButton) {
parentPhaseId = task.parent_phase_id || '';
} else {
parentPhaseId = task.phase_id || '';
}
const shouldAnimate = !isPhase && animatingTasks.has(parentPhaseId);
const staggerIndex = Math.min((index - 1) % 5, 4); // Subtract 1 to account for phase row, limit stagger to 5 levels
if (isAddTaskButton) {
const animationClass = shouldAnimate
? `gantt-task-slide-in gantt-task-stagger-${staggerIndex + 1}`
: '';
return (
<div key={task.id} className={animationClass}>
<AddTaskRow
task={task}
projectId={projectId}
onCreateQuickTask={onCreateQuickTask}
/>
</div>
);
} else if (isPhase && !isUnmappedPhase) {
return (
<SortableTaskRow
key={task.id}
id={task.id}
task={task}
index={index}
projectId={projectId}
onToggle={handleTaskToggle}
onTaskClick={onTaskClick}
onPhaseClick={onPhaseClick}
expandedTasks={expandedTasks}
onCreateTask={onCreateTask}
onCreateQuickTask={onCreateQuickTask}
activeId={activeId}
overId={overId}
/>
);
} else if (isUnmappedPhase) {
return (
<TaskRow
key={task.id}
task={task}
index={index}
projectId={projectId}
onToggle={handleTaskToggle}
onTaskClick={onTaskClick}
onPhaseClick={onPhaseClick}
expandedTasks={expandedTasks}
onCreateTask={onCreateTask}
onCreateQuickTask={onCreateQuickTask}
isDraggable={false}
activeId={activeId}
overId={overId}
/>
);
} else {
// Regular tasks - make them draggable too with animation
const animationClass = shouldAnimate
? `gantt-task-slide-in gantt-task-stagger-${staggerIndex + 1}`
: '';
return (
<SortableTaskRow
key={task.id}
id={task.id}
task={task}
index={index}
projectId={projectId}
onToggle={handleTaskToggle}
onTaskClick={onTaskClick}
onPhaseClick={onPhaseClick}
expandedTasks={expandedTasks}
onCreateTask={onCreateTask}
onCreateQuickTask={onCreateQuickTask}
activeId={activeId}
overId={overId}
animationClass={animationClass}
/>
);
}
})}
</SortableContext>
</DndContext>
{/* Add Phase Row - always at the bottom */}
<AddPhaseRow
projectId={projectId}
onCreatePhase={onCreatePhase}
/>
</div>
</div>
);
}
);
GanttTaskList.displayName = 'GanttTaskList';
export default memo(GanttTaskList);

View File

@@ -0,0 +1,249 @@
import React, { memo, useMemo, forwardRef, RefObject } from 'react';
import { GanttViewMode } from '../../types/gantt-types';
import { useGanttDimensions } from '../../hooks/useGanttDimensions';
import { TimelineUtils } from '../../utils/timeline-calculator';
interface GanttTimelineProps {
viewMode: GanttViewMode;
containerRef: RefObject<HTMLDivElement | null>;
dateRange?: { start: Date; end: Date };
}
const GanttTimeline = forwardRef<HTMLDivElement, GanttTimelineProps>(
({ viewMode, containerRef, dateRange }, ref) => {
const { topHeaders, bottomHeaders } = useMemo(() => {
if (!dateRange) {
return { topHeaders: [], bottomHeaders: [] };
}
const { start, end } = dateRange;
const topHeaders: Array<{ label: string; key: string; span: number }> = [];
const bottomHeaders: Array<{ label: string; key: string }> = [];
switch (viewMode) {
case 'month':
// Top: Years, Bottom: Months
const startYear = start.getFullYear();
const startMonth = start.getMonth();
const endYear = end.getFullYear();
const endMonth = end.getMonth();
// Generate bottom headers (months)
let currentYear = startYear;
let currentMonth = startMonth;
while (currentYear < endYear || (currentYear === endYear && currentMonth <= endMonth)) {
const date = new Date(currentYear, currentMonth, 1);
bottomHeaders.push({
label: date.toLocaleDateString('en-US', { month: 'short' }),
key: `month-${currentYear}-${currentMonth}`,
});
currentMonth++;
if (currentMonth > 11) {
currentMonth = 0;
currentYear++;
}
}
// Generate top headers (years)
for (let year = startYear; year <= endYear; year++) {
const monthsInYear = bottomHeaders.filter(h => h.key.includes(`-${year}-`)).length;
if (monthsInYear > 0) {
topHeaders.push({
label: `${year}`,
key: `year-${year}`,
span: monthsInYear,
});
}
}
break;
case 'week':
// Top: Months, Bottom: Weeks
const weekStart = new Date(start);
const weekEnd = new Date(end);
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
const weekDates: Date[] = [];
const tempDate = new Date(weekStart);
while (tempDate <= weekEnd) {
weekDates.push(new Date(tempDate));
tempDate.setDate(tempDate.getDate() + 7);
}
// Generate bottom headers (weeks)
weekDates.forEach(date => {
const weekNum = TimelineUtils.getWeekNumber(date);
bottomHeaders.push({
label: `W${weekNum}`,
key: `week-${date.getFullYear()}-${weekNum}`,
});
});
// Generate top headers (months)
const monthGroups = new Map<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();
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}
>
{header.label}
</div>
))}
</div>
</div>
);
}
);
GanttTimeline.displayName = 'GanttTimeline';
export default memo(GanttTimeline);

View File

@@ -0,0 +1,89 @@
import React, { memo } from 'react';
import { Select, Button, Space } from 'antd';
import {
ZoomInOutlined,
ZoomOutOutlined,
FullscreenOutlined,
} from '@ant-design/icons';
import { GanttViewMode } from '../../types/gantt-types';
const { Option } = Select;
interface GanttToolbarProps {
viewMode: GanttViewMode;
onViewModeChange: (mode: GanttViewMode) => void;
dateRange?: { start: Date; end: Date };
}
const GanttToolbar: React.FC<GanttToolbarProps> = memo(
({ viewMode, onViewModeChange, dateRange }) => {
// Define zoom levels in order from most detailed to least detailed
const zoomLevels: GanttViewMode[] = ['day', 'week', 'month', 'quarter', 'year'];
const currentZoomIndex = zoomLevels.indexOf(viewMode);
const handleZoomIn = () => {
// Zoom in means more detail (lower index)
if (currentZoomIndex > 0) {
onViewModeChange(zoomLevels[currentZoomIndex - 1]);
}
};
const handleZoomOut = () => {
// Zoom out means less detail (higher index)
if (currentZoomIndex < zoomLevels.length - 1) {
onViewModeChange(zoomLevels[currentZoomIndex + 1]);
}
};
const handleFullscreen = () => {
// Toggle fullscreen mode
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(err => {
console.warn('Failed to enter fullscreen:', err);
});
} else {
document.exitFullscreen().catch(err => {
console.warn('Failed to exit fullscreen:', err);
});
}
};
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">
<Space>
<Select value={viewMode} onChange={onViewModeChange} className="w-32">
<Option value="day">Day</Option>
<Option value="week">Week</Option>
<Option value="month">Month</Option>
<Option value="quarter">Quarter</Option>
<Option value="year">Year</Option>
</Select>
<Button
icon={<ZoomInOutlined />}
title="Zoom In"
onClick={handleZoomIn}
disabled={currentZoomIndex === 0}
className="hover:text-blue-600 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<Button
icon={<ZoomOutOutlined />}
title="Zoom Out"
onClick={handleZoomOut}
disabled={currentZoomIndex === zoomLevels.length - 1}
className="hover:text-blue-600 dark:hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<Button
icon={<FullscreenOutlined />}
title="Toggle Fullscreen"
onClick={handleFullscreen}
className="hover:text-blue-600 dark:hover:text-blue-400"
/>
</Space>
</div>
);
}
);
GanttToolbar.displayName = 'GanttToolbar';
export default GanttToolbar;

View File

@@ -0,0 +1,630 @@
import React, { useMemo, useState } from 'react';
import { Modal, Typography, Divider, Progress, Tag, Row, Col, Card, Statistic, theme, Tooltip, Input, DatePicker, ColorPicker, message } from 'antd';
import { CalendarOutlined, CheckCircleOutlined, ClockCircleOutlined, BgColorsOutlined, MinusOutlined, PauseOutlined, DoubleRightOutlined, UserOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import { useTranslation } from 'react-i18next';
import { useParams } from 'react-router-dom';
import AvatarGroup from '@/components/AvatarGroup';
import { GanttTask } from '../../types/gantt-types';
import { useUpdatePhaseMutation } from '../../services/gantt-api.service';
const { Title, Text } = Typography;
interface PhaseDetailsModalProps {
open: boolean;
onClose: () => void;
phase: GanttTask | null;
onPhaseUpdate?: (phase: Partial<GanttTask>) => void;
}
const PhaseDetailsModal: React.FC<PhaseDetailsModalProps> = ({ open, onClose, phase, onPhaseUpdate }) => {
const { projectId } = useParams<{ projectId: string }>();
const { t } = useTranslation('gantt/phase-details-modal');
const { token } = theme.useToken();
// API mutation hook
const [updatePhase, { isLoading: isUpdating }] = useUpdatePhaseMutation();
// Inline editing state
const [editingField, setEditingField] = useState<string | null>(null);
const [editedValues, setEditedValues] = useState<Partial<GanttTask>>({});
// Calculate phase statistics
const phaseStats = useMemo(() => {
if (!phase || !phase.children) {
return {
totalTasks: 0,
completedTasks: 0,
pendingTasks: 0,
overdueTasks: 0,
completionPercentage: 0,
};
}
const totalTasks = phase.children.length;
const completedTasks = phase.children.filter(task => task.progress === 100).length;
const pendingTasks = totalTasks - completedTasks;
// Calculate overdue tasks (tasks with end_date in the past and progress < 100)
const now = new Date();
const overdueTasks = phase.children.filter(task =>
task.end_date &&
new Date(task.end_date) < now &&
task.progress < 100
).length;
const completionPercentage = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
return {
totalTasks,
completedTasks,
pendingTasks,
overdueTasks,
completionPercentage,
};
}, [phase]);
const formatDate = (date: Date | null) => {
if (!date) return t('timeline.notSet');
return dayjs(date).format('MMM DD, YYYY');
};
const getDateStatus = () => {
if (!phase?.start_date || !phase?.end_date) return 'not-set';
const now = new Date();
const startDate = new Date(phase.start_date);
const endDate = new Date(phase.end_date);
if (now < startDate) return 'upcoming';
if (now > endDate) return 'overdue';
return 'active';
};
const getDateStatusColor = () => {
const status = getDateStatus();
switch (status) {
case 'upcoming': return '#1890ff';
case 'active': return '#52c41a';
case 'overdue': return '#ff4d4f';
default: return '#8c8c8c';
}
};
const getDateStatusText = () => {
const status = getDateStatus();
switch (status) {
case 'upcoming': return t('timeline.statusLabels.upcoming');
case 'active': return t('timeline.statusLabels.active');
case 'overdue': return t('timeline.statusLabels.overdue');
default: return t('timeline.statusLabels.notScheduled');
}
};
const getTaskStatus = (task: GanttTask) => {
if (task.progress === 100) return 'completed';
if (task.end_date && new Date(task.end_date) < new Date() && task.progress < 100) return 'overdue';
if (task.start_date && new Date(task.start_date) > new Date()) return 'upcoming';
return 'in-progress';
};
const getTaskStatusText = (status: string) => {
switch (status) {
case 'completed': return 'Completed';
case 'overdue': return 'Overdue';
case 'upcoming': return 'Upcoming';
case 'in-progress': return 'In Progress';
default: return 'Not Started';
}
};
const getTaskStatusColor = (status: string) => {
switch (status) {
case 'completed': return token.colorSuccess;
case 'overdue': return token.colorError;
case 'upcoming': return token.colorPrimary;
case 'in-progress': return token.colorWarning;
default: return token.colorTextTertiary;
}
};
const getPriorityIcon = (priority: string) => {
const priorityLower = priority?.toLowerCase();
switch (priorityLower) {
case 'low':
return <MinusOutlined className="w-3 h-3" />;
case 'medium':
return <PauseOutlined className="w-3 h-3" style={{ transform: 'rotate(90deg)' }} />;
case 'high':
return <DoubleRightOutlined className="w-3 h-3" style={{ transform: 'rotate(90deg)' }} />;
default:
return <MinusOutlined className="w-3 h-3" />;
}
};
const getPriorityColor = (priority: string) => {
const priorityLower = priority?.toLowerCase();
switch (priorityLower) {
case 'low': return '#52c41a';
case 'medium': return '#faad14';
case 'high': return '#ff4d4f';
default: return token.colorTextTertiary;
}
};
const convertAssigneesToMembers = (assignees: string[] | undefined) => {
if (!assignees || assignees.length === 0) return [];
return assignees.map((assignee, index) => ({
id: `assignee-${index}`,
name: assignee,
color_code: token.colorPrimary,
}));
};
const handleFieldSave = async (field: string, value: any) => {
if (!phase || !projectId) {
message.error('Phase or project information is missing');
return;
}
// Get the actual phase_id from the phase object
const phaseId = phase.phase_id || (phase.id.startsWith('phase-') ? phase.id.replace('phase-', '') : phase.id);
if (!phaseId || phaseId === 'unmapped') {
message.error('Cannot edit unmapped phase');
return;
}
try {
// Prepare API request based on field
const updateData: any = {
phase_id: phaseId,
project_id: projectId,
};
// Map the field to API format
if (field === 'name') {
updateData.name = value;
} else if (field === 'color') {
updateData.color_code = value;
} else if (field === 'start_date') {
updateData.start_date = value ? new Date(value).toISOString() : null;
} else if (field === 'end_date') {
updateData.end_date = value ? new Date(value).toISOString() : null;
}
// Call the API
await updatePhase(updateData).unwrap();
// Show success message
message.success(`Phase ${field.replace('_', ' ')} updated successfully`);
// Call the parent handler to refresh data
if (onPhaseUpdate) {
onPhaseUpdate({
id: phase.id,
[field]: value,
});
}
// Clear editing state
setEditingField(null);
setEditedValues({});
} catch (error: any) {
console.error('Failed to update phase:', error);
message.error(error?.data?.message || `Failed to update phase ${field.replace('_', ' ')}`);
// Don't clear editing state on error so user can try again
}
};
const handleFieldCancel = () => {
setEditingField(null);
setEditedValues({});
};
const startEditing = (field: string, currentValue: any) => {
setEditingField(field);
setEditedValues({ [field]: currentValue });
};
if (!phase) return null;
return (
<Modal
title={
<div className="flex items-center gap-3">
<ColorPicker
value={phase.color || token.colorPrimary}
onChange={(color) => handleFieldSave('color', color.toHexString())}
size="small"
showText={false}
trigger="click"
/>
{editingField === 'name' ? (
<Input
value={editedValues.name || phase.name}
onChange={(e) => setEditedValues(prev => ({ ...prev, name: e.target.value }))}
onPressEnter={() => handleFieldSave('name', editedValues.name)}
onBlur={() => handleFieldSave('name', editedValues.name)}
onKeyDown={(e) => e.key === 'Escape' && handleFieldCancel()}
className="font-semibold text-lg"
style={{ border: 'none', padding: 0, background: 'transparent' }}
autoFocus
/>
) : (
<Title
level={4}
className="!mb-0 cursor-pointer hover:opacity-70"
style={{ color: token.colorText }}
onClick={() => startEditing('name', phase.name)}
title="Click to edit"
>
{phase.name}
</Title>
)}
</div>
}
open={open}
onCancel={onClose}
footer={null}
width={1000}
centered
className="phase-details-modal"
confirmLoading={isUpdating}
>
<div className="flex gap-6">
{/* Left Side - Phase Overview and Stats */}
<div className="flex-1 space-y-6">
{/* Phase Overview */}
<Card
size="small"
className="shadow-sm"
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
>
<Row gutter={[16, 16]}>
<Col span={12}>
<Statistic
title={t('overview.totalTasks')}
value={phaseStats.totalTasks}
prefix={<ClockCircleOutlined style={{ color: token.colorPrimary }} />}
valueStyle={{ color: token.colorText }}
/>
</Col>
<Col span={12}>
<Statistic
title={t('overview.completion')}
value={phaseStats.completionPercentage}
suffix="%"
prefix={<CheckCircleOutlined style={{ color: token.colorSuccess }} />}
valueStyle={{ color: token.colorText }}
/>
</Col>
</Row>
<Divider className="my-4" style={{ borderColor: token.colorBorder }} />
<Progress
percent={phaseStats.completionPercentage}
strokeColor={{
'0%': phase.color || token.colorPrimary,
'100%': phase.color || token.colorPrimary,
}}
trailColor={token.colorBgLayout}
className="mb-2"
/>
</Card>
{/* Date Information */}
<Card
size="small"
title={
<div className="flex items-center gap-2">
<CalendarOutlined style={{ color: token.colorPrimary }} />
<Text strong style={{ color: token.colorText }}>{t('timeline.title')}</Text>
</div>
}
className="shadow-sm"
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
>
<Row gutter={[16, 16]}>
<Col span={8}>
<Text type="secondary">{t('timeline.startDate')}</Text>
<br />
{editingField === 'start_date' ? (
<DatePicker
value={editedValues.start_date ? dayjs(editedValues.start_date) : (phase.start_date ? dayjs(phase.start_date) : null)}
onChange={(date) => {
const newDate = date?.toDate() || null;
setEditedValues(prev => ({ ...prev, start_date: newDate }));
handleFieldSave('start_date', newDate);
}}
size="small"
className="w-full"
placeholder="Select start date"
autoFocus
open={true}
onOpenChange={(open) => !open && handleFieldCancel()}
/>
) : (
<Text
strong
className="cursor-pointer hover:opacity-70"
style={{ color: token.colorText }}
onClick={() => startEditing('start_date', phase.start_date)}
title="Click to edit"
>
{formatDate(phase.start_date)}
</Text>
)}
</Col>
<Col span={8}>
<Text type="secondary">{t('timeline.endDate')}</Text>
<br />
{editingField === 'end_date' ? (
<DatePicker
value={editedValues.end_date ? dayjs(editedValues.end_date) : (phase.end_date ? dayjs(phase.end_date) : null)}
onChange={(date) => {
const newDate = date?.toDate() || null;
setEditedValues(prev => ({ ...prev, end_date: newDate }));
handleFieldSave('end_date', newDate);
}}
size="small"
className="w-full"
placeholder="Select end date"
autoFocus
open={true}
onOpenChange={(open) => !open && handleFieldCancel()}
/>
) : (
<Text
strong
className="cursor-pointer hover:opacity-70"
style={{ color: token.colorText }}
onClick={() => startEditing('end_date', phase.end_date)}
title="Click to edit"
>
{formatDate(phase.end_date)}
</Text>
)}
</Col>
<Col span={8}>
<Text type="secondary">{t('timeline.status')}</Text>
<br />
<Tag color={getDateStatusColor()}>{getDateStatusText()}</Tag>
</Col>
</Row>
</Card>
{/* Task Breakdown */}
<Card
size="small"
title={
<div className="flex items-center gap-2">
<CheckCircleOutlined style={{ color: token.colorSuccess }} />
<Text strong style={{ color: token.colorText }}>{t('taskBreakdown.title')}</Text>
</div>
}
className="shadow-sm"
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
>
<Row gutter={[16, 16]}>
<Col span={8}>
<div className="text-center">
<div className="text-2xl font-bold text-green-500 dark:text-green-400">
{phaseStats.completedTasks}
</div>
<Text type="secondary">{t('taskBreakdown.completed')}</Text>
</div>
</Col>
<Col span={8}>
<div className="text-center">
<div className="text-2xl font-bold text-yellow-500 dark:text-yellow-400">
{phaseStats.pendingTasks}
</div>
<Text type="secondary">{t('taskBreakdown.pending')}</Text>
</div>
</Col>
<Col span={8}>
<div className="text-center">
<div className="text-2xl font-bold text-red-500 dark:text-red-400">
{phaseStats.overdueTasks}
</div>
<Text type="secondary">{t('taskBreakdown.overdue')}</Text>
</div>
</Col>
</Row>
</Card>
{/* Color Information */}
<Card
size="small"
title={
<div className="flex items-center gap-2">
<BgColorsOutlined style={{ color: token.colorPrimary }} />
<Text strong style={{ color: token.colorText }}>{t('phaseColor.title')}</Text>
</div>
}
className="shadow-sm"
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
>
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-lg border"
style={{
backgroundColor: phase.color || token.colorPrimary,
borderColor: token.colorBorder,
}}
/>
<div>
<Text strong style={{ color: token.colorText }}>{phase.color || token.colorPrimary}</Text>
<br />
<Text type="secondary">{t('phaseColor.description')}</Text>
</div>
</div>
</Card>
</div>
{/* Right Side - Task List */}
<div className="flex-1 flex flex-col">
{phase.children && phase.children.length > 0 ? (
<Card
size="small"
title={
<Text strong style={{ color: token.colorText }}>{t('tasksInPhase.title')}</Text>
}
className="shadow-sm flex-1 flex flex-col"
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '16px' }}
>
<div className="space-y-3 flex-1 overflow-y-auto">
{phase.children.map((task) => {
const taskStatus = getTaskStatus(task);
const taskStatusColor = getTaskStatusColor(taskStatus);
const assigneeMembers = convertAssigneesToMembers(task.assignees);
return (
<div
key={task.id}
className={`p-3 rounded-md border transition-colors hover:shadow-sm ${
task.progress === 100
? 'bg-green-50 dark:bg-green-900/20 border-green-200 dark:border-green-800'
: 'border-gray-200 dark:border-gray-700 hover:border-gray-300 dark:hover:border-gray-600'
}`}
style={{
backgroundColor: task.progress === 100
? undefined
: token.colorBgContainer,
borderColor: task.progress === 100
? undefined
: token.colorBorder
}}
>
{/* Main row with task info */}
<div className="flex items-center justify-between gap-3 mb-2">
{/* Left side: Status icon, task name, and priority */}
<div className="flex items-center gap-2 flex-1 min-w-0">
{task.progress === 100 ? (
<CheckCircleOutlined
className="flex-shrink-0"
style={{ color: token.colorSuccess, fontSize: '14px' }}
/>
) : taskStatus === 'overdue' ? (
<ClockCircleOutlined
className="flex-shrink-0"
style={{ color: token.colorError, fontSize: '14px' }}
/>
) : (
<ClockCircleOutlined
className="flex-shrink-0"
style={{ color: token.colorWarning, fontSize: '14px' }}
/>
)}
<Text
strong
className="text-sm truncate flex-1"
style={{ color: token.colorText }}
title={task.name}
>
{task.name}
</Text>
{/* Priority Icon */}
{task.priority && (
<Tooltip title={`Priority: ${task.priority}`}>
<div
className="flex items-center justify-center w-5 h-5 rounded flex-shrink-0"
style={{
backgroundColor: getPriorityColor(task.priority),
color: 'white'
}}
>
{getPriorityIcon(task.priority)}
</div>
</Tooltip>
)}
</div>
{/* Right side: Status tag */}
<Tag
color={taskStatusColor}
className="text-xs font-medium flex-shrink-0"
>
{getTaskStatusText(taskStatus)}
</Tag>
</div>
{/* Bottom row with assignees, progress, and due date */}
<div className="flex items-center justify-between gap-3">
{/* Assignees */}
<div className="flex items-center gap-2 flex-shrink-0">
{assigneeMembers.length > 0 ? (
<AvatarGroup
members={assigneeMembers}
maxCount={3}
size={20}
isDarkMode={token.mode === 'dark'}
/>
) : (
<div className="flex items-center gap-1 text-gray-400">
<UserOutlined className="text-xs" />
<Text type="secondary" className="text-xs">
Unassigned
</Text>
</div>
)}
</div>
{/* Due Date */}
<div className="flex items-center justify-end flex-1">
{task.end_date ? (
<div className="flex items-center gap-1">
<CalendarOutlined
className="text-xs"
style={{
color: taskStatus === 'overdue' ? token.colorError : token.colorTextTertiary
}}
/>
<Text
type="secondary"
className={`text-xs ${taskStatus === 'overdue' ? 'text-red-500 dark:text-red-400' : ''}`}
>
{dayjs(task.end_date).format('MMM DD')}
</Text>
</div>
) : (
<Text type="secondary" className="text-xs italic">
No due date
</Text>
)}
</div>
</div>
</div>
);
})}
</div>
</Card>
) : (
<Card
size="small"
className="shadow-sm flex-1 flex items-center justify-center"
style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }}
bodyStyle={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}
>
<div className="text-center py-8">
<ClockCircleOutlined className="text-4xl mb-3" style={{ color: token.colorTextTertiary }} />
<Text type="secondary" className="text-lg">
{t('tasksInPhase.noTasks')}
</Text>
</div>
</Card>
)}
</div>
</div>
</Modal>
);
};
export default PhaseDetailsModal;

View File

@@ -0,0 +1,18 @@
export const GANTT_COLUMN_WIDTH = 80; // Base column width in pixels
export const getColumnWidth = (viewMode: string): number => {
switch (viewMode) {
case 'day':
return 40;
case 'week':
return 60;
case 'month':
return 80;
case 'quarter':
return 120;
case 'year':
return 160;
default:
return 80;
}
};

View File

@@ -0,0 +1,19 @@
import React, { createContext, useContext } from 'react';
import { GanttContextType } from '../types/gantt-types';
const GanttContext = createContext<GanttContextType | undefined>(undefined);
export const GanttProvider: React.FC<{
children: React.ReactNode;
value: GanttContextType;
}> = ({ children, value }) => {
return <GanttContext.Provider value={value}>{children}</GanttContext.Provider>;
};
export const useGanttContext = () => {
const context = useContext(GanttContext);
if (!context) {
throw new Error('useGanttContext must be used within a GanttProvider');
}
return context;
};

View File

@@ -0,0 +1,270 @@
/* Hide scrollbar for Chrome, Safari and Opera */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
/* Hide scrollbar for IE, Edge and Firefox */
.scrollbar-hide {
-ms-overflow-style: none; /* IE and Edge */
scrollbar-width: none; /* Firefox */
}
/* Gantt task list specific styles */
.gantt-task-list-container {
/* Ensure the task list stays fixed and doesn't interfere with timeline scrolling */
position: relative;
box-sizing: border-box;
}
.gantt-timeline-container {
/* Ensure timeline scrolling doesn't affect task list positioning */
position: relative;
box-sizing: border-box;
}
/* Custom scrollbar for task list - hide scrollbar */
.gantt-task-list-scroll {
overflow-y: auto;
overflow-x: hidden;
scrollbar-width: none; /* Firefox - hide scrollbar */
-ms-overflow-style: none; /* IE and Edge - hide scrollbar */
}
/* Webkit scrollbar styling - hide scrollbar */
.gantt-task-list-scroll::-webkit-scrollbar {
display: none;
}
/* Gantt chart scrollbar - show both vertical and horizontal */
.gantt-chart-scroll::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.gantt-chart-scroll::-webkit-scrollbar-track {
background: transparent;
}
.gantt-chart-scroll::-webkit-scrollbar-thumb {
background-color: #cbd5e0;
border-radius: 4px;
}
.gantt-chart-scroll::-webkit-scrollbar-thumb:hover {
background-color: #a0aec0;
}
/* Firefox - hide horizontal scrollbar */
.gantt-chart-scroll {
scrollbar-width: thin;
scrollbar-color: #cbd5e0 transparent;
}
/* Dark mode chart scrollbar */
.dark .gantt-chart-scroll::-webkit-scrollbar-thumb {
background-color: #4a5568;
}
.dark .gantt-chart-scroll::-webkit-scrollbar-thumb:hover {
background-color: #718096;
}
.dark .gantt-chart-scroll {
scrollbar-color: #4a5568 transparent;
}
/* Ensure consistent row heights and alignment */
.gantt-task-list-scroll,
.gantt-chart-scroll {
margin: 0;
padding: 0;
box-sizing: border-box;
/* Ensure same scrolling behavior */
scroll-behavior: auto;
/* Prevent sub-pixel rendering issues */
transform: translateZ(0);
will-change: scroll-position;
}
/* Ensure all rows have exact same box model */
.gantt-task-list-scroll > div > div > div,
.gantt-chart-scroll > div > div > div {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* Force consistent border rendering */
.gantt-task-list-scroll .border-b,
.gantt-chart-scroll .border-b {
border-bottom-width: 1px;
border-bottom-style: solid;
}
/* Improve visual hierarchy for phase rows */
.gantt-phase-row {
position: relative;
cursor: pointer;
transition: all 0.2s ease-in-out;
}
.gantt-phase-row:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
transform: translateY(-1px);
}
.gantt-phase-row::before {
content: "";
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 4px;
background-color: currentColor;
opacity: 0.6;
transition: opacity 0.2s ease-in-out;
}
.gantt-phase-row:hover::before {
opacity: 0.8;
}
/* Better hover states */
.gantt-task-row:hover {
background-color: rgba(0, 0, 0, 0.02);
}
.dark .gantt-task-row:hover {
background-color: rgba(255, 255, 255, 0.02);
}
/* Improved button styles */
.gantt-add-task-btn {
transition: all 0.2s ease;
border-radius: 6px;
}
.gantt-add-task-btn:hover {
transform: translateY(-1px);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
/* Phase expansion transitions */
.gantt-phase-children {
overflow: hidden;
transition:
max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.3s ease-in-out;
}
.gantt-phase-children.collapsed {
max-height: 0;
opacity: 0;
}
.gantt-phase-children.expanded {
max-height: 2000px; /* Adjust based on expected max children */
opacity: 1;
}
/* Individual task transitions */
.gantt-task-row,
.gantt-add-task-inline {
transition: all 0.2s ease-in-out;
}
/* Staggered animation for multiple tasks */
.gantt-task-stagger-1 { animation-delay: 0.05s; }
.gantt-task-stagger-2 { animation-delay: 0.1s; }
.gantt-task-stagger-3 { animation-delay: 0.15s; }
.gantt-task-stagger-4 { animation-delay: 0.2s; }
.gantt-task-stagger-5 { animation-delay: 0.25s; }
/* Expand/collapse icon transitions */
.gantt-expand-icon {
transition: transform 0.2s ease-in-out;
}
.gantt-expand-icon.expanded {
transform: rotate(90deg);
}
/* Task row slide-in animation */
.gantt-task-slide-in {
animation: slideIn 0.3s ease-out forwards;
}
.gantt-task-slide-out {
animation: slideOut 0.2s ease-in forwards;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
max-height: 0;
}
to {
opacity: 1;
transform: translateY(0);
max-height: 36px; /* Height of a task row */
}
}
@keyframes slideOut {
from {
opacity: 1;
transform: translateY(0);
max-height: 36px;
}
to {
opacity: 0;
transform: translateY(-10px);
max-height: 0;
}
}
/* Add task button specific styles */
.gantt-add-task-inline {
transition: all 0.2s ease-in-out;
animation: fadeIn 0.3s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-5px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Timeline task bar transitions */
.gantt-chart-scroll .gantt-task-slide-in {
animation: slideInTimeline 0.3s ease-out forwards;
}
@keyframes slideInTimeline {
from {
opacity: 0;
transform: translateY(-10px) scale(0.95);
max-height: 0;
}
to {
opacity: 1;
transform: translateY(0) scale(1);
max-height: 36px;
}
}
/* Enhanced timeline task bar styling */
.gantt-chart-scroll .relative {
transition: all 0.2s ease-in-out;
}
/* Ensure timeline task bars have smooth hover transitions */
.gantt-chart-scroll .hover\\:bg-gray-50:hover {
transition: background-color 0.15s ease-in-out;
}

View File

@@ -0,0 +1,45 @@
import { useState, useEffect, useCallback, RefObject } from 'react';
import { GanttViewMode } from '../types/gantt-types';
import { getColumnWidth } from '../constants/gantt-constants';
export const useGanttDimensions = (
viewMode: GanttViewMode,
containerRef: RefObject<HTMLDivElement>,
columnsCount: number
) => {
const [containerWidth, setContainerWidth] = useState(0);
const updateContainerWidth = useCallback(() => {
if (containerRef.current) {
setContainerWidth(containerRef.current.offsetWidth);
}
}, [containerRef]);
useEffect(() => {
updateContainerWidth();
window.addEventListener('resize', updateContainerWidth);
return () => window.removeEventListener('resize', updateContainerWidth);
}, [updateContainerWidth]);
const baseColumnWidth = getColumnWidth(viewMode);
const minTotalWidth = columnsCount * baseColumnWidth;
// 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
const shouldStretch = viewMode !== 'day' && viewMode !== 'week';
const actualColumnWidth =
shouldStretch && containerWidth > minTotalWidth
? containerWidth / columnsCount
: baseColumnWidth;
const totalWidth = columnsCount * actualColumnWidth;
return {
containerWidth,
actualColumnWidth,
totalWidth,
columnsCount,
shouldScroll: totalWidth > containerWidth,
};
};

View File

@@ -0,0 +1,339 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { API_BASE_URL } from '@/shared/constants';
import { IServerResponse } from '@/types/common.types';
import { getCsrfToken, refreshCsrfToken } from '@/api/api-client';
import config from '@/config/env';
import { GanttTask, GanttPhase } from '../types/gantt-types';
const rootUrl = '/gantt';
export interface RoadmapTasksResponse {
id: string;
name: string;
start_date: string | null;
end_date: string | null;
done: boolean;
progress: number;
roadmap_sort_order: number;
parent_task_id: string | null;
status_name: string;
status_color: string;
priority_name: string;
priority_value: number;
priority_color: string;
phases: Array<{
phase_id: string;
phase_name: string;
phase_color: string;
}>;
assignees: Array<{
team_member_id: string;
assignee_name: string;
avatar_url?: string;
}>;
dependencies: Array<{
related_task_id: string;
dependency_type: string;
related_task_name: string;
}>;
subtasks: Array<{
id: string;
name: string;
start_date: string | null;
end_date: string | null;
done: boolean;
progress: number;
roadmap_sort_order: number;
parent_task_id: string;
phase_id?: string | null; // Keep this for subtasks compatibility
}>;
}
export interface ProjectPhaseResponse {
id: string;
name: string;
color_code: string;
start_date: string | null;
end_date: string | null;
sort_index: number;
todo_progress: number;
doing_progress: number;
done_progress: number;
total_tasks: number;
}
export interface UpdateTaskDatesRequest {
task_id: string;
start_date: string;
end_date: string;
}
export interface CreatePhaseRequest {
project_id: string;
name: string;
color_code?: string;
start_date?: string;
end_date?: string;
}
export interface CreateTaskRequest {
project_id: string;
name: string;
phase_id?: string;
start_date?: string;
end_date?: string;
priority_id?: string;
status_id?: string;
}
export interface UpdatePhaseRequest {
phase_id: string;
project_id: string;
name?: string;
color_code?: string;
start_date?: string;
end_date?: string;
}
export const ganttApi = createApi({
reducerPath: 'ganttApi',
baseQuery: fetchBaseQuery({
baseUrl: `${config.apiUrl}${API_BASE_URL}`,
prepareHeaders: async headers => {
// Get CSRF token, refresh if needed
let token = getCsrfToken();
if (!token) {
token = await refreshCsrfToken();
}
if (token) {
headers.set('X-CSRF-Token', token);
}
headers.set('Content-Type', 'application/json');
return headers;
},
credentials: 'include',
}),
tagTypes: ['GanttTasks', 'GanttPhases'],
endpoints: builder => ({
getRoadmapTasks: builder.query<IServerResponse<RoadmapTasksResponse[]>, { projectId: string }>({
query: ({ projectId }) => {
const params = new URLSearchParams({
project_id: projectId,
});
return `${rootUrl}/roadmap-tasks?${params.toString()}`;
},
providesTags: (result, error, { projectId }) => [
{ type: 'GanttTasks', id: projectId },
{ type: 'GanttTasks', id: 'LIST' },
],
}),
getProjectPhases: builder.query<IServerResponse<ProjectPhaseResponse[]>, { projectId: string }>(
{
query: ({ projectId }) => {
const params = new URLSearchParams({
project_id: projectId,
});
return `${rootUrl}/project-phases?${params.toString()}`;
},
providesTags: (result, error, { projectId }) => [
{ type: 'GanttPhases', id: projectId },
{ type: 'GanttPhases', id: 'LIST' },
],
}
),
updateTaskDates: builder.mutation<IServerResponse<any>, UpdateTaskDatesRequest>({
query: body => ({
url: `${rootUrl}/update-task-dates`,
method: 'POST',
body,
}),
invalidatesTags: (result, error, { task_id }) => [{ type: 'GanttTasks', id: 'LIST' }],
}),
createPhase: builder.mutation<IServerResponse<ProjectPhaseResponse>, CreatePhaseRequest>({
query: body => ({
url: `${rootUrl}/create-phase`,
method: 'POST',
body,
}),
invalidatesTags: (result, error, { project_id }) => [
{ type: 'GanttPhases', id: project_id },
{ type: 'GanttPhases', id: 'LIST' },
{ type: 'GanttTasks', id: project_id },
{ type: 'GanttTasks', id: 'LIST' },
],
}),
createTask: builder.mutation<IServerResponse<RoadmapTasksResponse>, CreateTaskRequest>({
query: body => ({
url: `${rootUrl}/create-task`,
method: 'POST',
body,
}),
invalidatesTags: (result, error, { project_id }) => [
{ type: 'GanttTasks', id: project_id },
{ type: 'GanttTasks', id: 'LIST' },
],
}),
updatePhase: builder.mutation<IServerResponse<ProjectPhaseResponse>, UpdatePhaseRequest>({
query: body => ({
url: `${rootUrl}/update-phase`,
method: 'PUT',
body,
}),
invalidatesTags: (result, error, { project_id }) => [
{ type: 'GanttPhases', id: project_id },
{ type: 'GanttPhases', id: 'LIST' },
{ type: 'GanttTasks', id: project_id },
{ type: 'GanttTasks', id: 'LIST' },
],
}),
}),
});
export const {
useGetRoadmapTasksQuery,
useGetProjectPhasesQuery,
useUpdateTaskDatesMutation,
useCreatePhaseMutation,
useCreateTaskMutation,
useUpdatePhaseMutation,
} = ganttApi;
/**
* Transform API response to Gantt task format with phases as milestones
*/
export const transformToGanttTasks = (
apiTasks: RoadmapTasksResponse[],
apiPhases: ProjectPhaseResponse[]
): GanttTask[] => {
// Group tasks by phase
const tasksByPhase = new Map<string, RoadmapTasksResponse[]>();
const unassignedTasks: RoadmapTasksResponse[] = [];
apiTasks.forEach(task => {
// Tasks now have phases array instead of direct phase_id
const taskPhaseId = task.phases.length > 0 ? task.phases[0].phase_id : null;
if (taskPhaseId) {
if (!tasksByPhase.has(taskPhaseId)) {
tasksByPhase.set(taskPhaseId, []);
}
tasksByPhase.get(taskPhaseId)!.push(task);
} else {
unassignedTasks.push(task);
}
});
const result: GanttTask[] = [];
// Create phase milestones with their tasks (sorted by phase order)
[...apiPhases]
.sort((a, b) => a.sort_index - b.sort_index)
.forEach(phase => {
const phaseTasks = tasksByPhase.get(phase.id) || [];
// Create phase milestone
const phaseMilestone: GanttTask = {
id: `phase-${phase.id}`,
name: phase.name,
start_date: phase.start_date ? new Date(phase.start_date) : null,
end_date: phase.end_date ? new Date(phase.end_date) : null,
progress: 0,
level: 0,
expanded: true,
color: phase.color_code,
type: 'milestone',
is_milestone: true,
phase_id: phase.id,
// 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);
});
// Always create unmapped phase at the bottom (even if empty)
const unmappedPhase: GanttTask = {
id: 'phase-unmapped',
name: 'Unmapped',
start_date: null,
end_date: null,
progress: 0,
level: 0,
expanded: true,
color: '#9CA3AF', // Gray color for unmapped phase
type: 'milestone',
is_milestone: true,
phase_id: null,
children: unassignedTasks.map(task => transformTask(task, 1)),
};
result.push(unmappedPhase);
return result;
};
/**
* Helper function to transform individual task
*/
const transformTask = (task: RoadmapTasksResponse, level: number = 0): GanttTask => {
const taskPhaseId = task.phases.length > 0 ? task.phases[0].phase_id : null;
return {
id: task.id,
name: task.name,
start_date: task.start_date ? new Date(task.start_date) : null,
end_date: task.end_date ? new Date(task.end_date) : null,
progress: task.progress,
dependencies: task.dependencies.map(dep => dep.related_task_id),
dependencyType: (task.dependencies[0]?.dependency_type as any) || 'blocked_by',
parent_id: task.parent_task_id,
children: task.subtasks.map(subtask => ({
id: subtask.id,
name: subtask.name,
start_date: subtask.start_date ? new Date(subtask.start_date) : null,
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',
};
};
/**
* Transform API response to Gantt phases format
*/
export const transformToGanttPhases = (apiPhases: ProjectPhaseResponse[]): GanttPhase[] => {
return apiPhases.map(phase => ({
id: phase.id,
name: phase.name,
color_code: phase.color_code,
start_date: phase.start_date ? new Date(phase.start_date) : null,
end_date: phase.end_date ? new Date(phase.end_date) : null,
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

@@ -0,0 +1,63 @@
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 interface GanttTask {
id: string;
name: string;
start_date: Date | null;
end_date: Date | null;
progress: number;
dependencies?: string[];
dependencyType?: DependencyType;
parent_id?: string;
children?: GanttTask[];
level?: number;
expanded?: boolean;
color?: string;
assignees?: string[];
priority?: string;
status?: string;
phase_id?: string;
is_milestone?: boolean;
type?: 'task' | 'milestone' | 'phase' | 'add-task-button';
// Add task row specific properties
parent_phase_id?: string;
}
export interface GanttPhase {
id: string;
name: string;
color_code: string;
start_date: Date | null;
end_date: Date | null;
sort_index: number;
tasks?: GanttTask[];
expanded?: boolean;
}
export interface GanttMilestone extends Omit<GanttTask, 'type'> {
type: 'milestone';
phase_id: string;
}
export interface GanttDependency {
id: string;
task_id: string;
related_task_id: string;
dependency_type: DependencyType;
}
export interface GanttContextType {
tasks: GanttTask[];
phases: GanttPhase[];
viewMode: GanttViewMode;
projectId: string;
dateRange: { start: Date; end: Date };
onRefresh: () => void;
}

View File

@@ -0,0 +1,338 @@
import { GanttViewMode, GanttTask } from '../types/gantt-types';
export interface TimelinePosition {
left: number;
width: number;
isValid: boolean;
}
export interface TimelineBounds {
startDate: Date;
endDate: Date;
totalDays: number;
pixelsPerDay: number;
}
export class TimelineCalculator {
private viewMode: GanttViewMode;
private columnWidth: number;
private timelineBounds: TimelineBounds;
constructor(viewMode: GanttViewMode, columnWidth: number, startDate: Date, endDate: Date) {
this.viewMode = viewMode;
this.columnWidth = columnWidth;
this.timelineBounds = this.calculateTimelineBounds(startDate, endDate);
}
/**
* Calculate timeline bounds and pixels per day
*/
private calculateTimelineBounds(startDate: Date, endDate: Date): TimelineBounds {
const totalDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24));
const columnsCount = this.getColumnsCount();
const totalWidth = columnsCount * this.columnWidth;
const pixelsPerDay = totalWidth / totalDays;
return {
startDate,
endDate,
totalDays,
pixelsPerDay,
};
}
/**
* Get number of columns based on view mode
*/
private getColumnsCount(): number {
switch (this.viewMode) {
case 'day':
return 30;
case 'week':
return 12;
case 'month':
return 12;
case 'quarter':
return 8;
case 'year':
return 5;
default:
return 12;
}
}
/**
* Calculate task bar position and width
*/
calculateTaskPosition(task: GanttTask): TimelinePosition {
if (!task.start_date || !task.end_date) {
return { left: 0, width: 0, isValid: false };
}
const taskStart = new Date(task.start_date);
const taskEnd = new Date(task.end_date);
// Ensure task dates are within timeline bounds
const clampedStart = new Date(
Math.max(taskStart.getTime(), this.timelineBounds.startDate.getTime())
);
const clampedEnd = new Date(Math.min(taskEnd.getTime(), this.timelineBounds.endDate.getTime()));
// Calculate days from timeline start
const daysFromStart = Math.floor(
(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
const left = daysFromStart * this.timelineBounds.pixelsPerDay;
const width = Math.max(taskDuration * this.timelineBounds.pixelsPerDay, 10); // Minimum 10px width
return {
left: Math.max(0, left),
width,
isValid: true,
};
}
/**
* Calculate milestone position (single point in time)
*/
calculateMilestonePosition(date: Date): { left: number; isValid: boolean } {
if (!date) {
return { left: 0, isValid: false };
}
const milestoneDate = new Date(date);
// Check if milestone is within timeline bounds
if (
milestoneDate < this.timelineBounds.startDate ||
milestoneDate > this.timelineBounds.endDate
) {
return { left: 0, isValid: false };
}
const daysFromStart = Math.floor(
(milestoneDate.getTime() - this.timelineBounds.startDate.getTime()) / (1000 * 60 * 60 * 24)
);
const left = daysFromStart * this.timelineBounds.pixelsPerDay;
return {
left: Math.max(0, left),
isValid: true,
};
}
/**
* Calculate dependency line coordinates
*/
calculateDependencyLine(
fromTask: GanttTask,
toTask: GanttTask,
rowHeight: number = 36
): {
x1: number;
y1: number;
x2: number;
y2: number;
isValid: boolean;
} {
const fromPosition = this.calculateTaskPosition(fromTask);
const toPosition = this.calculateTaskPosition(toTask);
if (!fromPosition.isValid || !toPosition.isValid) {
return { x1: 0, y1: 0, x2: 0, y2: 0, isValid: false };
}
// Assume tasks are in different rows - would need actual row indices in real implementation
const fromY = 0; // Would be calculated based on task index * rowHeight
const toY = rowHeight; // Would be calculated based on task index * rowHeight
return {
x1: fromPosition.left + fromPosition.width, // End of source task
y1: fromY + rowHeight / 2,
x2: toPosition.left, // Start of target task
y2: toY + rowHeight / 2,
isValid: true,
};
}
/**
* Convert pixel position back to date
*/
pixelToDate(pixelPosition: number): Date {
const daysFromStart = pixelPosition / this.timelineBounds.pixelsPerDay;
const targetDate = new Date(this.timelineBounds.startDate);
targetDate.setDate(targetDate.getDate() + daysFromStart);
return targetDate;
}
/**
* Get today line position
*/
getTodayLinePosition(): { left: number; isVisible: boolean } {
const today = new Date();
const position = this.calculateMilestonePosition(today);
return {
left: position.left,
isVisible: position.isValid,
};
}
/**
* Calculate weekend/holiday shading areas
*/
getWeekendAreas(): Array<{ left: number; width: number }> {
const weekendAreas: Array<{ left: number; width: number }> = [];
const current = new Date(this.timelineBounds.startDate);
while (current <= this.timelineBounds.endDate) {
// Saturday (6) and Sunday (0)
if (current.getDay() === 0 || current.getDay() === 6) {
const position = this.calculateMilestonePosition(current);
if (position.isValid) {
weekendAreas.push({
left: position.left,
width: this.timelineBounds.pixelsPerDay,
});
}
}
current.setDate(current.getDate() + 1);
}
return weekendAreas;
}
/**
* Get timeline bounds for external use
*/
getTimelineBounds(): TimelineBounds {
return { ...this.timelineBounds };
}
/**
* Update calculator with new parameters
*/
updateParameters(
viewMode: GanttViewMode,
columnWidth: number,
startDate: Date,
endDate: Date
): void {
this.viewMode = viewMode;
this.columnWidth = columnWidth;
this.timelineBounds = this.calculateTimelineBounds(startDate, endDate);
}
}
/**
* Utility functions for timeline calculations
*/
export const TimelineUtils = {
/**
* Get smart timeline date range based on project tasks
*/
getSmartDateRange(tasks: GanttTask[], viewMode: GanttViewMode): { start: Date; end: Date } {
if (!tasks.length) {
// Default to current year
const start = new Date();
start.setMonth(0, 1); // January 1st
const end = new Date();
end.setMonth(11, 31); // December 31st
return { start, end };
}
// Find earliest start date and latest end date
let earliestStart: Date | null = null;
let latestEnd: Date | null = null;
tasks.forEach(task => {
if (task.start_date) {
if (!earliestStart || task.start_date < earliestStart) {
earliestStart = task.start_date;
}
}
if (task.end_date) {
if (!latestEnd || task.end_date > latestEnd) {
latestEnd = task.end_date;
}
}
// Check subtasks too
if (task.children) {
task.children.forEach(subtask => {
if (subtask.start_date && (!earliestStart || subtask.start_date < earliestStart)) {
earliestStart = subtask.start_date;
}
if (subtask.end_date && (!latestEnd || subtask.end_date > latestEnd)) {
latestEnd = subtask.end_date;
}
});
}
});
// Add padding based on view mode
const start = earliestStart || new Date();
const end = latestEnd || new Date();
switch (viewMode) {
case 'day':
start.setDate(start.getDate() - 7); // 1 week before
end.setDate(end.getDate() + 7); // 1 week after
break;
case 'week':
start.setDate(start.getDate() - 14); // 2 weeks before
end.setDate(end.getDate() + 14); // 2 weeks after
break;
case 'month':
start.setMonth(start.getMonth() - 1); // 1 month before
end.setMonth(end.getMonth() + 1); // 1 month after
break;
case 'quarter':
start.setMonth(start.getMonth() - 3); // 1 quarter before
end.setMonth(end.getMonth() + 3); // 1 quarter after
break;
case 'year':
start.setFullYear(start.getFullYear() - 1); // 1 year before
end.setFullYear(end.getFullYear() + 1); // 1 year after
break;
}
return { start, end };
},
/**
* Format date based on view mode
*/
formatDateForViewMode(date: Date, viewMode: GanttViewMode): string {
switch (viewMode) {
case 'day':
return date.toLocaleDateString('en-US', { day: '2-digit', month: 'short' });
case 'week':
return `Week ${this.getWeekNumber(date)}`;
case 'month':
return date.toLocaleDateString('en-US', { month: 'short' });
case 'quarter':
return `Q${Math.ceil((date.getMonth() + 1) / 3)} ${date.getFullYear()}`;
case 'year':
return date.getFullYear().toString();
default:
return date.toLocaleDateString();
}
},
/**
* Get week number of the year
*/
getWeekNumber(date: Date): number {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
return Math.ceil(((d.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
},
};

View File

@@ -258,7 +258,14 @@ export interface ExportOptions {
// Filter and Search
export interface FilterConfig {
field: string;
operator: 'equals' | 'contains' | 'startsWith' | 'endsWith' | 'greaterThan' | 'lessThan' | 'between';
operator:
| 'equals'
| 'contains'
| 'startsWith'
| 'endsWith'
| 'greaterThan'
| 'lessThan'
| 'between';
value: any;
logic?: 'and' | 'or';
}

View File

@@ -2,37 +2,37 @@ import { useMemo, useCallback, useRef, useEffect } from 'react';
import { GanttTask, PerformanceMetrics } from '../types/advanced-gantt.types';
// Debounce utility for drag operations
export function useDebounce<T extends (...args: any[]) => any>(
callback: T,
delay: number
): T {
export function useDebounce<T extends (...args: any[]) => any>(callback: T, delay: number): T {
const timeoutRef = useRef<NodeJS.Timeout>();
return useCallback((...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
return useCallback(
(...args: Parameters<T>) => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
}, [callback, delay]) as T;
timeoutRef.current = setTimeout(() => {
callback(...args);
}, delay);
},
[callback, delay]
) as T;
}
// Throttle utility for scroll events
export function useThrottle<T extends (...args: any[]) => any>(
callback: T,
delay: number
): T {
export function useThrottle<T extends (...args: any[]) => any>(callback: T, delay: number): T {
const lastCall = useRef<number>(0);
return useCallback((...args: Parameters<T>) => {
const now = Date.now();
if (now - lastCall.current >= delay) {
lastCall.current = now;
callback(...args);
}
}, [callback, delay]) as T;
return useCallback(
(...args: Parameters<T>) => {
const now = Date.now();
if (now - lastCall.current >= delay) {
lastCall.current = now;
callback(...args);
}
},
[callback, delay]
) as T;
}
// Memoized task calculations
@@ -124,7 +124,9 @@ export const useTimelineVirtualScrolling = (
overscan: number = 10
): TimelineVirtualData => {
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 startDayIndex = Math.max(0, Math.floor(scrollLeft / dayWidth) - overscan);
@@ -300,17 +302,17 @@ export const useDateCalculations = () => {
};
// Task position calculations
export const useTaskPositions = (
tasks: GanttTask[],
timelineStart: Date,
dayWidth: number
) => {
export const useTaskPositions = (tasks: GanttTask[], timelineStart: Date, dayWidth: number) => {
return useMemo(() => {
const positions = new Map<string, { x: number; width: number; y: number }>();
tasks.forEach((task, index) => {
const startDays = Math.floor((task.startDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24));
const endDays = Math.floor((task.endDate.getTime() - timelineStart.getTime()) / (1000 * 60 * 60 * 24));
const startDays = Math.floor(
(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, {
x: startDays * dayWidth,
@@ -344,27 +346,27 @@ export const useMemoryManagement = () => {
};
// Batch update utility for multiple task changes
export const useBatchUpdates = <T>(
updateFunction: (updates: T[]) => void,
delay: number = 100
) => {
export const useBatchUpdates = <T>(updateFunction: (updates: T[]) => void, delay: number = 100) => {
const batchRef = useRef<T[]>([]);
const timeoutRef = useRef<NodeJS.Timeout>();
const addUpdate = useCallback((update: T) => {
batchRef.current.push(update);
const addUpdate = useCallback(
(update: T) => {
batchRef.current.push(update);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => {
if (batchRef.current.length > 0) {
updateFunction([...batchRef.current]);
batchRef.current = [];
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
}, delay);
}, [updateFunction, delay]);
timeoutRef.current = setTimeout(() => {
if (batchRef.current.length > 0) {
updateFunction([...batchRef.current]);
batchRef.current = [];
}
}, delay);
},
[updateFunction, delay]
);
const flushUpdates = useCallback(() => {
if (timeoutRef.current) {

View File

@@ -19,6 +19,12 @@ module.exports = {
'"Noto Color Emoji"',
],
},
colors: {
gray: {
750: '#2d3748',
850: '#1a202c',
},
},
},
},
plugins: [],