diff --git a/.gitignore b/.gitignore index 942c9b08..19c20eda 100644 --- a/.gitignore +++ b/.gitignore @@ -78,4 +78,7 @@ $RECYCLE.BIN/ # TypeScript *.tsbuildinfo +# Claude +CLAUDE.md + diff --git a/worklenz-frontend/public/locales/alb/gantt/phase-details-modal.json b/worklenz-frontend/public/locales/alb/gantt/phase-details-modal.json new file mode 100644 index 00000000..b0891d2d --- /dev/null +++ b/worklenz-frontend/public/locales/alb/gantt/phase-details-modal.json @@ -0,0 +1,36 @@ +{ + "title": "Phase Details", + "overview": { + "title": "Overview", + "totalTasks": "Total Tasks", + "completion": "Completion", + "progress": "Progress" + }, + "timeline": { + "title": "Timeline", + "startDate": "Start Date", + "endDate": "End Date", + "status": "Status", + "notSet": "Not set", + "statusLabels": { + "upcoming": "Upcoming", + "active": "In Progress", + "overdue": "Overdue", + "notScheduled": "Not Scheduled" + } + }, + "taskBreakdown": { + "title": "Task Breakdown", + "completed": "Completed", + "pending": "Pending", + "overdue": "Overdue" + }, + "phaseColor": { + "title": "Phase Color", + "description": "Phase identifier color" + }, + "tasksInPhase": { + "title": "Tasks in this Phase", + "noTasks": "No tasks in this phase" + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/de/gantt/phase-details-modal.json b/worklenz-frontend/public/locales/de/gantt/phase-details-modal.json new file mode 100644 index 00000000..b0891d2d --- /dev/null +++ b/worklenz-frontend/public/locales/de/gantt/phase-details-modal.json @@ -0,0 +1,36 @@ +{ + "title": "Phase Details", + "overview": { + "title": "Overview", + "totalTasks": "Total Tasks", + "completion": "Completion", + "progress": "Progress" + }, + "timeline": { + "title": "Timeline", + "startDate": "Start Date", + "endDate": "End Date", + "status": "Status", + "notSet": "Not set", + "statusLabels": { + "upcoming": "Upcoming", + "active": "In Progress", + "overdue": "Overdue", + "notScheduled": "Not Scheduled" + } + }, + "taskBreakdown": { + "title": "Task Breakdown", + "completed": "Completed", + "pending": "Pending", + "overdue": "Overdue" + }, + "phaseColor": { + "title": "Phase Color", + "description": "Phase identifier color" + }, + "tasksInPhase": { + "title": "Tasks in this Phase", + "noTasks": "No tasks in this phase" + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/en/gantt/phase-details-modal.json b/worklenz-frontend/public/locales/en/gantt/phase-details-modal.json new file mode 100644 index 00000000..c3d43b5a --- /dev/null +++ b/worklenz-frontend/public/locales/en/gantt/phase-details-modal.json @@ -0,0 +1,42 @@ +{ + "title": "Phase Details", + "overview": { + "title": "Overview", + "totalTasks": "Total Tasks", + "completion": "Completion", + "progress": "Progress" + }, + "timeline": { + "title": "Timeline", + "startDate": "Start Date", + "endDate": "End Date", + "status": "Status", + "notSet": "Not set", + "statusLabels": { + "upcoming": "Upcoming", + "active": "In Progress", + "overdue": "Overdue", + "notScheduled": "Not Scheduled" + } + }, + "taskBreakdown": { + "title": "Task Breakdown", + "completed": "Completed", + "pending": "Pending", + "overdue": "Overdue" + }, + "phaseColor": { + "title": "Phase Color", + "description": "Phase identifier color" + }, + "tasksInPhase": { + "title": "Tasks in this Phase", + "noTasks": "No tasks in this phase", + "priority": "Priority", + "assignees": "Assignees", + "dueDate": "Due Date", + "startDate": "Start Date", + "noAssignees": "Unassigned", + "noDueDate": "No due date" + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/gantt/phase-details-modal.json b/worklenz-frontend/public/locales/es/gantt/phase-details-modal.json new file mode 100644 index 00000000..b0891d2d --- /dev/null +++ b/worklenz-frontend/public/locales/es/gantt/phase-details-modal.json @@ -0,0 +1,36 @@ +{ + "title": "Phase Details", + "overview": { + "title": "Overview", + "totalTasks": "Total Tasks", + "completion": "Completion", + "progress": "Progress" + }, + "timeline": { + "title": "Timeline", + "startDate": "Start Date", + "endDate": "End Date", + "status": "Status", + "notSet": "Not set", + "statusLabels": { + "upcoming": "Upcoming", + "active": "In Progress", + "overdue": "Overdue", + "notScheduled": "Not Scheduled" + } + }, + "taskBreakdown": { + "title": "Task Breakdown", + "completed": "Completed", + "pending": "Pending", + "overdue": "Overdue" + }, + "phaseColor": { + "title": "Phase Color", + "description": "Phase identifier color" + }, + "tasksInPhase": { + "title": "Tasks in this Phase", + "noTasks": "No tasks in this phase" + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/gantt/phase-details-modal.json b/worklenz-frontend/public/locales/pt/gantt/phase-details-modal.json new file mode 100644 index 00000000..b0891d2d --- /dev/null +++ b/worklenz-frontend/public/locales/pt/gantt/phase-details-modal.json @@ -0,0 +1,36 @@ +{ + "title": "Phase Details", + "overview": { + "title": "Overview", + "totalTasks": "Total Tasks", + "completion": "Completion", + "progress": "Progress" + }, + "timeline": { + "title": "Timeline", + "startDate": "Start Date", + "endDate": "End Date", + "status": "Status", + "notSet": "Not set", + "statusLabels": { + "upcoming": "Upcoming", + "active": "In Progress", + "overdue": "Overdue", + "notScheduled": "Not Scheduled" + } + }, + "taskBreakdown": { + "title": "Task Breakdown", + "completed": "Completed", + "pending": "Pending", + "overdue": "Overdue" + }, + "phaseColor": { + "title": "Phase Color", + "description": "Phase identifier color" + }, + "tasksInPhase": { + "title": "Tasks in this Phase", + "noTasks": "No tasks in this phase" + } +} \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/gantt/phase-details-modal.json b/worklenz-frontend/public/locales/zh/gantt/phase-details-modal.json new file mode 100644 index 00000000..b0891d2d --- /dev/null +++ b/worklenz-frontend/public/locales/zh/gantt/phase-details-modal.json @@ -0,0 +1,36 @@ +{ + "title": "Phase Details", + "overview": { + "title": "Overview", + "totalTasks": "Total Tasks", + "completion": "Completion", + "progress": "Progress" + }, + "timeline": { + "title": "Timeline", + "startDate": "Start Date", + "endDate": "End Date", + "status": "Status", + "notSet": "Not set", + "statusLabels": { + "upcoming": "Upcoming", + "active": "In Progress", + "overdue": "Overdue", + "notScheduled": "Not Scheduled" + } + }, + "taskBreakdown": { + "title": "Task Breakdown", + "completed": "Completed", + "pending": "Pending", + "overdue": "Overdue" + }, + "phaseColor": { + "title": "Phase Color", + "description": "Phase identifier color" + }, + "tasksInPhase": { + "title": "Tasks in this Phase", + "noTasks": "No tasks in this phase" + } +} \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/ProjectViewGantt.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/ProjectViewGantt.tsx index e5ac8ec0..0c6cb37b 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/ProjectViewGantt.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/ProjectViewGantt.tsx @@ -6,6 +6,7 @@ import GanttTaskList from './components/gantt-task-list/GanttTaskList'; import GanttChart from './components/gantt-chart/GanttChart'; import GanttToolbar from './components/gantt-toolbar/GanttToolbar'; import ManagePhaseModal from '@components/task-management/ManagePhaseModal'; +import PhaseDetailsModal from './components/phase-details-modal/PhaseDetailsModal'; import { GanttProvider } from './context/gantt-context'; import { GanttViewMode } from './types/gantt-types'; import { @@ -31,7 +32,11 @@ const ProjectViewGantt: React.FC = React.memo(() => { const dispatch = useAppDispatch(); const [viewMode, setViewMode] = useState('month'); const [showPhaseModal, setShowPhaseModal] = useState(false); + const [showPhaseDetailsModal, setShowPhaseDetailsModal] = useState(false); + const [selectedPhase, setSelectedPhase] = useState(null); const [expandedTasks, setExpandedTasks] = useState>(new Set()); + const [animatingTasks, setAnimatingTasks] = useState>(new Set()); + const [prevExpandedTasks, setPrevExpandedTasks] = useState>(new Set()); const timelineRef = useRef(null); const chartRef = useRef(null); const taskListRef = useRef(null); @@ -108,6 +113,30 @@ const ProjectViewGantt: React.FC = React.memo(() => { dispatch(fetchPriorities()); }, [dispatch]); + // Track expansion changes for animations + useEffect(() => { + const currentExpanded = expandedTasks; + const previousExpanded = prevExpandedTasks; + + // Find newly expanded or collapsed phases + const newlyExpanded = new Set([...currentExpanded].filter(id => !previousExpanded.has(id))); + const newlyCollapsed = new Set([...previousExpanded].filter(id => !currentExpanded.has(id))); + + if (newlyExpanded.size > 0 || newlyCollapsed.size > 0) { + // Set animation state for newly changed phases + setAnimatingTasks(new Set([...newlyExpanded, ...newlyCollapsed])); + + // Clear animation state after animation completes + const timeout = setTimeout(() => { + setAnimatingTasks(new Set()); + }, 400); // Match CSS animation duration + + setPrevExpandedTasks(new Set(currentExpanded)); + + return () => clearTimeout(timeout); + } + }, [expandedTasks, prevExpandedTasks]); + const handleViewModeChange = useCallback((mode: GanttViewMode) => { setViewMode(mode); }, []); @@ -181,6 +210,16 @@ const ProjectViewGantt: React.FC = React.memo(() => { setShowPhaseModal(false); }, []); + const handlePhaseClick = useCallback((phase: any) => { + setSelectedPhase(phase); + setShowPhaseDetailsModal(true); + }, []); + + const handleClosePhaseDetailsModal = useCallback(() => { + setShowPhaseDetailsModal(false); + setSelectedPhase(null); + }, []); + const handlePhaseReorder = useCallback((oldIndex: number, newIndex: number) => { // TODO: Implement phase reordering API call console.log('Reorder phases:', { oldIndex, newIndex }); @@ -239,6 +278,7 @@ const ProjectViewGantt: React.FC = React.memo(() => { projectId={projectId || ''} viewMode={viewMode} onTaskClick={handleTaskClick} + onPhaseClick={handlePhaseClick} onCreateTask={handleCreateTask} onCreateQuickTask={handleCreateQuickTask} onPhaseReorder={handlePhaseReorder} @@ -246,6 +286,7 @@ const ProjectViewGantt: React.FC = React.memo(() => { onScroll={handleTaskListScroll} expandedTasks={expandedTasks} onExpandedTasksChange={setExpandedTasks} + animatingTasks={animatingTasks} /> @@ -266,10 +307,12 @@ const ProjectViewGantt: React.FC = React.memo(() => { viewMode={viewMode} ref={chartRef} onScroll={handleChartScroll} + onPhaseClick={handlePhaseClick} containerRef={containerRef} dateRange={dateRange} phases={phases} expandedTasks={expandedTasks} + animatingTasks={animatingTasks} /> @@ -282,6 +325,13 @@ const ProjectViewGantt: React.FC = React.memo(() => { onClose={handleClosePhaseModal} projectId={projectId} /> + + {/* Phase Details Modal */} + ); }); diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-chart/GanttChart.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-chart/GanttChart.tsx index beac759b..e81979c5 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-chart/GanttChart.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-chart/GanttChart.tsx @@ -20,10 +20,12 @@ interface GanttChartProps { tasks: GanttTask[]; viewMode: GanttViewMode; onScroll?: (e: React.UIEvent) => void; + onPhaseClick?: (phase: GanttTask) => void; containerRef: RefObject; dateRange?: { start: Date; end: Date }; phases?: GanttPhase[]; expandedTasks?: Set; + animatingTasks?: Set; } interface GridColumnProps { @@ -48,10 +50,12 @@ interface TaskBarRowProps { columnWidth: number; columnsCount: number; dateRange?: { start: Date; end: Date }; + animationClass?: string; + onPhaseClick?: (phase: GanttTask) => void; } const TaskBarRow: React.FC = memo( - ({ task, viewMode, columnWidth, columnsCount, dateRange }) => { + ({ task, viewMode, columnWidth, columnsCount, dateRange, animationClass = '', onPhaseClick }) => { const renderMilestone = () => { if (!task.start_date || !dateRange) return null; @@ -107,11 +111,18 @@ const TaskBarRow: React.FC = memo( const isPhase = task.type === 'milestone' || task.is_milestone; + const handleClick = () => { + if (isPhase && onPhaseClick) { + onPhaseClick(task); + } + }; + return (
= memo( TaskBarRow.displayName = 'TaskBarRow'; const GanttChart = forwardRef( - ({ tasks, viewMode, onScroll, containerRef, dateRange, phases, expandedTasks }, ref) => { + ({ tasks, viewMode, onScroll, onPhaseClick, containerRef, dateRange, phases, expandedTasks, animatingTasks }, ref) => { const columnsCount = useMemo(() => { if (!dateRange) { // Default counts if no date range @@ -259,24 +270,52 @@ const GanttChart = forwardRef( ))}
- {flattenedTasks.map(item => { + {flattenedTasks.map((item, index) => { if ('isEmptyRow' in item && item.isEmptyRow) { + // Determine if this add-task row should have animation classes + const addTaskPhaseId = item.id.replace('add-task-', '').replace('-timeline', ''); + const shouldAnimate = animatingTasks ? animatingTasks.has(addTaskPhaseId) : false; + const staggerIndex = Math.min((index - 1) % 5, 4); + const animationClass = shouldAnimate + ? `gantt-task-slide-in gantt-task-stagger-${staggerIndex + 1}` + : ''; + // Render empty row without "Add Task" button return (
); } + + const task = item as GanttTask; + const isPhase = task.type === 'milestone' || task.is_milestone; + + // Determine if this task should have animation classes + let parentPhaseId = ''; + if (isPhase) { + parentPhaseId = task.id === 'phase-unmapped' ? 'unmapped' : task.phase_id || task.id.replace('phase-', ''); + } else { + parentPhaseId = task.phase_id || ''; + } + + const shouldAnimate = !isPhase && animatingTasks ? animatingTasks.has(parentPhaseId) : false; + const staggerIndex = Math.min((index - 1) % 5, 4); + const animationClass = shouldAnimate + ? `gantt-task-slide-in gantt-task-stagger-${staggerIndex + 1}` + : ''; + return ( ); })} diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-task-list/GanttTaskList.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-task-list/GanttTaskList.tsx index 7d72740d..00725ace 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-task-list/GanttTaskList.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/components/gantt-task-list/GanttTaskList.tsx @@ -46,12 +46,14 @@ interface GanttTaskListProps { viewMode: GanttViewMode; onTaskToggle?: (taskId: string) => void; onTaskClick?: (taskId: string) => void; + onPhaseClick?: (phase: GanttTask) => void; onCreateTask?: (phaseId?: string) => void; onCreateQuickTask?: (taskName: string, phaseId?: string) => void; onPhaseReorder?: (oldIndex: number, newIndex: number) => void; onScroll?: (e: React.UIEvent) => void; expandedTasks?: Set; onExpandedTasksChange?: (expanded: Set) => void; + animatingTasks?: Set; } interface TaskRowProps { @@ -60,12 +62,14 @@ interface TaskRowProps { projectId: string; onToggle?: (taskId: string) => void; onTaskClick?: (taskId: string) => void; + onPhaseClick?: (phase: GanttTask) => void; expandedTasks: Set; onCreateTask?: (phaseId?: string) => void; onCreateQuickTask?: (taskName: string, phaseId?: string) => void; isDraggable?: boolean; activeId?: string | null; overId?: string | null; + animationClass?: string; } interface SortableTaskRowProps extends TaskRowProps { @@ -104,6 +108,7 @@ const TaskRow: React.FC { const [showInlineInput, setShowInlineInput] = useState(false); const [taskName, setTaskName] = useState(''); @@ -273,6 +279,12 @@ const TaskRow: React.FC { + if (isPhase && onPhaseClick) { + onPhaseClick(task); + } + }, [isPhase, onPhaseClick, task]); + // Handle click outside to close date picker useEffect(() => { const handleClickOutside = (event: MouseEvent) => { @@ -298,7 +310,7 @@ const TaskRow: React.FC @@ -529,12 +541,14 @@ const GanttTaskList = forwardRef( viewMode, onTaskToggle, onTaskClick, + onPhaseClick, onCreateTask, onCreateQuickTask, onPhaseReorder, onScroll, expandedTasks: expandedTasksProp, onExpandedTasksChange, + animatingTasks: animatingTasksProp, }, ref ) => { @@ -543,6 +557,7 @@ const GanttTaskList = forwardRef( ); const expandedTasks = expandedTasksProp || localExpandedTasks; + const animatingTasks = animatingTasksProp || new Set(); // Drag and drop state const [activeId, setActiveId] = useState(null); @@ -561,6 +576,7 @@ const GanttTaskList = forwardRef( }) ); + const handleTaskToggle = useCallback( (taskId: string) => { const updateExpanded = (prev: Set) => { @@ -789,15 +805,33 @@ const GanttTaskList = forwardRef( const isPhase = task.type === 'milestone' || task.is_milestone; const isUnmappedPhase = task.id === 'phase-unmapped'; const isAddTaskButton = task.type === 'add-task-button'; + + // Determine if this task should have animation classes + let parentPhaseId = ''; + if (isPhase) { + parentPhaseId = task.id === 'phase-unmapped' ? 'unmapped' : task.phase_id || task.id.replace('phase-', ''); + } else if (isAddTaskButton) { + parentPhaseId = task.parent_phase_id || ''; + } else { + parentPhaseId = task.phase_id || ''; + } + + const shouldAnimate = !isPhase && animatingTasks.has(parentPhaseId); + const staggerIndex = Math.min((index - 1) % 5, 4); // Subtract 1 to account for phase row, limit stagger to 5 levels if (isAddTaskButton) { + const animationClass = shouldAnimate + ? `gantt-task-slide-in gantt-task-stagger-${staggerIndex + 1}` + : ''; + return ( - +
+ +
); } else if (isPhase && !isUnmappedPhase) { return ( @@ -809,6 +843,7 @@ const GanttTaskList = forwardRef( projectId={projectId} onToggle={handleTaskToggle} onTaskClick={onTaskClick} + onPhaseClick={onPhaseClick} expandedTasks={expandedTasks} onCreateTask={onCreateTask} onCreateQuickTask={onCreateQuickTask} @@ -825,6 +860,7 @@ const GanttTaskList = forwardRef( projectId={projectId} onToggle={handleTaskToggle} onTaskClick={onTaskClick} + onPhaseClick={onPhaseClick} expandedTasks={expandedTasks} onCreateTask={onCreateTask} onCreateQuickTask={onCreateQuickTask} @@ -834,7 +870,11 @@ const GanttTaskList = forwardRef( /> ); } else { - // Regular tasks - make them draggable too + // Regular tasks - make them draggable too with animation + const animationClass = shouldAnimate + ? `gantt-task-slide-in gantt-task-stagger-${staggerIndex + 1}` + : ''; + return ( ( projectId={projectId} onToggle={handleTaskToggle} onTaskClick={onTaskClick} + onPhaseClick={onPhaseClick} expandedTasks={expandedTasks} onCreateTask={onCreateTask} onCreateQuickTask={onCreateQuickTask} activeId={activeId} overId={overId} + animationClass={animationClass} /> ); } diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/components/phase-details-modal/PhaseDetailsModal.tsx b/worklenz-frontend/src/pages/projects/projectView/gantt/components/phase-details-modal/PhaseDetailsModal.tsx new file mode 100644 index 00000000..5c0629c6 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/components/phase-details-modal/PhaseDetailsModal.tsx @@ -0,0 +1,586 @@ +import React, { useMemo, useState } from 'react'; +import { Modal, Typography, Divider, Space, Progress, Tag, Row, Col, Card, Statistic, theme, Tooltip, Input, DatePicker, Button, ColorPicker } from 'antd'; +import { CalendarOutlined, CheckCircleOutlined, ClockCircleOutlined, BgColorsOutlined, MinusOutlined, PauseOutlined, DoubleRightOutlined, UserOutlined, EditOutlined, SaveOutlined, CloseOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import { useTranslation } from 'react-i18next'; +import AvatarGroup from '@/components/AvatarGroup'; +import { GanttTask } from '../../types/gantt-types'; + +const { Title, Text } = Typography; + +interface PhaseDetailsModalProps { + open: boolean; + onClose: () => void; + phase: GanttTask | null; + onPhaseUpdate?: (phase: Partial) => void; +} + +const PhaseDetailsModal: React.FC = ({ open, onClose, phase, onPhaseUpdate }) => { + const { t } = useTranslation('gantt/phase-details-modal'); + const { token } = theme.useToken(); + + // Editing state + const [isEditing, setIsEditing] = useState(false); + const [editedPhase, setEditedPhase] = useState>({}); + + // Initialize edited phase when phase changes or editing starts + React.useEffect(() => { + if (phase && isEditing) { + setEditedPhase({ + name: phase.name, + start_date: phase.start_date, + end_date: phase.end_date, + color: phase.color, + }); + } + }, [phase, isEditing]); + + // Calculate phase statistics + const phaseStats = useMemo(() => { + if (!phase || !phase.children) { + return { + totalTasks: 0, + completedTasks: 0, + pendingTasks: 0, + overdueTasks: 0, + completionPercentage: 0, + }; + } + + const totalTasks = phase.children.length; + const completedTasks = phase.children.filter(task => task.progress === 100).length; + const pendingTasks = totalTasks - completedTasks; + + // Calculate overdue tasks (tasks with end_date in the past and progress < 100) + const now = new Date(); + const overdueTasks = phase.children.filter(task => + task.end_date && + new Date(task.end_date) < now && + task.progress < 100 + ).length; + + const completionPercentage = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; + + return { + totalTasks, + completedTasks, + pendingTasks, + overdueTasks, + completionPercentage, + }; + }, [phase]); + + const formatDate = (date: Date | null) => { + if (!date) return t('timeline.notSet'); + return dayjs(date).format('MMM DD, YYYY'); + }; + + const getDateStatus = () => { + if (!phase?.start_date || !phase?.end_date) return 'not-set'; + + const now = new Date(); + const startDate = new Date(phase.start_date); + const endDate = new Date(phase.end_date); + + if (now < startDate) return 'upcoming'; + if (now > endDate) return 'overdue'; + return 'active'; + }; + + const getDateStatusColor = () => { + const status = getDateStatus(); + switch (status) { + case 'upcoming': return '#1890ff'; + case 'active': return '#52c41a'; + case 'overdue': return '#ff4d4f'; + default: return '#8c8c8c'; + } + }; + + const getDateStatusText = () => { + const status = getDateStatus(); + switch (status) { + case 'upcoming': return t('timeline.statusLabels.upcoming'); + case 'active': return t('timeline.statusLabels.active'); + case 'overdue': return t('timeline.statusLabels.overdue'); + default: return t('timeline.statusLabels.notScheduled'); + } + }; + + const getTaskStatus = (task: GanttTask) => { + if (task.progress === 100) return 'completed'; + if (task.end_date && new Date(task.end_date) < new Date() && task.progress < 100) return 'overdue'; + if (task.start_date && new Date(task.start_date) > new Date()) return 'upcoming'; + return 'in-progress'; + }; + + const getTaskStatusText = (status: string) => { + switch (status) { + case 'completed': return 'Completed'; + case 'overdue': return 'Overdue'; + case 'upcoming': return 'Upcoming'; + case 'in-progress': return 'In Progress'; + default: return 'Not Started'; + } + }; + + const getTaskStatusColor = (status: string) => { + switch (status) { + case 'completed': return token.colorSuccess; + case 'overdue': return token.colorError; + case 'upcoming': return token.colorPrimary; + case 'in-progress': return token.colorWarning; + default: return token.colorTextTertiary; + } + }; + + const getPriorityIcon = (priority: string) => { + const priorityLower = priority?.toLowerCase(); + switch (priorityLower) { + case 'low': + return ; + case 'medium': + return ; + case 'high': + return ; + default: + return ; + } + }; + + const getPriorityColor = (priority: string) => { + const priorityLower = priority?.toLowerCase(); + switch (priorityLower) { + case 'low': return '#52c41a'; + case 'medium': return '#faad14'; + case 'high': return '#ff4d4f'; + default: return token.colorTextTertiary; + } + }; + + const convertAssigneesToMembers = (assignees: string[] | undefined) => { + if (!assignees || assignees.length === 0) return []; + + return assignees.map((assignee, index) => ({ + id: `assignee-${index}`, + name: assignee, + color_code: token.colorPrimary, + })); + }; + + const handleSavePhase = () => { + if (phase && onPhaseUpdate && editedPhase) { + onPhaseUpdate({ + id: phase.id, + ...editedPhase, + }); + } + setIsEditing(false); + }; + + const handleCancelEdit = () => { + setIsEditing(false); + setEditedPhase({}); + }; + + const handleStartEdit = () => { + setIsEditing(true); + }; + + if (!phase) return null; + + return ( + +
+ {isEditing ? ( + setEditedPhase(prev => ({ ...prev, color: color.toHexString() }))} + size="small" + showText={false} + /> + ) : ( +
+ )} + {isEditing ? ( + setEditedPhase(prev => ({ ...prev, name: e.target.value }))} + className="font-semibold text-lg" + style={{ border: 'none', padding: 0, background: 'transparent' }} + autoFocus + /> + ) : ( + + {phase.name} + + )} +
+
+ {isEditing ? ( + <> + + + + ) : ( + + )} +
+
+ } + open={open} + onCancel={onClose} + footer={null} + width={1000} + centered + className="phase-details-modal" + > +
+ {/* Left Side - Phase Overview and Stats */} +
+ {/* Phase Overview */} + + + + } + valueStyle={{ color: token.colorText }} + /> + + + } + valueStyle={{ color: token.colorText }} + /> + + + + + + + {/* Date Information */} + + + {t('timeline.title')} +
+ } + className="shadow-sm" + style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }} + > + + + {t('timeline.startDate')} +
+ {isEditing ? ( + setEditedPhase(prev => ({ ...prev, start_date: date?.toDate() || null }))} + size="small" + className="w-full" + placeholder="Select start date" + /> + ) : ( + {formatDate(phase.start_date)} + )} + + + {t('timeline.endDate')} +
+ {isEditing ? ( + setEditedPhase(prev => ({ ...prev, end_date: date?.toDate() || null }))} + size="small" + className="w-full" + placeholder="Select end date" + /> + ) : ( + {formatDate(phase.end_date)} + )} + + + {t('timeline.status')} +
+ {getDateStatusText()} + +
+ + + {/* Task Breakdown */} + + + {t('taskBreakdown.title')} +
+ } + className="shadow-sm" + style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }} + > + + +
+
+ {phaseStats.completedTasks} +
+ {t('taskBreakdown.completed')} +
+ + +
+
+ {phaseStats.pendingTasks} +
+ {t('taskBreakdown.pending')} +
+ + +
+
+ {phaseStats.overdueTasks} +
+ {t('taskBreakdown.overdue')} +
+ +
+ + + {/* Color Information */} + + + {t('phaseColor.title')} +
+ } + className="shadow-sm" + style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }} + > +
+
+
+ {phase.color || token.colorPrimary} +
+ {t('phaseColor.description')} +
+
+ +
+ + {/* Right Side - Task List */} +
+ {phase.children && phase.children.length > 0 ? ( + {t('tasksInPhase.title')} + } + className="shadow-sm flex-1 flex flex-col" + style={{ backgroundColor: token.colorBgContainer, borderColor: token.colorBorder }} + bodyStyle={{ flex: 1, display: 'flex', flexDirection: 'column', padding: '16px' }} + > +
+ {phase.children.map((task) => { + const taskStatus = getTaskStatus(task); + const taskStatusColor = getTaskStatusColor(taskStatus); + + const assigneeMembers = convertAssigneesToMembers(task.assignees); + + return ( +
+ {/* Main row with task info */} +
+ {/* Left side: Status icon, task name, and priority */} +
+ {task.progress === 100 ? ( + + ) : taskStatus === 'overdue' ? ( + + ) : ( + + )} + + + {task.name} + + + {/* Priority Icon */} + {task.priority && ( + +
+ {getPriorityIcon(task.priority)} +
+
+ )} +
+ + {/* Right side: Status tag */} + + {getTaskStatusText(taskStatus)} + +
+ + {/* Bottom row with assignees, progress, and due date */} +
+ {/* Assignees */} +
+ {assigneeMembers.length > 0 ? ( + + ) : ( +
+ + + Unassigned + +
+ )} +
+ + {/* Due Date */} +
+ {task.end_date ? ( +
+ + + {dayjs(task.end_date).format('MMM DD')} + +
+ ) : ( + + No due date + + )} +
+
+
+ ); + })} +
+
+ ) : ( + +
+ + + {t('tasksInPhase.noTasks')} + +
+
+ )} +
+
+ + ); +}; + +export default PhaseDetailsModal; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/gantt/gantt-styles.css b/worklenz-frontend/src/pages/projects/projectView/gantt/gantt-styles.css index b9b4cdac..5f9374b9 100644 --- a/worklenz-frontend/src/pages/projects/projectView/gantt/gantt-styles.css +++ b/worklenz-frontend/src/pages/projects/projectView/gantt/gantt-styles.css @@ -104,6 +104,13 @@ /* Improve visual hierarchy for phase rows */ .gantt-phase-row { position: relative; + cursor: pointer; + transition: all 0.2s ease-in-out; +} + +.gantt-phase-row:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); + transform: translateY(-1px); } .gantt-phase-row::before { @@ -115,6 +122,11 @@ width: 4px; background-color: currentColor; opacity: 0.6; + transition: opacity 0.2s ease-in-out; +} + +.gantt-phase-row:hover::before { + opacity: 0.8; } /* Better hover states */ @@ -141,8 +153,8 @@ .gantt-phase-children { overflow: hidden; transition: - max-height 0.3s ease-in-out, - opacity 0.2s ease-in-out; + max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), + opacity 0.3s ease-in-out; } .gantt-phase-children.collapsed { @@ -151,10 +163,23 @@ } .gantt-phase-children.expanded { - max-height: 1000px; /* Adjust based on expected max children */ + max-height: 2000px; /* Adjust based on expected max children */ opacity: 1; } +/* Individual task transitions */ +.gantt-task-row, +.gantt-add-task-inline { + transition: all 0.2s ease-in-out; +} + +/* Staggered animation for multiple tasks */ +.gantt-task-stagger-1 { animation-delay: 0.05s; } +.gantt-task-stagger-2 { animation-delay: 0.1s; } +.gantt-task-stagger-3 { animation-delay: 0.15s; } +.gantt-task-stagger-4 { animation-delay: 0.2s; } +.gantt-task-stagger-5 { animation-delay: 0.25s; } + /* Expand/collapse icon transitions */ .gantt-expand-icon { transition: transform 0.2s ease-in-out; @@ -166,17 +191,36 @@ /* Task row slide-in animation */ .gantt-task-slide-in { - animation: slideIn 0.3s ease-out; + animation: slideIn 0.3s ease-out forwards; +} + +.gantt-task-slide-out { + animation: slideOut 0.2s ease-in forwards; } @keyframes slideIn { from { opacity: 0; transform: translateY(-10px); + max-height: 0; } to { opacity: 1; transform: translateY(0); + max-height: 36px; /* Height of a task row */ + } +} + +@keyframes slideOut { + from { + opacity: 1; + transform: translateY(0); + max-height: 36px; + } + to { + opacity: 0; + transform: translateY(-10px); + max-height: 0; } } @@ -196,3 +240,31 @@ transform: translateY(0); } } + +/* Timeline task bar transitions */ +.gantt-chart-scroll .gantt-task-slide-in { + animation: slideInTimeline 0.3s ease-out forwards; +} + +@keyframes slideInTimeline { + from { + opacity: 0; + transform: translateY(-10px) scale(0.95); + max-height: 0; + } + to { + opacity: 1; + transform: translateY(0) scale(1); + max-height: 36px; + } +} + +/* Enhanced timeline task bar styling */ +.gantt-chart-scroll .relative { + transition: all 0.2s ease-in-out; +} + +/* Ensure timeline task bars have smooth hover transitions */ +.gantt-chart-scroll .hover\\:bg-gray-50:hover { + transition: background-color 0.15s ease-in-out; +}