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.
This commit is contained in:
@@ -94,4 +94,133 @@ export default class GanttController extends WorklenzControllerBase {
|
|||||||
}
|
}
|
||||||
return res.status(200).send(new ServerResponse(true, result.rows));
|
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
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
color_code,
|
||||||
|
start_date,
|
||||||
|
end_date,
|
||||||
|
sort_index
|
||||||
|
FROM project_phases
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY sort_index, created_at;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const result = await db.query(q, [projectId]);
|
||||||
|
return res.status(200).send(new ServerResponse(true, result.rows));
|
||||||
|
}
|
||||||
|
|
||||||
|
@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]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,4 +12,9 @@ ganttApiRouter.get("/project-phases/:id", safeControllerFunction(GanttController
|
|||||||
|
|
||||||
ganttApiRouter.get("/project-workload", safeControllerFunction(GanttController.getWorkload));
|
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;
|
export default ganttApiRouter;
|
||||||
@@ -84,6 +84,7 @@ import selectionReducer from '@/features/task-management/selection.slice';
|
|||||||
import homePageApiService from '@/api/home-page/home-page.api.service';
|
import homePageApiService from '@/api/home-page/home-page.api.service';
|
||||||
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
import { projectsApi } from '@/api/projects/projects.v1.api.service';
|
||||||
import { userActivityApiService } from '@/api/home-page/user-activity.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 projectViewReducer from '@features/project/project-view-slice';
|
||||||
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
|
import taskManagementFieldsReducer from '@features/task-management/taskListFields.slice';
|
||||||
@@ -92,7 +93,7 @@ export const store = configureStore({
|
|||||||
middleware: getDefaultMiddleware =>
|
middleware: getDefaultMiddleware =>
|
||||||
getDefaultMiddleware({
|
getDefaultMiddleware({
|
||||||
serializableCheck: false,
|
serializableCheck: false,
|
||||||
}).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware),
|
}).concat(homePageApiService.middleware, projectsApi.middleware, userActivityApiService.middleware, ganttApi.middleware),
|
||||||
reducer: {
|
reducer: {
|
||||||
// Auth & User
|
// Auth & User
|
||||||
auth: authReducer,
|
auth: authReducer,
|
||||||
@@ -105,6 +106,7 @@ export const store = configureStore({
|
|||||||
homePageReducer: homePageReducer,
|
homePageReducer: homePageReducer,
|
||||||
[homePageApiService.reducerPath]: homePageApiService.reducer,
|
[homePageApiService.reducerPath]: homePageApiService.reducer,
|
||||||
[projectsApi.reducerPath]: projectsApi.reducer,
|
[projectsApi.reducerPath]: projectsApi.reducer,
|
||||||
|
[ganttApi.reducerPath]: ganttApi.reducer,
|
||||||
userActivityReducer: userActivityReducer,
|
userActivityReducer: userActivityReducer,
|
||||||
[userActivityApiService.reducerPath]: userActivityApiService.reducer,
|
[userActivityApiService.reducerPath]: userActivityApiService.reducer,
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ const ProjectViewMembers = React.lazy(
|
|||||||
const ProjectViewUpdates = React.lazy(
|
const ProjectViewUpdates = React.lazy(
|
||||||
() => import('@/pages/projects/project-view-1/updates/project-view-updates')
|
() => import('@/pages/projects/project-view-1/updates/project-view-updates')
|
||||||
);
|
);
|
||||||
|
const ProjectViewGantt = React.lazy(
|
||||||
|
() => import('@/pages/projects/projectView/gantt/project-view-gantt')
|
||||||
|
);
|
||||||
|
|
||||||
// type of a tab items
|
// type of a tab items
|
||||||
type TabItems = {
|
type TabItems = {
|
||||||
@@ -43,6 +46,7 @@ const getTabLabel = (key: string): string => {
|
|||||||
files: 'Files',
|
files: 'Files',
|
||||||
members: 'Members',
|
members: 'Members',
|
||||||
updates: 'Updates',
|
updates: 'Updates',
|
||||||
|
gantt: 'Gantt Chart',
|
||||||
};
|
};
|
||||||
return fallbacks[key] || key;
|
return fallbacks[key] || key;
|
||||||
}
|
}
|
||||||
@@ -117,6 +121,16 @@ export const tabItems: TabItems[] = [
|
|||||||
React.createElement(ProjectViewUpdates)
|
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
|
// Function to update tab labels when language changes
|
||||||
@@ -142,6 +156,9 @@ export const updateTabLabels = () => {
|
|||||||
case 'updates':
|
case 'updates':
|
||||||
item.label = getTabLabel('updates');
|
item.label = getTabLabel('updates');
|
||||||
break;
|
break;
|
||||||
|
case 'gantt':
|
||||||
|
item.label = getTabLabel('gantt');
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,248 @@
|
|||||||
|
import React, { memo, useMemo, forwardRef, RefObject } from 'react';
|
||||||
|
import { GanttTask, GanttViewMode, GanttPhase } from '../../types/gantt-types';
|
||||||
|
import { useGanttDimensions } from '../../hooks/useGanttDimensions';
|
||||||
|
|
||||||
|
interface GanttChartProps {
|
||||||
|
tasks: GanttTask[];
|
||||||
|
viewMode: GanttViewMode;
|
||||||
|
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
|
||||||
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
|
dateRange?: { start: Date; end: Date };
|
||||||
|
phases?: GanttPhase[];
|
||||||
|
expandedTasks?: Set<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 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const TaskBarRow: React.FC<TaskBarRowProps> = memo(({ task, viewMode, columnWidth, columnsCount, dateRange }) => {
|
||||||
|
const renderMilestone = () => {
|
||||||
|
if (!task.start_date || !dateRange) return null;
|
||||||
|
|
||||||
|
// Calculate position for milestone diamond
|
||||||
|
const totalDays = Math.ceil((dateRange.end.getTime() - dateRange.start.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
const daysFromStart = Math.floor((task.start_date.getTime() - dateRange.start.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
const left = Math.max(0, (daysFromStart / totalDays) * (columnsCount * columnWidth));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute top-1/2 transform -translate-y-1/2 -translate-x-1/2 w-3 h-3 rotate-45 z-10"
|
||||||
|
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
|
||||||
|
const totalDays = Math.ceil((dateRange.end.getTime() - dateRange.start.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
const daysFromStart = Math.floor((task.start_date.getTime() - dateRange.start.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
const taskDuration = Math.ceil((task.end_date.getTime() - task.start_date.getTime()) / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
const left = Math.max(0, (daysFromStart / totalDays) * (columnsCount * columnWidth));
|
||||||
|
const width = Math.max(10, (taskDuration / totalDays) * (columnsCount * columnWidth));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`h-9 relative border-b border-gray-100 dark:border-gray-700 transition-colors ${
|
||||||
|
task.type === 'milestone'
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30'
|
||||||
|
: 'hover:bg-gray-50 dark:hover:bg-gray-750'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{task.type === 'milestone' || task.is_milestone ? renderMilestone() : renderTaskBar()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TaskBarRow.displayName = 'TaskBarRow';
|
||||||
|
|
||||||
|
const GanttChart = forwardRef<HTMLDivElement, GanttChartProps>(({ tasks, viewMode, onScroll, containerRef, dateRange, phases, expandedTasks }, ref) => {
|
||||||
|
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));
|
||||||
|
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'day':
|
||||||
|
return diffDays;
|
||||||
|
case 'week':
|
||||||
|
return Math.ceil(diffDays / 7);
|
||||||
|
case 'month':
|
||||||
|
const startYear = start.getFullYear();
|
||||||
|
const startMonth = start.getMonth();
|
||||||
|
const endYear = end.getFullYear();
|
||||||
|
const endMonth = end.getMonth();
|
||||||
|
return (endYear - startYear) * 12 + (endMonth - startMonth) + 1;
|
||||||
|
case 'quarter':
|
||||||
|
const qStartYear = start.getFullYear();
|
||||||
|
const qStartQuarter = Math.ceil((start.getMonth() + 1) / 3);
|
||||||
|
const qEndYear = end.getFullYear();
|
||||||
|
const qEndQuarter = Math.ceil((end.getMonth() + 1) / 3);
|
||||||
|
return (qEndYear - qStartYear) * 4 + (qEndQuarter - qStartQuarter) + 1;
|
||||||
|
case 'year':
|
||||||
|
return end.getFullYear() - start.getFullYear() + 1;
|
||||||
|
default:
|
||||||
|
return 12;
|
||||||
|
}
|
||||||
|
}, [viewMode, dateRange]);
|
||||||
|
|
||||||
|
const { actualColumnWidth, totalWidth, shouldScroll } = useGanttDimensions(
|
||||||
|
viewMode,
|
||||||
|
containerRef,
|
||||||
|
columnsCount
|
||||||
|
);
|
||||||
|
|
||||||
|
const gridColumns = useMemo(() =>
|
||||||
|
Array.from({ length: columnsCount }).map((_, index) => index)
|
||||||
|
, [columnsCount]);
|
||||||
|
|
||||||
|
// 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 }> = [];
|
||||||
|
|
||||||
|
const processTask = (task: GanttTask) => {
|
||||||
|
result.push(task);
|
||||||
|
|
||||||
|
// Check if this is an expanded phase with no children
|
||||||
|
const isPhase = task.type === 'milestone' || task.is_milestone;
|
||||||
|
const isEmpty = isPhase && (!task.children || task.children.length === 0);
|
||||||
|
const isExpanded = expandedTasks ? expandedTasks.has(task.id) : (task.expanded !== false);
|
||||||
|
|
||||||
|
if (isEmpty && isExpanded) {
|
||||||
|
// Add an empty row for the "Add Task" button
|
||||||
|
result.push({ id: `${task.id}-empty`, isEmptyRow: true });
|
||||||
|
} else if (task.children && isExpanded) {
|
||||||
|
task.children.forEach(child => processTask(child));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
tasks.forEach(processTask);
|
||||||
|
return result;
|
||||||
|
}, [tasks, expandedTasks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
{flattenedTasks.map(item => {
|
||||||
|
if ('isEmptyRow' in item && item.isEmptyRow) {
|
||||||
|
// Render empty row for "Add Task" placeholder
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={item.id}
|
||||||
|
className="h-9 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<TaskBarRow
|
||||||
|
key={item.id}
|
||||||
|
task={item as GanttTask}
|
||||||
|
viewMode={viewMode}
|
||||||
|
columnWidth={actualColumnWidth}
|
||||||
|
columnsCount={columnsCount}
|
||||||
|
dateRange={dateRange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{flattenedTasks.length === 0 && (
|
||||||
|
<div className="flex items-center justify-center h-64 text-gray-400 dark:text-gray-500">
|
||||||
|
No tasks to display
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
GanttChart.displayName = 'GanttChart';
|
||||||
|
|
||||||
|
export default memo(GanttChart);
|
||||||
@@ -0,0 +1,565 @@
|
|||||||
|
import React, { memo, useCallback, useState, forwardRef } from 'react';
|
||||||
|
import { RightOutlined, DownOutlined, StarOutlined, PlusOutlined, HolderOutlined } from '@ant-design/icons';
|
||||||
|
import { Button, Tooltip, Input } from 'antd';
|
||||||
|
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 } 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';
|
||||||
|
|
||||||
|
interface GanttTaskListProps {
|
||||||
|
tasks: GanttTask[];
|
||||||
|
projectId: string;
|
||||||
|
onTaskToggle?: (taskId: string) => void;
|
||||||
|
onCreateTask?: (phaseId?: string) => void;
|
||||||
|
onCreateQuickTask?: (taskName: string, phaseId?: string) => void;
|
||||||
|
onPhaseReorder?: (oldIndex: number, newIndex: number) => void;
|
||||||
|
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
|
||||||
|
expandedTasks?: Set<string>;
|
||||||
|
onExpandedTasksChange?: (expanded: Set<string>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TaskRowProps {
|
||||||
|
task: GanttTask;
|
||||||
|
index: number;
|
||||||
|
projectId: string;
|
||||||
|
onToggle?: (taskId: string) => void;
|
||||||
|
expandedTasks: Set<string>;
|
||||||
|
onCreateTask?: (phaseId?: string) => void;
|
||||||
|
onCreateQuickTask?: (taskName: string, phaseId?: string) => void;
|
||||||
|
isDraggable?: boolean;
|
||||||
|
activeId?: string | null;
|
||||||
|
overId?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
expandedTasks,
|
||||||
|
onCreateTask,
|
||||||
|
onCreateQuickTask,
|
||||||
|
isDraggable = false,
|
||||||
|
dragAttributes,
|
||||||
|
dragListeners
|
||||||
|
}) => {
|
||||||
|
const [showInlineInput, setShowInlineInput] = useState(false);
|
||||||
|
const [taskName, setTaskName] = useState('');
|
||||||
|
const { socket, connected } = useSocket();
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
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 hasChildren = task.children && task.children.length > 0;
|
||||||
|
const isExpanded = expandedTasks.has(task.id);
|
||||||
|
const indentLevel = (task.level || 0) * 20;
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
if (hasChildren && onToggle) {
|
||||||
|
onToggle(task.id);
|
||||||
|
}
|
||||||
|
}, [hasChildren, onToggle, task.id]);
|
||||||
|
|
||||||
|
const getTaskIcon = () => {
|
||||||
|
if (task.type === 'milestone' || task.is_milestone) {
|
||||||
|
return <StarOutlined className="text-blue-500 text-xs" />;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getExpandIcon = () => {
|
||||||
|
// For empty phases, show expand icon to allow adding tasks
|
||||||
|
if (isEmpty || hasChildren) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
className="w-4 h-4 flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
|
||||||
|
>
|
||||||
|
{isExpanded ? (
|
||||||
|
<DownOutlined className="text-xs" />
|
||||||
|
) : (
|
||||||
|
<RightOutlined className="text-xs" />
|
||||||
|
)}
|
||||||
|
</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 isPhase = task.type === 'milestone' || task.is_milestone;
|
||||||
|
const isEmpty = isPhase && (!task.children || task.children.length === 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className={`group flex h-9 border-b border-gray-100 dark:border-gray-700 transition-colors ${
|
||||||
|
task.type === 'milestone'
|
||||||
|
? 'bg-blue-50 dark:bg-blue-900/20 hover:bg-blue-100 dark:hover:bg-blue-900/30'
|
||||||
|
: 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750'
|
||||||
|
} ${isDraggable && !isPhase ? 'cursor-grab active:cursor-grabbing' : ''} ${
|
||||||
|
activeId === task.id ? 'opacity-50' : ''
|
||||||
|
} ${overId === task.id && overId !== activeId ? 'ring-2 ring-blue-500 ring-inset' : ''}`}
|
||||||
|
{...(!isPhase && isDraggable ? dragAttributes : {})}
|
||||||
|
{...(!isPhase && isDraggable ? dragListeners : {})}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-[420px] px-2 py-2 text-sm text-gray-800 dark:text-gray-200 flex items-center justify-between"
|
||||||
|
style={{ paddingLeft: `${8 + indentLevel}px` }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 truncate">
|
||||||
|
{/* 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-white/50"
|
||||||
|
title="Drag to reorder phase"
|
||||||
|
>
|
||||||
|
<HolderOutlined className="text-xs" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{getExpandIcon()}
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 ml-1 truncate">
|
||||||
|
{getTaskIcon()}
|
||||||
|
<span className={`truncate ${task.type === 'milestone' ? 'font-semibold text-blue-700 dark:text-blue-300' : ''}`}>
|
||||||
|
{task.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Add Task button for phases */}
|
||||||
|
{isPhase && onCreateTask && (
|
||||||
|
<Tooltip title="Add task to this phase">
|
||||||
|
<Button
|
||||||
|
type="text"
|
||||||
|
size="small"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleCreateTask}
|
||||||
|
className="opacity-0 group-hover:opacity-100 transition-opacity h-6 w-6 flex items-center justify-center hover:bg-white/50 dark:hover:bg-gray-600"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-[64px] px-2 py-2 text-xs text-center text-gray-600 dark:text-gray-400 flex-shrink-0 border-l border-gray-100 dark:border-gray-700">
|
||||||
|
{formatDateRange()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Inline task creation for empty expanded phases */}
|
||||||
|
{isEmpty && isExpanded && (
|
||||||
|
<div className="flex h-9 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||||
|
<div
|
||||||
|
className="w-[420px] 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"
|
||||||
|
>
|
||||||
|
Add Task
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="w-[64px] px-2 py-2 text-xs text-center text-gray-600 dark:text-gray-400 border-l border-gray-100 dark:border-gray-700">
|
||||||
|
{/* Empty duration column */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TaskRow.displayName = 'TaskRow';
|
||||||
|
|
||||||
|
const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({
|
||||||
|
tasks,
|
||||||
|
projectId,
|
||||||
|
onTaskToggle,
|
||||||
|
onCreateTask,
|
||||||
|
onCreateQuickTask,
|
||||||
|
onPhaseReorder,
|
||||||
|
onScroll,
|
||||||
|
expandedTasks: expandedTasksProp,
|
||||||
|
onExpandedTasksChange
|
||||||
|
}, ref) => {
|
||||||
|
const [localExpandedTasks, setLocalExpandedTasks] = useState<Set<string>>(
|
||||||
|
() => new Set(tasks.filter(t => t.expanded).map(t => t.id))
|
||||||
|
);
|
||||||
|
|
||||||
|
const expandedTasks = expandedTasksProp || localExpandedTasks;
|
||||||
|
|
||||||
|
// 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 processTask = (task: GanttTask) => {
|
||||||
|
result.push(task);
|
||||||
|
|
||||||
|
if (task.children && expandedTasks.has(task.id)) {
|
||||||
|
task.children.forEach(child => processTask(child));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
taskList.forEach(processTask);
|
||||||
|
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));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[508px] min-w-[508px] max-w-[508px] h-full flex flex-col bg-gray-50 dark:bg-gray-900 gantt-task-list-container">
|
||||||
|
<div className="flex h-10 border-b border-gray-200 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 font-medium text-sm flex-shrink-0">
|
||||||
|
<div className="w-[420px] px-4 py-2.5 text-gray-700 dark:text-gray-300">
|
||||||
|
Task Name
|
||||||
|
</div>
|
||||||
|
<div className="w-[64px] px-2 py-2.5 text-center text-gray-700 dark:text-gray-300 text-xs border-l border-gray-200 dark:border-gray-700">
|
||||||
|
Duration
|
||||||
|
</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';
|
||||||
|
|
||||||
|
if (isPhase && !isUnmappedPhase) {
|
||||||
|
return (
|
||||||
|
<SortableTaskRow
|
||||||
|
key={task.id}
|
||||||
|
id={task.id}
|
||||||
|
task={task}
|
||||||
|
index={index}
|
||||||
|
projectId={projectId}
|
||||||
|
onToggle={handleTaskToggle}
|
||||||
|
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}
|
||||||
|
expandedTasks={expandedTasks}
|
||||||
|
onCreateTask={onCreateTask}
|
||||||
|
onCreateQuickTask={onCreateQuickTask}
|
||||||
|
isDraggable={false}
|
||||||
|
activeId={activeId}
|
||||||
|
overId={overId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Regular tasks - make them draggable too
|
||||||
|
return (
|
||||||
|
<SortableTaskRow
|
||||||
|
key={task.id}
|
||||||
|
id={task.id}
|
||||||
|
task={task}
|
||||||
|
index={index}
|
||||||
|
projectId={projectId}
|
||||||
|
onToggle={handleTaskToggle}
|
||||||
|
expandedTasks={expandedTasks}
|
||||||
|
onCreateTask={onCreateTask}
|
||||||
|
onCreateQuickTask={onCreateQuickTask}
|
||||||
|
activeId={activeId}
|
||||||
|
overId={overId}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
GanttTaskList.displayName = 'GanttTaskList';
|
||||||
|
|
||||||
|
export default memo(GanttTaskList);
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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 headers = useMemo(() => {
|
||||||
|
// Generate timeline headers based on view mode and date range
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
if (!dateRange) {
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, end } = dateRange;
|
||||||
|
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'month':
|
||||||
|
// Generate month headers based on date range
|
||||||
|
const startYear = start.getFullYear();
|
||||||
|
const startMonth = start.getMonth();
|
||||||
|
const endYear = end.getFullYear();
|
||||||
|
const endMonth = end.getMonth();
|
||||||
|
|
||||||
|
let currentYear = startYear;
|
||||||
|
let currentMonth = startMonth;
|
||||||
|
|
||||||
|
while (currentYear < endYear || (currentYear === endYear && currentMonth <= endMonth)) {
|
||||||
|
const date = new Date(currentYear, currentMonth, 1);
|
||||||
|
result.push({
|
||||||
|
label: date.toLocaleDateString('en-US', { month: 'short', year: currentYear !== new Date().getFullYear() ? 'numeric' : undefined }),
|
||||||
|
key: `month-${currentYear}-${currentMonth}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentMonth++;
|
||||||
|
if (currentMonth > 11) {
|
||||||
|
currentMonth = 0;
|
||||||
|
currentYear++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'week':
|
||||||
|
// Generate week headers based on date range
|
||||||
|
const weekStart = new Date(start);
|
||||||
|
const weekEnd = new Date(end);
|
||||||
|
|
||||||
|
// Align to start of week
|
||||||
|
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
||||||
|
|
||||||
|
while (weekStart <= weekEnd) {
|
||||||
|
const weekNum = TimelineUtils.getWeekNumber(weekStart);
|
||||||
|
result.push({
|
||||||
|
label: `Week ${weekNum}`,
|
||||||
|
key: `week-${weekStart.getFullYear()}-${weekNum}`,
|
||||||
|
});
|
||||||
|
weekStart.setDate(weekStart.getDate() + 7);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'day':
|
||||||
|
// Generate day headers based on date range
|
||||||
|
const dayStart = new Date(start);
|
||||||
|
const dayEnd = new Date(end);
|
||||||
|
|
||||||
|
while (dayStart <= dayEnd) {
|
||||||
|
result.push({
|
||||||
|
label: dayStart.toLocaleDateString('en-US', { day: '2-digit', month: 'short' }),
|
||||||
|
key: `day-${dayStart.getFullYear()}-${dayStart.getMonth()}-${dayStart.getDate()}`,
|
||||||
|
});
|
||||||
|
dayStart.setDate(dayStart.getDate() + 1);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'quarter':
|
||||||
|
// Generate quarter headers based on date range
|
||||||
|
const qStartYear = start.getFullYear();
|
||||||
|
const qStartQuarter = Math.ceil((start.getMonth() + 1) / 3);
|
||||||
|
const qEndYear = end.getFullYear();
|
||||||
|
const qEndQuarter = Math.ceil((end.getMonth() + 1) / 3);
|
||||||
|
|
||||||
|
let qYear = qStartYear;
|
||||||
|
let qQuarter = qStartQuarter;
|
||||||
|
|
||||||
|
while (qYear < qEndYear || (qYear === qEndYear && qQuarter <= qEndQuarter)) {
|
||||||
|
result.push({
|
||||||
|
label: `Q${qQuarter} ${qYear}`,
|
||||||
|
key: `quarter-${qYear}-${qQuarter}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
qQuarter++;
|
||||||
|
if (qQuarter > 4) {
|
||||||
|
qQuarter = 1;
|
||||||
|
qYear++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
// Generate year headers based on date range
|
||||||
|
const yearStart = start.getFullYear();
|
||||||
|
const yearEnd = end.getFullYear();
|
||||||
|
|
||||||
|
for (let year = yearStart; year <= yearEnd; year++) {
|
||||||
|
result.push({
|
||||||
|
label: `${year}`,
|
||||||
|
key: `year-${year}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [viewMode, dateRange]);
|
||||||
|
|
||||||
|
const { actualColumnWidth, totalWidth, shouldScroll } = useGanttDimensions(
|
||||||
|
viewMode,
|
||||||
|
containerRef,
|
||||||
|
headers.length
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={`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`}
|
||||||
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||||
|
>
|
||||||
|
<div className="flex h-full" style={{ width: `${totalWidth}px`, minWidth: shouldScroll ? 'auto' : '100%' }}>
|
||||||
|
{headers.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);
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import React, { memo } from 'react';
|
||||||
|
import { Select, Button, Space, Divider } from 'antd';
|
||||||
|
import { ZoomInOutlined, ZoomOutOutlined, FullscreenOutlined, PlusOutlined, FlagOutlined } 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 };
|
||||||
|
onCreatePhase?: () => void;
|
||||||
|
onCreateTask?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const GanttToolbar: React.FC<GanttToolbarProps> = memo(({ viewMode, onViewModeChange, dateRange, onCreatePhase, onCreateTask }) => {
|
||||||
|
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>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
icon={<FlagOutlined />}
|
||||||
|
onClick={onCreatePhase}
|
||||||
|
className="bg-blue-600 hover:bg-blue-700 border-blue-600"
|
||||||
|
>
|
||||||
|
Manage Phases
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={onCreateTask}
|
||||||
|
className="hover:text-blue-600 dark:hover:text-blue-400 hover:border-blue-600"
|
||||||
|
>
|
||||||
|
Add Task
|
||||||
|
</Button>
|
||||||
|
<Divider type="vertical" className="bg-gray-300 dark:bg-gray-600" />
|
||||||
|
<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"
|
||||||
|
className="hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<ZoomOutOutlined />}
|
||||||
|
title="Zoom Out"
|
||||||
|
className="hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
icon={<FullscreenOutlined />}
|
||||||
|
title="Fullscreen"
|
||||||
|
className="hover:text-blue-600 dark:hover:text-blue-400"
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
GanttToolbar.displayName = 'GanttToolbar';
|
||||||
|
|
||||||
|
export default GanttToolbar;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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;
|
||||||
|
};
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
/* 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 */
|
||||||
|
.gantt-task-list-scroll {
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
scrollbar-width: thin; /* Firefox */
|
||||||
|
scrollbar-gutter: stable; /* Prevent layout shift */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Webkit scrollbar styling */
|
||||||
|
.gantt-task-list-scroll::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-task-list-scroll::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-task-list-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #cbd5e0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-task-list-scroll::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #a0aec0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Firefox scrollbar color */
|
||||||
|
.gantt-task-list-scroll {
|
||||||
|
scrollbar-color: #cbd5e0 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode scrollbar styles */
|
||||||
|
.dark .gantt-task-list-scroll {
|
||||||
|
scrollbar-color: #4a5568 transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .gantt-task-list-scroll::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #4a5568;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .gantt-task-list-scroll::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #718096;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Gantt chart scrollbar - show vertical only */
|
||||||
|
.gantt-chart-scroll::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide horizontal scrollbar specifically */
|
||||||
|
.gantt-chart-scroll::-webkit-scrollbar:horizontal {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,245 @@
|
|||||||
|
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
||||||
|
import { Spin, message } from 'antd';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import GanttTimeline from './components/gantt-timeline/gantt-timeline';
|
||||||
|
import GanttTaskList from './components/gantt-task-list/gantt-task-list';
|
||||||
|
import GanttChart from './components/gantt-chart/gantt-chart';
|
||||||
|
import GanttToolbar from './components/gantt-toolbar/gantt-toolbar';
|
||||||
|
import ManagePhaseModal from '../../../../components/task-management/ManagePhaseModal';
|
||||||
|
import { GanttProvider } from './context/gantt-context';
|
||||||
|
import { GanttTask, GanttViewMode, GanttPhase } from './types/gantt-types';
|
||||||
|
import { useGetRoadmapTasksQuery, useGetProjectPhasesQuery, transformToGanttTasks, transformToGanttPhases } from './services/gantt-api.service';
|
||||||
|
import { TimelineUtils } from './utils/timeline-calculator';
|
||||||
|
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
|
||||||
|
import { setShowTaskDrawer, setSelectedTaskId, setTaskFormViewModel } from '../../../../features/task-drawer/task-drawer.slice';
|
||||||
|
import { DEFAULT_TASK_NAME } from '../../../../shared/constants';
|
||||||
|
import './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 [expandedTasks, setExpandedTasks] = 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);
|
||||||
|
// Initialize expanded state for all phases
|
||||||
|
const expanded = new Set<string>();
|
||||||
|
transformedTasks.forEach(task => {
|
||||||
|
if ((task.type === 'milestone' || task.is_milestone) && task.expanded !== false) {
|
||||||
|
expanded.add(task.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
setExpandedTasks(expanded);
|
||||||
|
return transformedTasks;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [tasksResponse, phasesResponse]);
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
const handleViewModeChange = useCallback((mode: GanttViewMode) => {
|
||||||
|
setViewMode(mode);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const [isScrolling, setIsScrolling] = useState(false);
|
||||||
|
|
||||||
|
const handleChartScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
if (isScrolling) return;
|
||||||
|
setIsScrolling(true);
|
||||||
|
|
||||||
|
const target = e.target as HTMLDivElement;
|
||||||
|
|
||||||
|
// Sync horizontal scroll with timeline
|
||||||
|
if (timelineRef.current) {
|
||||||
|
timelineRef.current.scrollLeft = target.scrollLeft;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync vertical scroll with task list
|
||||||
|
if (taskListRef.current) {
|
||||||
|
taskListRef.current.scrollTop = target.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => setIsScrolling(false), 10);
|
||||||
|
}, [isScrolling]);
|
||||||
|
|
||||||
|
const handleTaskListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
|
if (isScrolling) return;
|
||||||
|
setIsScrolling(true);
|
||||||
|
|
||||||
|
const target = e.target as HTMLDivElement;
|
||||||
|
|
||||||
|
// Sync vertical scroll with chart
|
||||||
|
if (chartRef.current) {
|
||||||
|
chartRef.current.scrollTop = target.scrollTop;
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => setIsScrolling(false), 10);
|
||||||
|
}, [isScrolling]);
|
||||||
|
|
||||||
|
const handleRefresh = useCallback(() => {
|
||||||
|
refetchTasks();
|
||||||
|
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 handleClosePhaseModal = useCallback(() => {
|
||||||
|
setShowPhaseModal(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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) => {
|
||||||
|
// Refresh the Gantt data after task creation
|
||||||
|
refetchTasks();
|
||||||
|
message.success(`Task "${taskName}" created successfully!`);
|
||||||
|
}, [refetchTasks]);
|
||||||
|
|
||||||
|
// 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}
|
||||||
|
onCreatePhase={handleCreatePhase}
|
||||||
|
onCreateTask={handleCreateTask}
|
||||||
|
/>
|
||||||
|
<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 || ''}
|
||||||
|
onCreateTask={handleCreateTask}
|
||||||
|
onCreateQuickTask={handleCreateQuickTask}
|
||||||
|
onPhaseReorder={handlePhaseReorder}
|
||||||
|
ref={taskListRef}
|
||||||
|
onScroll={handleTaskListScroll}
|
||||||
|
expandedTasks={expandedTasks}
|
||||||
|
onExpandedTasksChange={setExpandedTasks}
|
||||||
|
/>
|
||||||
|
</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: '508px' }} ref={containerRef}>
|
||||||
|
<GanttTimeline
|
||||||
|
viewMode={viewMode}
|
||||||
|
ref={timelineRef}
|
||||||
|
containerRef={containerRef}
|
||||||
|
dateRange={dateRange}
|
||||||
|
/>
|
||||||
|
<GanttChart
|
||||||
|
tasks={tasks}
|
||||||
|
viewMode={viewMode}
|
||||||
|
ref={chartRef}
|
||||||
|
onScroll={handleChartScroll}
|
||||||
|
containerRef={containerRef}
|
||||||
|
dateRange={dateRange}
|
||||||
|
phases={phases}
|
||||||
|
expandedTasks={expandedTasks}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Phase Management Modal */}
|
||||||
|
<ManagePhaseModal
|
||||||
|
open={showPhaseModal}
|
||||||
|
onClose={handleClosePhaseModal}
|
||||||
|
projectId={projectId}
|
||||||
|
/>
|
||||||
|
</GanttProvider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ProjectViewGantt.displayName = 'ProjectViewGantt';
|
||||||
|
|
||||||
|
export default ProjectViewGantt;
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
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;
|
||||||
|
phase_id: string | null;
|
||||||
|
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;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectPhaseResponse {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color_code: string;
|
||||||
|
start_date: string | null;
|
||||||
|
end_date: string | null;
|
||||||
|
sort_index: 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 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' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const {
|
||||||
|
useGetRoadmapTasksQuery,
|
||||||
|
useGetProjectPhasesQuery,
|
||||||
|
useUpdateTaskDatesMutation,
|
||||||
|
useCreatePhaseMutation,
|
||||||
|
useCreateTaskMutation,
|
||||||
|
} = 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 => {
|
||||||
|
if (task.phase_id) {
|
||||||
|
if (!tasksByPhase.has(task.phase_id)) {
|
||||||
|
tasksByPhase.set(task.phase_id, []);
|
||||||
|
}
|
||||||
|
tasksByPhase.get(task.phase_id)!.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,
|
||||||
|
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 => ({
|
||||||
|
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
|
||||||
|
})),
|
||||||
|
level,
|
||||||
|
expanded: true,
|
||||||
|
color: task.status_color || task.priority_color,
|
||||||
|
assignees: task.assignees.map(a => a.assignee_name),
|
||||||
|
priority: task.priority_name,
|
||||||
|
status: task.status_name,
|
||||||
|
phase_id: task.phase_id,
|
||||||
|
is_milestone: false,
|
||||||
|
type: 'task'
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Transform API response to Gantt phases format
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
}));
|
||||||
|
};
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,321 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -19,6 +19,12 @@ module.exports = {
|
|||||||
'"Noto Color Emoji"',
|
'"Noto Color Emoji"',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
colors: {
|
||||||
|
gray: {
|
||||||
|
750: '#2d3748',
|
||||||
|
850: '#1a202c',
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: [],
|
plugins: [],
|
||||||
|
|||||||
Reference in New Issue
Block a user