refactor(gantt): restructure Gantt components and update styles
- Deleted obsolete Gantt components including GanttChart, GanttTaskList, GanttTimeline, GanttToolbar, and their associated styles. - Renamed ProjectViewGantt component for consistency in naming conventions. - Updated CSS for task list scrollbar to hide the scrollbar and improve visual aesthetics. - Introduced new styles for consistent row heights, improved hover states, and enhanced button interactions. - Added functionality for phase management and task animations to enhance user experience.
This commit is contained in:
@@ -20,7 +20,7 @@ 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(
|
const ProjectViewGantt = React.lazy(
|
||||||
() => import('@/pages/projects/projectView/gantt/project-view-gantt')
|
() => import('@/pages/projects/projectView/gantt/ProjectViewGantt')
|
||||||
);
|
);
|
||||||
|
|
||||||
// type of a tab items
|
// type of a tab items
|
||||||
|
|||||||
@@ -1,18 +1,27 @@
|
|||||||
import React, { useState, useCallback, useRef, useMemo, useEffect } from 'react';
|
import React, { useState, useCallback, useRef, useMemo } from 'react';
|
||||||
import { Spin, message } from 'antd';
|
import { Spin, message } from '@/shared/antd-imports';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import GanttTimeline from './components/gantt-timeline/gantt-timeline';
|
import GanttTimeline from './components/gantt-timeline/GanttTimeline';
|
||||||
import GanttTaskList from './components/gantt-task-list/gantt-task-list';
|
import GanttTaskList from './components/gantt-task-list/GanttTaskList';
|
||||||
import GanttChart from './components/gantt-chart/gantt-chart';
|
import GanttChart from './components/gantt-chart/GanttChart';
|
||||||
import GanttToolbar from './components/gantt-toolbar/gantt-toolbar';
|
import GanttToolbar from './components/gantt-toolbar/GanttToolbar';
|
||||||
import ManagePhaseModal from '../../../../components/task-management/ManagePhaseModal';
|
import ManagePhaseModal from '@components/task-management/ManagePhaseModal';
|
||||||
import { GanttProvider } from './context/gantt-context';
|
import { GanttProvider } from './context/gantt-context';
|
||||||
import { GanttTask, GanttViewMode, GanttPhase } from './types/gantt-types';
|
import { GanttViewMode } from './types/gantt-types';
|
||||||
import { useGetRoadmapTasksQuery, useGetProjectPhasesQuery, transformToGanttTasks, transformToGanttPhases } from './services/gantt-api.service';
|
import {
|
||||||
|
useGetRoadmapTasksQuery,
|
||||||
|
useGetProjectPhasesQuery,
|
||||||
|
transformToGanttTasks,
|
||||||
|
transformToGanttPhases,
|
||||||
|
} from './services/gantt-api.service';
|
||||||
import { TimelineUtils } from './utils/timeline-calculator';
|
import { TimelineUtils } from './utils/timeline-calculator';
|
||||||
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { setShowTaskDrawer, setSelectedTaskId, setTaskFormViewModel } from '../../../../features/task-drawer/task-drawer.slice';
|
import {
|
||||||
import { DEFAULT_TASK_NAME } from '../../../../shared/constants';
|
setShowTaskDrawer,
|
||||||
|
setSelectedTaskId,
|
||||||
|
setTaskFormViewModel,
|
||||||
|
} from '@features/task-drawer/task-drawer.slice';
|
||||||
|
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
||||||
import './gantt-styles.css';
|
import './gantt-styles.css';
|
||||||
|
|
||||||
const ProjectViewGantt: React.FC = React.memo(() => {
|
const ProjectViewGantt: React.FC = React.memo(() => {
|
||||||
@@ -31,38 +40,44 @@ const ProjectViewGantt: React.FC = React.memo(() => {
|
|||||||
data: tasksResponse,
|
data: tasksResponse,
|
||||||
error: tasksError,
|
error: tasksError,
|
||||||
isLoading: tasksLoading,
|
isLoading: tasksLoading,
|
||||||
refetch: refetchTasks
|
refetch: refetchTasks,
|
||||||
} = useGetRoadmapTasksQuery(
|
} = useGetRoadmapTasksQuery({ projectId: projectId || '' }, { skip: !projectId });
|
||||||
{ projectId: projectId || '' },
|
|
||||||
{ skip: !projectId }
|
|
||||||
);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: phasesResponse,
|
data: phasesResponse,
|
||||||
error: phasesError,
|
error: phasesError,
|
||||||
isLoading: phasesLoading,
|
isLoading: phasesLoading,
|
||||||
refetch: refetchPhases
|
refetch: refetchPhases,
|
||||||
} = useGetProjectPhasesQuery(
|
} = useGetProjectPhasesQuery({ projectId: projectId || '' }, { skip: !projectId });
|
||||||
{ projectId: projectId || '' },
|
|
||||||
{ skip: !projectId }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Transform API data to component format
|
// Transform API data to component format
|
||||||
const tasks = useMemo(() => {
|
const tasks = useMemo(() => {
|
||||||
if (tasksResponse?.body && phasesResponse?.body) {
|
if (tasksResponse?.body && phasesResponse?.body) {
|
||||||
const transformedTasks = transformToGanttTasks(tasksResponse.body, phasesResponse.body);
|
const transformedTasks = transformToGanttTasks(tasksResponse.body, phasesResponse.body);
|
||||||
// Initialize expanded state for all phases
|
const result: any[] = [];
|
||||||
const expanded = new Set<string>();
|
|
||||||
transformedTasks.forEach(task => {
|
transformedTasks.forEach(task => {
|
||||||
if ((task.type === 'milestone' || task.is_milestone) && task.expanded !== false) {
|
// Always show phase milestones
|
||||||
expanded.add(task.id);
|
if (task.type === 'milestone' || task.is_milestone) {
|
||||||
|
result.push(task);
|
||||||
|
|
||||||
|
// If this phase is expanded, show its children tasks
|
||||||
|
const phaseId = task.id === 'phase-unmapped' ? 'unmapped' : task.phase_id;
|
||||||
|
if (expandedTasks.has(phaseId) && task.children) {
|
||||||
|
task.children.forEach((child: any) => {
|
||||||
|
result.push({
|
||||||
|
...child,
|
||||||
|
phase_id: task.phase_id // Ensure child has correct phase_id
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
setExpandedTasks(expanded);
|
|
||||||
return transformedTasks;
|
return result;
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
}, [tasksResponse, phasesResponse]);
|
}, [tasksResponse, phasesResponse, expandedTasks]);
|
||||||
|
|
||||||
const phases = useMemo(() => {
|
const phases = useMemo(() => {
|
||||||
if (phasesResponse?.body) {
|
if (phasesResponse?.body) {
|
||||||
@@ -85,12 +100,7 @@ const ProjectViewGantt: React.FC = React.memo(() => {
|
|||||||
setViewMode(mode);
|
setViewMode(mode);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const [isScrolling, setIsScrolling] = useState(false);
|
|
||||||
|
|
||||||
const handleChartScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
const handleChartScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
if (isScrolling) return;
|
|
||||||
setIsScrolling(true);
|
|
||||||
|
|
||||||
const target = e.target as HTMLDivElement;
|
const target = e.target as HTMLDivElement;
|
||||||
|
|
||||||
// Sync horizontal scroll with timeline
|
// Sync horizontal scroll with timeline
|
||||||
@@ -102,23 +112,16 @@ const ProjectViewGantt: React.FC = React.memo(() => {
|
|||||||
if (taskListRef.current) {
|
if (taskListRef.current) {
|
||||||
taskListRef.current.scrollTop = target.scrollTop;
|
taskListRef.current.scrollTop = target.scrollTop;
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
setTimeout(() => setIsScrolling(false), 10);
|
|
||||||
}, [isScrolling]);
|
|
||||||
|
|
||||||
const handleTaskListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
const handleTaskListScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||||
if (isScrolling) return;
|
|
||||||
setIsScrolling(true);
|
|
||||||
|
|
||||||
const target = e.target as HTMLDivElement;
|
const target = e.target as HTMLDivElement;
|
||||||
|
|
||||||
// Sync vertical scroll with chart
|
// Sync vertical scroll with chart
|
||||||
if (chartRef.current) {
|
if (chartRef.current) {
|
||||||
chartRef.current.scrollTop = target.scrollTop;
|
chartRef.current.scrollTop = target.scrollTop;
|
||||||
}
|
}
|
||||||
|
}, []);
|
||||||
setTimeout(() => setIsScrolling(false), 10);
|
|
||||||
}, [isScrolling]);
|
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
refetchTasks();
|
refetchTasks();
|
||||||
@@ -129,20 +132,33 @@ const ProjectViewGantt: React.FC = React.memo(() => {
|
|||||||
setShowPhaseModal(true);
|
setShowPhaseModal(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCreateTask = useCallback((phaseId?: string) => {
|
const handleCreateTask = useCallback(
|
||||||
// Create a new task using the task drawer
|
(phaseId?: string) => {
|
||||||
const newTaskViewModel = {
|
// Create a new task using the task drawer
|
||||||
id: null,
|
const newTaskViewModel = {
|
||||||
name: DEFAULT_TASK_NAME,
|
id: null,
|
||||||
project_id: projectId,
|
name: DEFAULT_TASK_NAME,
|
||||||
phase_id: phaseId || null,
|
project_id: projectId,
|
||||||
// Add other default properties as needed
|
phase_id: phaseId || null,
|
||||||
};
|
// Add other default properties as needed
|
||||||
|
};
|
||||||
|
|
||||||
dispatch(setSelectedTaskId(null));
|
dispatch(setSelectedTaskId(null));
|
||||||
dispatch(setTaskFormViewModel(newTaskViewModel));
|
dispatch(setTaskFormViewModel(newTaskViewModel));
|
||||||
dispatch(setShowTaskDrawer(true));
|
dispatch(setShowTaskDrawer(true));
|
||||||
}, [dispatch, projectId]);
|
},
|
||||||
|
[dispatch, projectId]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTaskClick = useCallback(
|
||||||
|
(taskId: string) => {
|
||||||
|
// Open existing task in the task drawer
|
||||||
|
dispatch(setSelectedTaskId(taskId));
|
||||||
|
dispatch(setTaskFormViewModel(null)); // Clear form view model for existing task
|
||||||
|
dispatch(setShowTaskDrawer(true));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
const handleClosePhaseModal = useCallback(() => {
|
const handleClosePhaseModal = useCallback(() => {
|
||||||
setShowPhaseModal(false);
|
setShowPhaseModal(false);
|
||||||
@@ -154,11 +170,15 @@ const ProjectViewGantt: React.FC = React.memo(() => {
|
|||||||
message.info('Phase reordering will be implemented with the backend API');
|
message.info('Phase reordering will be implemented with the backend API');
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleCreateQuickTask = useCallback((taskName: string, phaseId?: string) => {
|
const handleCreateQuickTask = useCallback(
|
||||||
// Refresh the Gantt data after task creation
|
(taskName: string, phaseId?: string) => {
|
||||||
refetchTasks();
|
// Refresh the Gantt data after task creation
|
||||||
message.success(`Task "${taskName}" created successfully!`);
|
refetchTasks();
|
||||||
}, [refetchTasks]);
|
message.success(`Task "${taskName}" created successfully!`);
|
||||||
|
},
|
||||||
|
[refetchTasks]
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
// Handle errors
|
// Handle errors
|
||||||
if (tasksError || phasesError) {
|
if (tasksError || phasesError) {
|
||||||
@@ -174,15 +194,20 @@ const ProjectViewGantt: React.FC = React.memo(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GanttProvider value={{
|
<GanttProvider
|
||||||
tasks,
|
value={{
|
||||||
phases,
|
tasks,
|
||||||
viewMode,
|
phases,
|
||||||
projectId: projectId || '',
|
viewMode,
|
||||||
dateRange,
|
projectId: projectId || '',
|
||||||
onRefresh: handleRefresh
|
dateRange,
|
||||||
}}>
|
onRefresh: handleRefresh,
|
||||||
<div className="flex flex-col h-full w-full bg-gray-50 dark:bg-gray-900" style={{ height: 'calc(100vh - 64px)' }}>
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="flex flex-col h-full w-full bg-gray-50 dark:bg-gray-900"
|
||||||
|
style={{ height: 'calc(100vh - 64px)' }}
|
||||||
|
>
|
||||||
<GanttToolbar
|
<GanttToolbar
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
onViewModeChange={handleViewModeChange}
|
onViewModeChange={handleViewModeChange}
|
||||||
@@ -197,6 +222,8 @@ const ProjectViewGantt: React.FC = React.memo(() => {
|
|||||||
<GanttTaskList
|
<GanttTaskList
|
||||||
tasks={tasks}
|
tasks={tasks}
|
||||||
projectId={projectId || ''}
|
projectId={projectId || ''}
|
||||||
|
viewMode={viewMode}
|
||||||
|
onTaskClick={handleTaskClick}
|
||||||
onCreateTask={handleCreateTask}
|
onCreateTask={handleCreateTask}
|
||||||
onCreateQuickTask={handleCreateQuickTask}
|
onCreateQuickTask={handleCreateQuickTask}
|
||||||
onPhaseReorder={handlePhaseReorder}
|
onPhaseReorder={handlePhaseReorder}
|
||||||
@@ -208,7 +235,11 @@ const ProjectViewGantt: React.FC = React.memo(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Timeline and Chart - with left margin for task list */}
|
{/* 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}>
|
<div
|
||||||
|
className="flex-1 flex flex-col overflow-hidden gantt-timeline-container"
|
||||||
|
style={{ marginLeft: '444px' }}
|
||||||
|
ref={containerRef}
|
||||||
|
>
|
||||||
<GanttTimeline
|
<GanttTimeline
|
||||||
viewMode={viewMode}
|
viewMode={viewMode}
|
||||||
ref={timelineRef}
|
ref={timelineRef}
|
||||||
@@ -2,6 +2,20 @@ import React, { memo, useMemo, forwardRef, RefObject } from 'react';
|
|||||||
import { GanttTask, GanttViewMode, GanttPhase } from '../../types/gantt-types';
|
import { GanttTask, GanttViewMode, GanttPhase } from '../../types/gantt-types';
|
||||||
import { useGanttDimensions } from '../../hooks/useGanttDimensions';
|
import { useGanttDimensions } from '../../hooks/useGanttDimensions';
|
||||||
|
|
||||||
|
// Utility function to add alpha channel to hex color
|
||||||
|
const addAlphaToHex = (hex: string, alpha: number): string => {
|
||||||
|
// Remove # if present
|
||||||
|
const cleanHex = hex.replace('#', '');
|
||||||
|
|
||||||
|
// Convert hex to RGB
|
||||||
|
const r = parseInt(cleanHex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(cleanHex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(cleanHex.substring(4, 6), 16);
|
||||||
|
|
||||||
|
// Return rgba string
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
};
|
||||||
|
|
||||||
interface GanttChartProps {
|
interface GanttChartProps {
|
||||||
tasks: GanttTask[];
|
tasks: GanttTask[];
|
||||||
viewMode: GanttViewMode;
|
viewMode: GanttViewMode;
|
||||||
@@ -47,7 +61,7 @@ const TaskBarRow: React.FC<TaskBarRowProps> = memo(({ task, viewMode, columnWidt
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="absolute top-1/2 transform -translate-y-1/2 -translate-x-1/2 w-3 h-3 rotate-45 z-10"
|
className="absolute top-1/2 transform -translate-y-1/2 -translate-x-1/2 w-4 h-4 rotate-45 z-10 shadow-sm"
|
||||||
style={{
|
style={{
|
||||||
left: `${left}px`,
|
left: `${left}px`,
|
||||||
backgroundColor: task.color || '#3b82f6'
|
backgroundColor: task.color || '#3b82f6'
|
||||||
@@ -89,15 +103,18 @@ const TaskBarRow: React.FC<TaskBarRowProps> = memo(({ task, viewMode, columnWidt
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isPhase = task.type === 'milestone' || task.is_milestone;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`h-9 relative border-b border-gray-100 dark:border-gray-700 transition-colors ${
|
className={`${isPhase ? 'min-h-[4.5rem]' : 'h-9'} relative border-b border-gray-100 dark:border-gray-700 transition-colors ${
|
||||||
task.type === 'milestone'
|
!isPhase ? 'hover:bg-gray-50 dark:hover:bg-gray-750' : ''
|
||||||
? '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'
|
|
||||||
}`}
|
}`}
|
||||||
|
style={isPhase && task.color ? {
|
||||||
|
backgroundColor: addAlphaToHex(task.color, 0.15),
|
||||||
|
} : {}}
|
||||||
>
|
>
|
||||||
{task.type === 'milestone' || task.is_milestone ? renderMilestone() : renderTaskBar()}
|
{isPhase ? renderMilestone() : renderTaskBar()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -213,7 +230,7 @@ const GanttChart = forwardRef<HTMLDivElement, GanttChartProps>(({ tasks, viewMod
|
|||||||
<div className="relative z-10">
|
<div className="relative z-10">
|
||||||
{flattenedTasks.map(item => {
|
{flattenedTasks.map(item => {
|
||||||
if ('isEmptyRow' in item && item.isEmptyRow) {
|
if ('isEmptyRow' in item && item.isEmptyRow) {
|
||||||
// Render empty row for "Add Task" placeholder
|
// Render empty row without "Add Task" button
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={item.id}
|
||||||
@@ -1,20 +1,38 @@
|
|||||||
import React, { memo, useCallback, useState, forwardRef } from 'react';
|
import React, { memo, useCallback, useState, forwardRef, useRef, useEffect, useMemo } from 'react';
|
||||||
import { RightOutlined, DownOutlined, StarOutlined, PlusOutlined, HolderOutlined } from '@ant-design/icons';
|
import { RightOutlined, DownOutlined, PlusOutlined, HolderOutlined, CalendarOutlined } from '@ant-design/icons';
|
||||||
import { Button, Tooltip, Input } from 'antd';
|
import { Button, Tooltip, Input, DatePicker, Space, message } from 'antd';
|
||||||
|
import dayjs, { Dayjs } from 'dayjs';
|
||||||
import { DndContext, DragEndEvent, DragOverEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
import { DndContext, DragEndEvent, DragOverEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||||
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { GanttTask } from '../../types/gantt-types';
|
import { GanttTask, GanttViewMode } from '../../types/gantt-types';
|
||||||
import { useSocket } from '../../../../../../socket/socketContext';
|
import { useSocket } from '../../../../../../socket/socketContext';
|
||||||
import { SocketEvents } from '../../../../../../shared/socket-events';
|
import { SocketEvents } from '../../../../../../shared/socket-events';
|
||||||
import { useAppDispatch } from '../../../../../../hooks/useAppDispatch';
|
import { useAppDispatch } from '../../../../../../hooks/useAppDispatch';
|
||||||
import { addTask } from '../../../../../../features/task-management/task-management.slice';
|
import { addTask } from '../../../../../../features/task-management/task-management.slice';
|
||||||
import { useAuthService } from '../../../../../../hooks/useAuth';
|
import { useAuthService } from '../../../../../../hooks/useAuth';
|
||||||
|
import { useUpdatePhaseMutation } from '../../services/gantt-api.service';
|
||||||
|
|
||||||
|
// Utility function to add alpha channel to hex color
|
||||||
|
const addAlphaToHex = (hex: string, alpha: number): string => {
|
||||||
|
// Remove # if present
|
||||||
|
const cleanHex = hex.replace('#', '');
|
||||||
|
|
||||||
|
// Convert hex to RGB
|
||||||
|
const r = parseInt(cleanHex.substring(0, 2), 16);
|
||||||
|
const g = parseInt(cleanHex.substring(2, 4), 16);
|
||||||
|
const b = parseInt(cleanHex.substring(4, 6), 16);
|
||||||
|
|
||||||
|
// Return rgba string
|
||||||
|
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||||
|
};
|
||||||
|
|
||||||
interface GanttTaskListProps {
|
interface GanttTaskListProps {
|
||||||
tasks: GanttTask[];
|
tasks: GanttTask[];
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
viewMode: GanttViewMode;
|
||||||
onTaskToggle?: (taskId: string) => void;
|
onTaskToggle?: (taskId: string) => void;
|
||||||
|
onTaskClick?: (taskId: string) => void;
|
||||||
onCreateTask?: (phaseId?: string) => void;
|
onCreateTask?: (phaseId?: string) => void;
|
||||||
onCreateQuickTask?: (taskName: string, phaseId?: string) => void;
|
onCreateQuickTask?: (taskName: string, phaseId?: string) => void;
|
||||||
onPhaseReorder?: (oldIndex: number, newIndex: number) => void;
|
onPhaseReorder?: (oldIndex: number, newIndex: number) => void;
|
||||||
@@ -28,6 +46,7 @@ interface TaskRowProps {
|
|||||||
index: number;
|
index: number;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
onToggle?: (taskId: string) => void;
|
onToggle?: (taskId: string) => void;
|
||||||
|
onTaskClick?: (taskId: string) => void;
|
||||||
expandedTasks: Set<string>;
|
expandedTasks: Set<string>;
|
||||||
onCreateTask?: (phaseId?: string) => void;
|
onCreateTask?: (phaseId?: string) => void;
|
||||||
onCreateQuickTask?: (taskName: string, phaseId?: string) => void;
|
onCreateQuickTask?: (taskName: string, phaseId?: string) => void;
|
||||||
@@ -75,17 +94,23 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
|
|||||||
task,
|
task,
|
||||||
projectId,
|
projectId,
|
||||||
onToggle,
|
onToggle,
|
||||||
|
onTaskClick,
|
||||||
expandedTasks,
|
expandedTasks,
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
onCreateQuickTask,
|
onCreateQuickTask,
|
||||||
isDraggable = false,
|
isDraggable = false,
|
||||||
|
activeId,
|
||||||
|
overId,
|
||||||
dragAttributes,
|
dragAttributes,
|
||||||
dragListeners
|
dragListeners
|
||||||
}) => {
|
}) => {
|
||||||
const [showInlineInput, setShowInlineInput] = useState(false);
|
const [showInlineInput, setShowInlineInput] = useState(false);
|
||||||
const [taskName, setTaskName] = useState('');
|
const [taskName, setTaskName] = useState('');
|
||||||
|
const [showDatePickers, setShowDatePickers] = useState(false);
|
||||||
|
const datePickerRef = useRef<HTMLDivElement>(null);
|
||||||
const { socket, connected } = useSocket();
|
const { socket, connected } = useSocket();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const [updatePhase] = useUpdatePhaseMutation();
|
||||||
const formatDateRange = useCallback(() => {
|
const formatDateRange = useCallback(() => {
|
||||||
if (!task.start_date || !task.end_date) {
|
if (!task.start_date || !task.end_date) {
|
||||||
return <span className="text-gray-400 dark:text-gray-500">Not scheduled</span>;
|
return <span className="text-gray-400 dark:text-gray-500">Not scheduled</span>;
|
||||||
@@ -96,36 +121,40 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
|
|||||||
return `${start} - ${end}`;
|
return `${start} - ${end}`;
|
||||||
}, [task.start_date, task.end_date]);
|
}, [task.start_date, task.end_date]);
|
||||||
|
|
||||||
|
const isPhase = task.type === 'milestone' || task.is_milestone;
|
||||||
const hasChildren = task.children && task.children.length > 0;
|
const hasChildren = task.children && task.children.length > 0;
|
||||||
const isExpanded = expandedTasks.has(task.id);
|
// For phases, use phase_id for expansion state, for tasks use task.id
|
||||||
|
const phaseId = isPhase ? (task.id === 'phase-unmapped' ? 'unmapped' : task.phase_id || task.id.replace('phase-', '')) : task.id;
|
||||||
|
const isExpanded = expandedTasks.has(phaseId);
|
||||||
const indentLevel = (task.level || 0) * 20;
|
const indentLevel = (task.level || 0) * 20;
|
||||||
|
|
||||||
const handleToggle = useCallback(() => {
|
const handleToggle = useCallback(() => {
|
||||||
if (hasChildren && onToggle) {
|
// For phases, always allow toggle (regardless of having children)
|
||||||
|
// Use the standard onToggle handler which will call handleTaskToggle in GanttTaskList
|
||||||
|
if (isPhase && onToggle) {
|
||||||
|
onToggle(phaseId);
|
||||||
|
} else if (hasChildren && onToggle) {
|
||||||
onToggle(task.id);
|
onToggle(task.id);
|
||||||
}
|
}
|
||||||
}, [hasChildren, onToggle, task.id]);
|
}, [isPhase, hasChildren, onToggle, task.id, phaseId]);
|
||||||
|
|
||||||
const getTaskIcon = () => {
|
const getTaskIcon = () => {
|
||||||
if (task.type === 'milestone' || task.is_milestone) {
|
// No icon for phases
|
||||||
return <StarOutlined className="text-blue-500 text-xs" />;
|
|
||||||
}
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const getExpandIcon = () => {
|
const getExpandIcon = () => {
|
||||||
// For empty phases, show expand icon to allow adding tasks
|
// All phases should be expandable (with or without children)
|
||||||
if (isEmpty || hasChildren) {
|
if (isPhase) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={handleToggle}
|
onClick={handleToggle}
|
||||||
className="w-4 h-4 flex items-center justify-center hover:bg-gray-200 dark:hover:bg-gray-600 rounded"
|
className={`w-4 h-4 flex items-center justify-center rounded gantt-expand-icon ${
|
||||||
|
isExpanded ? 'expanded' : ''
|
||||||
|
} hover:bg-black/10`}
|
||||||
|
style={task.color ? { color: task.color } : {}}
|
||||||
>
|
>
|
||||||
{isExpanded ? (
|
<RightOutlined className="text-xs transition-transform duration-200" />
|
||||||
<DownOutlined className="text-xs" />
|
|
||||||
) : (
|
|
||||||
<RightOutlined className="text-xs" />
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -185,33 +214,90 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
|
|||||||
setShowInlineInput(true);
|
setShowInlineInput(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isPhase = task.type === 'milestone' || task.is_milestone;
|
const handlePhaseDateUpdate = useCallback(async (startDate: Date, endDate: Date) => {
|
||||||
|
if (!projectId || !task.phase_id) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updatePhase({
|
||||||
|
project_id: projectId,
|
||||||
|
phase_id: task.phase_id,
|
||||||
|
start_date: startDate.toISOString(),
|
||||||
|
end_date: endDate.toISOString(),
|
||||||
|
}).unwrap();
|
||||||
|
|
||||||
|
message.success('Phase dates updated successfully');
|
||||||
|
setShowDatePickers(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update phase dates:', error);
|
||||||
|
message.error('Failed to update phase dates');
|
||||||
|
}
|
||||||
|
}, [projectId, task.phase_id, updatePhase]);
|
||||||
|
|
||||||
const isEmpty = isPhase && (!task.children || task.children.length === 0);
|
const isEmpty = isPhase && (!task.children || task.children.length === 0);
|
||||||
|
|
||||||
|
// Calculate phase completion percentage
|
||||||
|
const phaseCompletion = useMemo(() => {
|
||||||
|
if (!isPhase || !task.children || task.children.length === 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
const totalTasks = task.children.length;
|
||||||
|
const completedTasks = task.children.filter(child => child.progress === 100).length;
|
||||||
|
return Math.round((completedTasks / totalTasks) * 100);
|
||||||
|
}, [isPhase, task.children]);
|
||||||
|
|
||||||
|
const handleTaskClick = useCallback(() => {
|
||||||
|
if (!isPhase && onTaskClick) {
|
||||||
|
onTaskClick(task.id);
|
||||||
|
}
|
||||||
|
}, [isPhase, onTaskClick, task.id]);
|
||||||
|
|
||||||
|
// Handle click outside to close date picker
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) {
|
||||||
|
setShowDatePickers(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (showDatePickers) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [showDatePickers]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`group flex h-9 border-b border-gray-100 dark:border-gray-700 transition-colors ${
|
className={`group flex ${isPhase ? 'min-h-[4.5rem] gantt-phase-row' : 'h-9 gantt-task-row'} border-b border-gray-100 dark:border-gray-700 transition-colors ${
|
||||||
task.type === 'milestone'
|
!isPhase ? 'bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 cursor-pointer' : ''
|
||||||
? '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' : ''} ${
|
} ${isDraggable && !isPhase ? 'cursor-grab active:cursor-grabbing' : ''} ${
|
||||||
activeId === task.id ? 'opacity-50' : ''
|
activeId === task.id ? 'opacity-50' : ''
|
||||||
} ${overId === task.id && overId !== activeId ? 'ring-2 ring-blue-500 ring-inset' : ''}`}
|
} ${overId === task.id && overId !== activeId ? 'ring-2 ring-blue-500 ring-inset' : ''}`}
|
||||||
|
style={isPhase && task.color ? {
|
||||||
|
backgroundColor: addAlphaToHex(task.color, 0.15),
|
||||||
|
color: task.color
|
||||||
|
} : {}}
|
||||||
|
onClick={!isPhase ? handleTaskClick : undefined}
|
||||||
{...(!isPhase && isDraggable ? dragAttributes : {})}
|
{...(!isPhase && isDraggable ? dragAttributes : {})}
|
||||||
{...(!isPhase && isDraggable ? dragListeners : {})}
|
{...(!isPhase && isDraggable ? dragListeners : {})}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="w-[420px] px-2 py-2 text-sm text-gray-800 dark:text-gray-200 flex items-center justify-between"
|
className={`w-full px-2 py-2 text-sm ${isPhase ? '' : 'text-gray-800 dark:text-gray-200'} flex items-center justify-between`}
|
||||||
style={{ paddingLeft: `${8 + indentLevel}px` }}
|
style={{
|
||||||
|
paddingLeft: `${8 + indentLevel + (isPhase && task.id === 'phase-unmapped' ? 28 : 0)}px`,
|
||||||
|
color: isPhase && task.color ? task.color : undefined
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-2 truncate">
|
<div className="flex items-center gap-2 truncate flex-1">
|
||||||
{/* Drag handle for phases */}
|
{/* Drag handle for phases */}
|
||||||
{isPhase && isDraggable && (
|
{isPhase && isDraggable && (
|
||||||
<button
|
<button
|
||||||
{...dragAttributes}
|
{...dragAttributes}
|
||||||
{...dragListeners}
|
{...dragListeners}
|
||||||
className="opacity-50 hover:opacity-100 cursor-grab active:cursor-grabbing p-1 rounded hover:bg-white/50"
|
className="opacity-50 hover:opacity-100 cursor-grab active:cursor-grabbing p-1 rounded hover:bg-black/10"
|
||||||
|
style={{ color: task.color }}
|
||||||
title="Drag to reorder phase"
|
title="Drag to reorder phase"
|
||||||
>
|
>
|
||||||
<HolderOutlined className="text-xs" />
|
<HolderOutlined className="text-xs" />
|
||||||
@@ -220,37 +306,82 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
|
|||||||
|
|
||||||
{getExpandIcon()}
|
{getExpandIcon()}
|
||||||
|
|
||||||
<div className="flex items-center gap-2 ml-1 truncate">
|
<div className="flex items-center gap-2 ml-1 truncate flex-1">
|
||||||
{getTaskIcon()}
|
{getTaskIcon()}
|
||||||
<span className={`truncate ${task.type === 'milestone' ? 'font-semibold text-blue-700 dark:text-blue-300' : ''}`}>
|
<div className="flex flex-col flex-1">
|
||||||
{task.name}
|
<span
|
||||||
</span>
|
className={`truncate ${task.type === 'milestone' ? 'font-semibold' : ''}`}
|
||||||
|
>
|
||||||
|
{task.name}
|
||||||
|
</span>
|
||||||
|
{isPhase && (
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span className="text-xs" style={{ color: task.color, opacity: 0.8 }}>
|
||||||
|
{task.children?.length || 0} tasks
|
||||||
|
</span>
|
||||||
|
{!showDatePickers && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDatePickers(true)}
|
||||||
|
className="text-xs flex items-center gap-1 transition-colors"
|
||||||
|
style={{ color: task.color, opacity: 0.7 }}
|
||||||
|
>
|
||||||
|
<CalendarOutlined className="text-[10px]" />
|
||||||
|
{task.start_date && task.end_date ? (
|
||||||
|
<>{dayjs(task.start_date).format('MMM D')} - {dayjs(task.end_date).format('MMM D, YYYY')}</>
|
||||||
|
) : (
|
||||||
|
'Set dates'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{showDatePickers && isPhase && (
|
||||||
|
<div ref={datePickerRef} className="flex items-center gap-1 mt-2 -ml-1">
|
||||||
|
<DatePicker.RangePicker
|
||||||
|
size="small"
|
||||||
|
value={[
|
||||||
|
task.start_date ? dayjs(task.start_date) : null,
|
||||||
|
task.end_date ? dayjs(task.end_date) : null
|
||||||
|
]}
|
||||||
|
onChange={(dates) => {
|
||||||
|
if (dates && dates[0] && dates[1]) {
|
||||||
|
handlePhaseDateUpdate(dates[0].toDate(), dates[1].toDate());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setShowDatePickers(false);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-xs"
|
||||||
|
style={{ width: 180 }}
|
||||||
|
format="MMM D, YYYY"
|
||||||
|
placeholder={['Start date', 'End date']}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Add Task button for phases */}
|
{/* Phase completion percentage on the right side */}
|
||||||
{isPhase && onCreateTask && (
|
{isPhase && task.children && task.children.length > 0 && (
|
||||||
<Tooltip title="Add task to this phase">
|
<div className="flex-shrink-0 mr-2">
|
||||||
<Button
|
<span className="text-xs font-medium" style={{ color: task.color, opacity: 0.9 }}>
|
||||||
type="text"
|
{phaseCompletion}%
|
||||||
size="small"
|
</span>
|
||||||
icon={<PlusOutlined />}
|
</div>
|
||||||
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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Inline task creation for empty expanded phases */}
|
{/* Inline task creation for all expanded phases */}
|
||||||
{isEmpty && isExpanded && (
|
{isPhase && isExpanded && (
|
||||||
<div className="flex h-9 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
<div className="gantt-add-task-inline flex h-9 border-b border-gray-100 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||||
<div
|
<div
|
||||||
className="w-[420px] px-2 py-2 text-sm flex items-center"
|
className="w-full px-2 py-2 text-sm flex items-center"
|
||||||
style={{ paddingLeft: `${8 + 40}px` }} // Extra indent for child
|
style={{ paddingLeft: `${8 + 40}px` }} // Extra indent for child
|
||||||
>
|
>
|
||||||
{showInlineInput ? (
|
{showInlineInput ? (
|
||||||
@@ -274,15 +405,12 @@ const TaskRow: React.FC<TaskRowProps & { dragAttributes?: any; dragListeners?: a
|
|||||||
size="small"
|
size="small"
|
||||||
icon={<PlusOutlined />}
|
icon={<PlusOutlined />}
|
||||||
onClick={handleShowInlineInput}
|
onClick={handleShowInlineInput}
|
||||||
className="text-xs text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400"
|
className="text-xs text-gray-500 dark:text-gray-400 hover:text-blue-600 dark:hover:text-blue-400 gantt-add-task-btn"
|
||||||
>
|
>
|
||||||
Add Task
|
Add Task
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -294,7 +422,9 @@ TaskRow.displayName = 'TaskRow';
|
|||||||
const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({
|
const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({
|
||||||
tasks,
|
tasks,
|
||||||
projectId,
|
projectId,
|
||||||
|
viewMode,
|
||||||
onTaskToggle,
|
onTaskToggle,
|
||||||
|
onTaskClick,
|
||||||
onCreateTask,
|
onCreateTask,
|
||||||
onCreateQuickTask,
|
onCreateQuickTask,
|
||||||
onPhaseReorder,
|
onPhaseReorder,
|
||||||
@@ -471,15 +601,16 @@ const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({
|
|||||||
const allDraggableItems = [...phases.map(p => p.id), ...regularTasks.map(t => t.id)];
|
const allDraggableItems = [...phases.map(p => p.id), ...regularTasks.map(t => t.id)];
|
||||||
const phasesSet = new Set(phases.map(p => p.id));
|
const phasesSet = new Set(phases.map(p => p.id));
|
||||||
|
|
||||||
|
// Determine if the timeline has dual headers
|
||||||
|
const hasDualHeaders = ['month', 'week', 'day'].includes(viewMode);
|
||||||
|
const headerHeight = hasDualHeaders ? 'h-20' : 'h-10';
|
||||||
|
|
||||||
return (
|
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="w-[444px] min-w-[444px] max-w-[444px] h-full flex flex-col bg-gray-50 dark:bg-gray-900 gantt-task-list-container">
|
||||||
<div className="flex 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={`flex ${headerHeight} border-b border-gray-200 dark:border-gray-700 bg-gray-100 dark:bg-gray-800 font-medium text-sm flex-shrink-0 items-center`}>
|
||||||
<div className="w-[420px] px-4 py-2.5 text-gray-700 dark:text-gray-300">
|
<div className="w-full px-4 text-gray-700 dark:text-gray-300">
|
||||||
Task Name
|
Task Name
|
||||||
</div>
|
</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>
|
||||||
<div className="flex-1 gantt-task-list-scroll relative" ref={ref} onScroll={onScroll}>
|
<div className="flex-1 gantt-task-list-scroll relative" ref={ref} onScroll={onScroll}>
|
||||||
{visibleTasks.length === 0 && (
|
{visibleTasks.length === 0 && (
|
||||||
@@ -511,6 +642,7 @@ const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({
|
|||||||
index={index}
|
index={index}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onToggle={handleTaskToggle}
|
onToggle={handleTaskToggle}
|
||||||
|
onTaskClick={onTaskClick}
|
||||||
expandedTasks={expandedTasks}
|
expandedTasks={expandedTasks}
|
||||||
onCreateTask={onCreateTask}
|
onCreateTask={onCreateTask}
|
||||||
onCreateQuickTask={onCreateQuickTask}
|
onCreateQuickTask={onCreateQuickTask}
|
||||||
@@ -526,6 +658,7 @@ const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({
|
|||||||
index={index}
|
index={index}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onToggle={handleTaskToggle}
|
onToggle={handleTaskToggle}
|
||||||
|
onTaskClick={onTaskClick}
|
||||||
expandedTasks={expandedTasks}
|
expandedTasks={expandedTasks}
|
||||||
onCreateTask={onCreateTask}
|
onCreateTask={onCreateTask}
|
||||||
onCreateQuickTask={onCreateQuickTask}
|
onCreateQuickTask={onCreateQuickTask}
|
||||||
@@ -544,6 +677,7 @@ const GanttTaskList = forwardRef<HTMLDivElement, GanttTaskListProps>(({
|
|||||||
index={index}
|
index={index}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onToggle={handleTaskToggle}
|
onToggle={handleTaskToggle}
|
||||||
|
onTaskClick={onTaskClick}
|
||||||
expandedTasks={expandedTasks}
|
expandedTasks={expandedTasks}
|
||||||
onCreateTask={onCreateTask}
|
onCreateTask={onCreateTask}
|
||||||
onCreateQuickTask={onCreateQuickTask}
|
onCreateQuickTask={onCreateQuickTask}
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
import React, { memo, useMemo, forwardRef, RefObject } from 'react';
|
||||||
|
import { GanttViewMode } from '../../types/gantt-types';
|
||||||
|
import { useGanttDimensions } from '../../hooks/useGanttDimensions';
|
||||||
|
import { TimelineUtils } from '../../utils/timeline-calculator';
|
||||||
|
|
||||||
|
interface GanttTimelineProps {
|
||||||
|
viewMode: GanttViewMode;
|
||||||
|
containerRef: RefObject<HTMLDivElement | null>;
|
||||||
|
dateRange?: { start: Date; end: Date };
|
||||||
|
}
|
||||||
|
|
||||||
|
const GanttTimeline = forwardRef<HTMLDivElement, GanttTimelineProps>(({ viewMode, containerRef, dateRange }, ref) => {
|
||||||
|
const { topHeaders, bottomHeaders } = useMemo(() => {
|
||||||
|
if (!dateRange) {
|
||||||
|
return { topHeaders: [], bottomHeaders: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const { start, end } = dateRange;
|
||||||
|
const topHeaders: Array<{ label: string; key: string; span: number }> = [];
|
||||||
|
const bottomHeaders: Array<{ label: string; key: string }> = [];
|
||||||
|
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'month':
|
||||||
|
// Top: Years, Bottom: Months
|
||||||
|
const startYear = start.getFullYear();
|
||||||
|
const startMonth = start.getMonth();
|
||||||
|
const endYear = end.getFullYear();
|
||||||
|
const endMonth = end.getMonth();
|
||||||
|
|
||||||
|
// Generate bottom headers (months)
|
||||||
|
let currentYear = startYear;
|
||||||
|
let currentMonth = startMonth;
|
||||||
|
|
||||||
|
while (currentYear < endYear || (currentYear === endYear && currentMonth <= endMonth)) {
|
||||||
|
const date = new Date(currentYear, currentMonth, 1);
|
||||||
|
bottomHeaders.push({
|
||||||
|
label: date.toLocaleDateString('en-US', { month: 'short' }),
|
||||||
|
key: `month-${currentYear}-${currentMonth}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
currentMonth++;
|
||||||
|
if (currentMonth > 11) {
|
||||||
|
currentMonth = 0;
|
||||||
|
currentYear++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate top headers (years)
|
||||||
|
for (let year = startYear; year <= endYear; year++) {
|
||||||
|
const monthsInYear = bottomHeaders.filter(h => h.key.includes(`-${year}-`)).length;
|
||||||
|
if (monthsInYear > 0) {
|
||||||
|
topHeaders.push({
|
||||||
|
label: `${year}`,
|
||||||
|
key: `year-${year}`,
|
||||||
|
span: monthsInYear
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'week':
|
||||||
|
// Top: Months, Bottom: Weeks
|
||||||
|
const weekStart = new Date(start);
|
||||||
|
const weekEnd = new Date(end);
|
||||||
|
weekStart.setDate(weekStart.getDate() - weekStart.getDay());
|
||||||
|
|
||||||
|
const weekDates: Date[] = [];
|
||||||
|
const tempDate = new Date(weekStart);
|
||||||
|
while (tempDate <= weekEnd) {
|
||||||
|
weekDates.push(new Date(tempDate));
|
||||||
|
tempDate.setDate(tempDate.getDate() + 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate bottom headers (weeks)
|
||||||
|
weekDates.forEach(date => {
|
||||||
|
const weekNum = TimelineUtils.getWeekNumber(date);
|
||||||
|
bottomHeaders.push({
|
||||||
|
label: `W${weekNum}`,
|
||||||
|
key: `week-${date.getFullYear()}-${weekNum}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate top headers (months)
|
||||||
|
const monthGroups = new Map<string, number>();
|
||||||
|
weekDates.forEach(date => {
|
||||||
|
const monthKey = `${date.getFullYear()}-${date.getMonth()}`;
|
||||||
|
monthGroups.set(monthKey, (monthGroups.get(monthKey) || 0) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
monthGroups.forEach((count, monthKey) => {
|
||||||
|
const [year, month] = monthKey.split('-').map(Number);
|
||||||
|
const date = new Date(year, month, 1);
|
||||||
|
topHeaders.push({
|
||||||
|
label: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }),
|
||||||
|
key: `month-${monthKey}`,
|
||||||
|
span: count
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'day':
|
||||||
|
// Top: Months, Bottom: Days
|
||||||
|
const dayStart = new Date(start);
|
||||||
|
const dayEnd = new Date(end);
|
||||||
|
|
||||||
|
const dayDates: Date[] = [];
|
||||||
|
const tempDayDate = new Date(dayStart);
|
||||||
|
while (tempDayDate <= dayEnd) {
|
||||||
|
dayDates.push(new Date(tempDayDate));
|
||||||
|
tempDayDate.setDate(tempDayDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate bottom headers (days)
|
||||||
|
dayDates.forEach(date => {
|
||||||
|
bottomHeaders.push({
|
||||||
|
label: date.getDate().toString(),
|
||||||
|
key: `day-${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate top headers (months)
|
||||||
|
const dayMonthGroups = new Map<string, number>();
|
||||||
|
dayDates.forEach(date => {
|
||||||
|
const monthKey = `${date.getFullYear()}-${date.getMonth()}`;
|
||||||
|
dayMonthGroups.set(monthKey, (dayMonthGroups.get(monthKey) || 0) + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
dayMonthGroups.forEach((count, monthKey) => {
|
||||||
|
const [year, month] = monthKey.split('-').map(Number);
|
||||||
|
const date = new Date(year, month, 1);
|
||||||
|
topHeaders.push({
|
||||||
|
label: date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }),
|
||||||
|
key: `month-${monthKey}`,
|
||||||
|
span: count
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Fallback to single row for other view modes
|
||||||
|
const result = [];
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'quarter':
|
||||||
|
const qStartYear = start.getFullYear();
|
||||||
|
const qStartQuarter = Math.ceil((start.getMonth() + 1) / 3);
|
||||||
|
const qEndYear = end.getFullYear();
|
||||||
|
const qEndQuarter = Math.ceil((end.getMonth() + 1) / 3);
|
||||||
|
|
||||||
|
let qYear = qStartYear;
|
||||||
|
let qQuarter = qStartQuarter;
|
||||||
|
|
||||||
|
while (qYear < qEndYear || (qYear === qEndYear && qQuarter <= qEndQuarter)) {
|
||||||
|
result.push({
|
||||||
|
label: `Q${qQuarter} ${qYear}`,
|
||||||
|
key: `quarter-${qYear}-${qQuarter}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
qQuarter++;
|
||||||
|
if (qQuarter > 4) {
|
||||||
|
qQuarter = 1;
|
||||||
|
qYear++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
const yearStart = start.getFullYear();
|
||||||
|
const yearEnd = end.getFullYear();
|
||||||
|
|
||||||
|
for (let year = yearStart; year <= yearEnd; year++) {
|
||||||
|
result.push({
|
||||||
|
label: `${year}`,
|
||||||
|
key: `year-${year}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.forEach(item => {
|
||||||
|
bottomHeaders.push(item);
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { topHeaders, bottomHeaders };
|
||||||
|
}, [viewMode, dateRange]);
|
||||||
|
|
||||||
|
const { actualColumnWidth, totalWidth, shouldScroll } = useGanttDimensions(
|
||||||
|
viewMode,
|
||||||
|
containerRef,
|
||||||
|
bottomHeaders.length
|
||||||
|
);
|
||||||
|
|
||||||
|
const hasTopHeaders = topHeaders.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={`${hasTopHeaders ? 'h-20' : 'h-10'} flex-shrink-0 bg-gray-100 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 overflow-y-hidden ${
|
||||||
|
shouldScroll ? 'overflow-x-auto' : 'overflow-x-hidden'
|
||||||
|
} scrollbar-hide flex flex-col`}
|
||||||
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||||
|
>
|
||||||
|
{hasTopHeaders && (
|
||||||
|
<div className="flex h-10 border-b border-gray-200 dark:border-gray-700" style={{ width: `${totalWidth}px`, minWidth: shouldScroll ? 'auto' : '100%' }}>
|
||||||
|
{topHeaders.map(header => (
|
||||||
|
<div
|
||||||
|
key={header.key}
|
||||||
|
className="py-2.5 text-center border-r border-gray-200 dark:border-gray-700 text-sm font-semibold text-gray-800 dark:text-gray-200 flex-shrink-0 px-2 whitespace-nowrap bg-gray-50 dark:bg-gray-750"
|
||||||
|
style={{ width: `${actualColumnWidth * header.span}px` }}
|
||||||
|
title={header.label}
|
||||||
|
>
|
||||||
|
{header.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex h-10" style={{ width: `${totalWidth}px`, minWidth: shouldScroll ? 'auto' : '100%' }}>
|
||||||
|
{bottomHeaders.map(header => (
|
||||||
|
<div
|
||||||
|
key={header.key}
|
||||||
|
className={`py-2.5 text-center border-r border-gray-200 dark:border-gray-700 text-sm font-medium text-gray-700 dark:text-gray-300 flex-shrink-0 ${
|
||||||
|
viewMode === 'day' ? 'px-1 text-xs' : 'px-2'
|
||||||
|
} ${
|
||||||
|
viewMode === 'day' && actualColumnWidth < 50 ? 'whitespace-nowrap overflow-hidden text-ellipsis' : 'whitespace-nowrap'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${actualColumnWidth}px` }}
|
||||||
|
title={header.label}
|
||||||
|
>
|
||||||
|
{header.label}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
GanttTimeline.displayName = 'GanttTimeline';
|
||||||
|
|
||||||
|
export default memo(GanttTimeline);
|
||||||
@@ -1,156 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -22,59 +22,24 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom scrollbar for task list */
|
/* Custom scrollbar for task list - hide scrollbar */
|
||||||
.gantt-task-list-scroll {
|
.gantt-task-list-scroll {
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
scrollbar-width: thin; /* Firefox */
|
scrollbar-width: none; /* Firefox - hide scrollbar */
|
||||||
scrollbar-gutter: stable; /* Prevent layout shift */
|
-ms-overflow-style: none; /* IE and Edge - hide scrollbar */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Webkit scrollbar styling */
|
/* Webkit scrollbar styling - hide scrollbar */
|
||||||
.gantt-task-list-scroll::-webkit-scrollbar {
|
.gantt-task-list-scroll::-webkit-scrollbar {
|
||||||
width: 6px;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gantt-task-list-scroll::-webkit-scrollbar-track {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
.gantt-task-list-scroll::-webkit-scrollbar-thumb {
|
/* Gantt chart scrollbar - show both vertical and horizontal */
|
||||||
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 {
|
.gantt-chart-scroll::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 0;
|
height: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
/* Hide horizontal scrollbar specifically */
|
|
||||||
.gantt-chart-scroll::-webkit-scrollbar:horizontal {
|
|
||||||
display: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.gantt-chart-scroll::-webkit-scrollbar-track {
|
.gantt-chart-scroll::-webkit-scrollbar-track {
|
||||||
@@ -108,3 +73,125 @@
|
|||||||
.dark .gantt-chart-scroll {
|
.dark .gantt-chart-scroll {
|
||||||
scrollbar-color: #4a5568 transparent;
|
scrollbar-color: #4a5568 transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Ensure consistent row heights and alignment */
|
||||||
|
.gantt-task-list-scroll,
|
||||||
|
.gantt-chart-scroll {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
/* Ensure same scrolling behavior */
|
||||||
|
scroll-behavior: auto;
|
||||||
|
/* Prevent sub-pixel rendering issues */
|
||||||
|
transform: translateZ(0);
|
||||||
|
will-change: scroll-position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure all rows have exact same box model */
|
||||||
|
.gantt-task-list-scroll > div > div > div,
|
||||||
|
.gantt-chart-scroll > div > div > div {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Force consistent border rendering */
|
||||||
|
.gantt-task-list-scroll .border-b,
|
||||||
|
.gantt-chart-scroll .border-b {
|
||||||
|
border-bottom-width: 1px;
|
||||||
|
border-bottom-style: solid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improve visual hierarchy for phase rows */
|
||||||
|
.gantt-phase-row {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-phase-row::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background-color: currentColor;
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Better hover states */
|
||||||
|
.gantt-task-row:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .gantt-task-row:hover {
|
||||||
|
background-color: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Improved button styles */
|
||||||
|
.gantt-add-task-btn {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-add-task-btn:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Phase expansion transitions */
|
||||||
|
.gantt-phase-children {
|
||||||
|
overflow: hidden;
|
||||||
|
transition: max-height 0.3s ease-in-out, opacity 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-phase-children.collapsed {
|
||||||
|
max-height: 0;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-phase-children.expanded {
|
||||||
|
max-height: 1000px; /* Adjust based on expected max children */
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Expand/collapse icon transitions */
|
||||||
|
.gantt-expand-icon {
|
||||||
|
transition: transform 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gantt-expand-icon.expanded {
|
||||||
|
transform: rotate(90deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task row slide-in animation */
|
||||||
|
.gantt-task-slide-in {
|
||||||
|
animation: slideIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-10px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add task button specific styles */
|
||||||
|
.gantt-add-task-inline {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
animation: fadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,15 @@ export interface CreateTaskRequest {
|
|||||||
status_id?: string;
|
status_id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UpdatePhaseRequest {
|
||||||
|
phase_id: string;
|
||||||
|
project_id: string;
|
||||||
|
name?: string;
|
||||||
|
color_code?: string;
|
||||||
|
start_date?: string;
|
||||||
|
end_date?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const ganttApi = createApi({
|
export const ganttApi = createApi({
|
||||||
reducerPath: 'ganttApi',
|
reducerPath: 'ganttApi',
|
||||||
baseQuery: fetchBaseQuery({
|
baseQuery: fetchBaseQuery({
|
||||||
@@ -176,6 +185,23 @@ export const ganttApi = createApi({
|
|||||||
{ type: 'GanttTasks', id: 'LIST' },
|
{ type: 'GanttTasks', id: 'LIST' },
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
updatePhase: builder.mutation<
|
||||||
|
IServerResponse<ProjectPhaseResponse>,
|
||||||
|
UpdatePhaseRequest
|
||||||
|
>({
|
||||||
|
query: body => ({
|
||||||
|
url: `${rootUrl}/update-phase`,
|
||||||
|
method: 'PUT',
|
||||||
|
body,
|
||||||
|
}),
|
||||||
|
invalidatesTags: (result, error, { project_id }) => [
|
||||||
|
{ type: 'GanttPhases', id: project_id },
|
||||||
|
{ type: 'GanttPhases', id: 'LIST' },
|
||||||
|
{ type: 'GanttTasks', id: project_id },
|
||||||
|
{ type: 'GanttTasks', id: 'LIST' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -185,6 +211,7 @@ export const {
|
|||||||
useUpdateTaskDatesMutation,
|
useUpdateTaskDatesMutation,
|
||||||
useCreatePhaseMutation,
|
useCreatePhaseMutation,
|
||||||
useCreateTaskMutation,
|
useCreateTaskMutation,
|
||||||
|
useUpdatePhaseMutation,
|
||||||
} = ganttApi;
|
} = ganttApi;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user