diff --git a/worklenz-frontend/public/locales/alb/task-management.json b/worklenz-frontend/public/locales/alb/task-management.json index 477be621..5fe5aef6 100644 --- a/worklenz-frontend/public/locales/alb/task-management.json +++ b/worklenz-frontend/public/locales/alb/task-management.json @@ -1,5 +1,15 @@ { "noTasksInGroup": "Nuk ka detyra në këtë grup", "noTasksInGroupDescription": "Shtoni një detyrë për të filluar", - "addFirstTask": "Shtoni detyrën tuaj të parë" + "addFirstTask": "Shtoni detyrën tuaj të parë", + "openTask": "Hap", + "subtask": "nën-detyrë", + "subtasks": "nën-detyra", + "comment": "koment", + "comments": "komente", + "attachment": "bashkëngjitje", + "attachments": "bashkëngjitje", + "enterSubtaskName": "Shkruani emrin e nën-detyrës...", + "add": "Shto", + "cancel": "Anulo" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/de/task-management.json b/worklenz-frontend/public/locales/de/task-management.json index 35e406e1..45ae2836 100644 --- a/worklenz-frontend/public/locales/de/task-management.json +++ b/worklenz-frontend/public/locales/de/task-management.json @@ -1,5 +1,15 @@ { "noTasksInGroup": "Keine Aufgaben in dieser Gruppe", "noTasksInGroupDescription": "Fügen Sie eine Aufgabe hinzu, um zu beginnen", - "addFirstTask": "Fügen Sie Ihre erste Aufgabe hinzu" + "addFirstTask": "Fügen Sie Ihre erste Aufgabe hinzu", + "openTask": "Öffnen", + "subtask": "Unteraufgabe", + "subtasks": "Unteraufgaben", + "comment": "Kommentar", + "comments": "Kommentare", + "attachment": "Anhang", + "attachments": "Anhänge", + "enterSubtaskName": "Unteraufgabenname eingeben...", + "add": "Hinzufügen", + "cancel": "Abbrechen" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/en/task-management.json b/worklenz-frontend/public/locales/en/task-management.json index d76e4d9b..27df7a05 100644 --- a/worklenz-frontend/public/locales/en/task-management.json +++ b/worklenz-frontend/public/locales/en/task-management.json @@ -1,5 +1,15 @@ { "noTasksInGroup": "No tasks in this group", "noTasksInGroupDescription": "Add a task to get started", - "addFirstTask": "Add your first task" + "addFirstTask": "Add your first task", + "openTask": "Open", + "subtask": "subtask", + "subtasks": "subtasks", + "comment": "comment", + "comments": "comments", + "attachment": "attachment", + "attachments": "attachments", + "enterSubtaskName": "Enter subtask name...", + "add": "Add", + "cancel": "Cancel" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/es/task-management.json b/worklenz-frontend/public/locales/es/task-management.json index e24bcb6d..4b916d5b 100644 --- a/worklenz-frontend/public/locales/es/task-management.json +++ b/worklenz-frontend/public/locales/es/task-management.json @@ -1,5 +1,15 @@ { "noTasksInGroup": "No hay tareas en este grupo", "noTasksInGroupDescription": "Añade una tarea para comenzar", - "addFirstTask": "Añade tu primera tarea" + "addFirstTask": "Añade tu primera tarea", + "openTask": "Abrir", + "subtask": "subtarea", + "subtasks": "subtareas", + "comment": "comentario", + "comments": "comentarios", + "attachment": "adjunto", + "attachments": "adjuntos", + "enterSubtaskName": "Ingresa el nombre de la subtarea...", + "add": "Añadir", + "cancel": "Cancelar" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/pt/task-management.json b/worklenz-frontend/public/locales/pt/task-management.json index a0b23c6f..5f9bc0d4 100644 --- a/worklenz-frontend/public/locales/pt/task-management.json +++ b/worklenz-frontend/public/locales/pt/task-management.json @@ -1,5 +1,15 @@ { "noTasksInGroup": "Nenhuma tarefa neste grupo", "noTasksInGroupDescription": "Adicione uma tarefa para começar", - "addFirstTask": "Adicione sua primeira tarefa" + "addFirstTask": "Adicione sua primeira tarefa", + "openTask": "Abrir", + "subtask": "subtarefa", + "subtasks": "subtarefas", + "comment": "comentário", + "comments": "comentários", + "attachment": "anexo", + "attachments": "anexos", + "enterSubtaskName": "Digite o nome da subtarefa...", + "add": "Adicionar", + "cancel": "Cancelar" } \ No newline at end of file diff --git a/worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx b/worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx index ed7e35df..2deafb5d 100644 --- a/worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx +++ b/worklenz-frontend/src/components/project-list/project-group/project-group-list.tsx @@ -55,6 +55,20 @@ const ProjectGroupList: React.FC = ({ loading, t }) => { + // Preload project view components on hover for smoother navigation + const handleProjectHover = React.useCallback((project_id: string) => { + if (project_id) { + // Preload the project view route to reduce loading time + import('@/pages/projects/projectView/project-view').catch(() => { + // Silently fail if preload doesn't work + }); + + // Also preload critical task management components + import('@/components/task-management/task-list-board').catch(() => { + // Silently fail if preload doesn't work + }); + } + }, []); const { token } = theme.useToken(); const themeMode = useAppSelector(state => state.themeReducer.mode); const dispatch = useAppDispatch(); @@ -360,6 +374,8 @@ const ProjectGroupList: React.FC = ({ if (actionButtons) { actionButtons.style.opacity = '1'; } + // Preload components for smoother navigation + handleProjectHover(project.id); }} onMouseLeave={(e) => { Object.assign(e.currentTarget.style, styles.projectCard); diff --git a/worklenz-frontend/src/components/suspense-fallback/suspense-fallback.tsx b/worklenz-frontend/src/components/suspense-fallback/suspense-fallback.tsx index d533a4a2..4f346af0 100644 --- a/worklenz-frontend/src/components/suspense-fallback/suspense-fallback.tsx +++ b/worklenz-frontend/src/components/suspense-fallback/suspense-fallback.tsx @@ -7,6 +7,8 @@ export const SuspenseFallback = memo(() => {
import('./improved-task-filters') -); - - +// Import the improved TaskListFilters component synchronously to avoid suspense +import ImprovedTaskFilters from './improved-task-filters'; interface TaskListBoardProps { projectId: string; @@ -393,9 +389,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' {/* Task Filters */}
- Loading filters...
}> - - +
{/* Virtualized Task Groups Container */} diff --git a/worklenz-frontend/src/components/task-management/task-row-optimized.css b/worklenz-frontend/src/components/task-management/task-row-optimized.css index 6a0322b4..57e811d4 100644 --- a/worklenz-frontend/src/components/task-management/task-row-optimized.css +++ b/worklenz-frontend/src/components/task-management/task-row-optimized.css @@ -235,4 +235,105 @@ .task-row-optimized * { box-sizing: border-box; +} + +/* Task row hover effects for better performance */ +.task-cell-container:hover .task-open-button { + opacity: 1 !important; +} + +.task-open-button { + opacity: 0; + transition: opacity 0.2s ease-in-out; +} + +/* Expand icon smart visibility */ +.expand-icon-container { + transition: opacity 0.2s ease-in-out; +} + +/* Always show expand icon if task has subtasks */ +.expand-icon-container.has-subtasks { + opacity: 1; +} + +.expand-icon-container.has-subtasks .expand-toggle-btn { + opacity: 0.8; +} + +.task-cell-container:hover .expand-icon-container.has-subtasks .expand-toggle-btn { + opacity: 1; +} + +/* Show expand icon on hover for tasks without subtasks (for adding subtasks) */ +.expand-icon-container.hover-only { + opacity: 0; +} + +.task-cell-container:hover .expand-icon-container.hover-only { + opacity: 1; +} + +.expand-icon-container.hover-only .expand-toggle-btn { + opacity: 0.6; +} + +.task-cell-container:hover .expand-icon-container.hover-only .expand-toggle-btn { + opacity: 1; +} + +/* Add subtask row styling */ +.add-subtask-row { + opacity: 0; + max-height: 0; + overflow: hidden; + transition: all 0.3s ease-in-out; + transform: translateY(-10px); +} + +.add-subtask-row.visible { + opacity: 1; + max-height: 60px; + transform: translateY(0); +} + +.add-subtask-input { + transition: all 0.2s ease-in-out; +} + +.add-subtask-input:focus { + transform: scale(1.02); + box-shadow: 0 2px 8px rgba(59, 130, 246, 0.15); +} + +/* Dark mode add subtask row */ +.dark .add-subtask-row { + background-color: #1f2937; + border-color: #374151; +} + +.dark .add-subtask-input { + background-color: #374151; + border-color: #4b5563; + color: #f3f4f6; +} + +.dark .add-subtask-input:focus { + border-color: #60a5fa; + box-shadow: 0 2px 8px rgba(96, 165, 250, 0.25); +} + +/* Task indicators hover effects */ +.task-indicators .indicator-badge { + transition: all 0.2s ease-in-out; +} + +.task-indicators .indicator-badge:hover { + transform: translateY(-1px); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); +} + +/* Dark mode specific hover effects */ +.dark .task-indicators .indicator-badge:hover { + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3); } \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/task-row.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx index de8731b1..41c40d8d 100644 --- a/worklenz-frontend/src/components/task-management/task-row.tsx +++ b/worklenz-frontend/src/components/task-management/task-row.tsx @@ -16,6 +16,8 @@ import { UserOutlined, type InputRef } from './antd-imports'; +import { DownOutlined, RightOutlined, ExpandAltOutlined, DoubleRightOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; import { Task } from '@/types/task-management.types'; import { RootState } from '@/app/store'; import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tooltip } from '@/components'; @@ -185,7 +187,10 @@ const TaskRow: React.FC = React.memo(({ // Edit task name state const [editTaskName, setEditTaskName] = useState(false); const [taskName, setTaskName] = useState(task.title || ''); - const inputRef = useRef(null); + const [showAddSubtask, setShowAddSubtask] = useState(false); + const [newSubtaskName, setNewSubtaskName] = useState(''); + const inputRef = useRef(null); + const addSubtaskInputRef = useRef(null); const wrapperRef = useRef(null); // PERFORMANCE OPTIMIZATION: Intersection Observer for lazy loading @@ -244,6 +249,9 @@ const TaskRow: React.FC = React.memo(({ // Get theme from Redux store - memoized selector const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark'); + + // Translation hook + const { t } = useTranslation('task-management'); // PERFORMANCE OPTIMIZATION: Only setup click outside detection when editing useEffect(() => { @@ -265,7 +273,7 @@ const TaskRow: React.FC = React.memo(({ // Optimized task name save handler const handleTaskNameSave = useCallback(() => { - const newTaskName = inputRef.current?.value?.trim(); + const newTaskName = taskName?.trim(); if (newTaskName && connected && newTaskName !== task.title) { socket?.emit( SocketEvents.TASK_NAME_CHANGE.toString(), @@ -277,7 +285,30 @@ const TaskRow: React.FC = React.memo(({ ); } setEditTaskName(false); - }, [connected, socket, task.id, task.title]); + }, [connected, socket, task.id, task.title, taskName]); + + // Handle adding new subtask + const handleAddSubtask = useCallback(() => { + const subtaskName = newSubtaskName?.trim(); + if (subtaskName && connected) { + socket?.emit( + SocketEvents.TASK_NAME_CHANGE.toString(), // Using existing event for now + JSON.stringify({ + name: subtaskName, + parent_task_id: task.id, + project_id: projectId, + }) + ); + setNewSubtaskName(''); + setShowAddSubtask(false); + } + }, [newSubtaskName, connected, socket, task.id, projectId]); + + // Handle canceling add subtask + const handleCancelAddSubtask = useCallback(() => { + setNewSubtaskName(''); + setShowAddSubtask(false); + }, []); // Optimized style calculations with better memoization const dragStyle = useMemo(() => { @@ -302,6 +333,18 @@ const TaskRow: React.FC = React.memo(({ onToggleSubtasks?.(task.id); }, [onToggleSubtasks, task.id]); + // Handle expand/collapse or add subtask + const handleExpandClick = useCallback(() => { + // For now, just toggle add subtask row for all tasks + setShowAddSubtask(!showAddSubtask); + if (!showAddSubtask) { + // Focus the input after state update + setTimeout(() => { + addSubtaskInputRef.current?.focus(); + }, 100); + } + }, [showAddSubtask]); + // Optimized date handling with better memoization const dateValues = useMemo(() => ({ start: task.startDate ? dayjs(task.startDate) : undefined, @@ -494,26 +537,46 @@ const TaskRow: React.FC = React.memo(({ return (
-
-
+
+ {/* Left section with expand icon and task content */} +
+ {/* Expand/Collapse Icon - Smart visibility */} +
+ +
+ + {/* Task name and input */}
{editTaskName ? ( - setTaskName(e.target.value)} onBlur={handleTaskNameSave} - onKeyDown={(e) => { - if (e.key === 'Enter') { - handleTaskNameSave(); - } - }} + onPressEnter={handleTaskNameSave} + variant="borderless" style={{ - color: isDarkMode ? '#ffffff' : '#262626' + color: isDarkMode ? '#ffffff' : '#262626', + padding: 0 }} autoFocus /> @@ -528,7 +591,90 @@ const TaskRow: React.FC = React.memo(({ )}
+ + {/* Indicators section */} + {!editTaskName && ( +
+ {/* Subtasks count */} + {task.subtasks_count && task.subtasks_count > 0 && ( + +
{ + e.preventDefault(); + e.stopPropagation(); + handleToggleSubtasks?.(); + }} + > + {task.subtasks_count} + +
+
+ )} + + {/* Comments indicator */} + {task.comments_count && task.comments_count > 0 && ( + +
+ + {task.comments_count} +
+
+ )} + + {/* Attachments indicator */} + {task.attachments_count && task.attachments_count > 0 && ( + +
+ + {task.attachments_count} +
+
+ )} +
+ )}
+ + {/* Right section with open button - CSS hover only */} + {!editTaskName && ( +
+ +
+ )}
); @@ -767,6 +913,106 @@ const TaskRow: React.FC = React.memo(({
)}
+ + {/* Add Subtask Row */} + {showAddSubtask && ( +
+
+ {/* Fixed Columns for Add Subtask */} + {fixedColumns && fixedColumns.length > 0 && ( +
sum + col.width, 0), + }} + > + {fixedColumns.map((col, index) => { + const isLast = index === fixedColumns.length - 1; + const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`; + + if (col.key === 'task') { + return ( +
+
+ setNewSubtaskName(e.target.value)} + onPressEnter={handleAddSubtask} + onBlur={handleCancelAddSubtask} + className={`add-subtask-input flex-1 ${ + isDarkMode + ? 'bg-gray-700 border-gray-600 text-gray-200' + : 'bg-white border-gray-300 text-gray-900' + }`} + size="small" + autoFocus + /> +
+ + +
+
+
+ ); + } else { + return ( +
+ ); + } + })} +
+ )} + + {/* Scrollable Columns for Add Subtask */} + {scrollableColumns && scrollableColumns.length > 0 && ( +
sum + col.width, 0) + }} + > + {scrollableColumns.map((col, index) => { + const isLast = index === scrollableColumns.length - 1; + const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`; + + return ( +
+ ); + })} +
+ )} +
+
+ )}
); }, (prevProps, nextProps) => { diff --git a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts index 74ba350c..6bdbebaa 100644 --- a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts +++ b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts @@ -114,6 +114,9 @@ const taskDrawerSlice = createSlice({ state.taskFormViewModel.task.schedule_id = schedule_id; } }, + resetTaskDrawer: (state) => { + return initialState; + }, }, extraReducers: builder => { builder.addCase(fetchTask.pending, state => { @@ -142,6 +145,7 @@ export const { setTaskLabels, setTaskSubscribers, setTimeLogEditing, - setTaskRecurringSchedule + setTaskRecurringSchedule, + resetTaskDrawer } = taskDrawerSlice.actions; export default taskDrawerSlice.reducer; diff --git a/worklenz-frontend/src/hooks/usePerformanceOptimization.ts b/worklenz-frontend/src/hooks/usePerformanceOptimization.ts new file mode 100644 index 00000000..aa2e7d73 --- /dev/null +++ b/worklenz-frontend/src/hooks/usePerformanceOptimization.ts @@ -0,0 +1,163 @@ +import { useCallback, useMemo, useRef, useEffect, useState } from 'react'; +import { useAppSelector } from './useAppSelector'; +import { debounce, throttle } from 'lodash'; + +// Performance optimization utilities +export const usePerformanceOptimization = () => { + const renderCountRef = useRef(0); + const lastRenderTimeRef = useRef(0); + + // Track render performance + const trackRender = useCallback((componentName: string) => { + renderCountRef.current += 1; + const now = performance.now(); + const timeSinceLastRender = now - lastRenderTimeRef.current; + lastRenderTimeRef.current = now; + + if (process.env.NODE_ENV === 'development') { + console.log(`[${componentName}] Render #${renderCountRef.current}, Time since last: ${timeSinceLastRender.toFixed(2)}ms`); + + if (timeSinceLastRender < 16) { // Less than 60fps + console.warn(`[${componentName}] Potential over-rendering detected`); + } + } + }, []); + + // Debounced callback creator + const createDebouncedCallback = useCallback( any>( + callback: T, + delay: number = 300 + ) => { + return debounce(callback, delay); + }, []); + + // Throttled callback creator + const createThrottledCallback = useCallback( any>( + callback: T, + delay: number = 100 + ) => { + return throttle(callback, delay); + }, []); + + return { + trackRender, + createDebouncedCallback, + createThrottledCallback, + }; +}; + +// Optimized selector hook to prevent unnecessary re-renders +export const useOptimizedSelector = ( + selector: (state: any) => T, + equalityFn?: (left: T, right: T) => boolean +) => { + const defaultEqualityFn = useCallback((left: T, right: T) => { + // Deep equality check for objects and arrays + if (typeof left === 'object' && typeof right === 'object') { + return JSON.stringify(left) === JSON.stringify(right); + } + return left === right; + }, []); + + return useAppSelector(selector, equalityFn || defaultEqualityFn); +}; + +// Memoized component props +export const useMemoizedProps = >(props: T): T => { + return useMemo(() => props, Object.values(props)); +}; + +// Optimized event handlers +export const useOptimizedEventHandlers = any>>( + handlers: T +) => { + return useMemo(() => { + const optimizedHandlers = {} as any; + + Object.entries(handlers).forEach(([key, handler]) => { + optimizedHandlers[key] = useCallback(handler, [handler]); + }); + + return optimizedHandlers as T; + }, [handlers]); +}; + +// Virtual scrolling utilities +export const useVirtualScrolling = ( + itemCount: number, + itemHeight: number, + containerHeight: number +) => { + const visibleRange = useMemo(() => { + const startIndex = Math.floor(window.scrollY / itemHeight); + const endIndex = Math.min( + startIndex + Math.ceil(containerHeight / itemHeight) + 1, + itemCount + ); + + return { startIndex: Math.max(0, startIndex), endIndex }; + }, [itemCount, itemHeight, containerHeight]); + + return visibleRange; +}; + +// Image lazy loading hook +export const useLazyLoading = (threshold: number = 0.1) => { + const observerRef = useRef(null); + const [isVisible, setIsVisible] = useState(false); + + const targetRef = useCallback((node: HTMLElement | null) => { + if (observerRef.current) observerRef.current.disconnect(); + + if (node) { + observerRef.current = new IntersectionObserver( + ([entry]) => { + if (entry.isIntersecting) { + setIsVisible(true); + observerRef.current?.disconnect(); + } + }, + { threshold } + ); + observerRef.current.observe(node); + } + }, [threshold]); + + useEffect(() => { + return () => { + observerRef.current?.disconnect(); + }; + }, []); + + return { targetRef, isVisible }; +}; + +// Memory optimization for large datasets +export const useMemoryOptimization = ( + data: T[], + maxCacheSize: number = 1000 +) => { + const cacheRef = useRef(new Map()); + + const optimizedData = useMemo(() => { + if (data.length <= maxCacheSize) { + return data; + } + + // Keep only the most recently accessed items + const cache = cacheRef.current; + const recentData = data.slice(0, maxCacheSize); + + // Clear old cache entries + cache.clear(); + recentData.forEach((item, index) => { + cache.set(String(index), item); + }); + + return recentData; + }, [data, maxCacheSize]); + + return optimizedData; +}; + +export default usePerformanceOptimization; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/project-list.tsx b/worklenz-frontend/src/pages/projects/project-list.tsx index 8920f078..0fa3e377 100644 --- a/worklenz-frontend/src/pages/projects/project-list.tsx +++ b/worklenz-frontend/src/pages/projects/project-list.tsx @@ -437,6 +437,21 @@ const ProjectList: React.FC = () => { } }, [navigate]); + // Preload project view components on hover for smoother navigation + const handleProjectHover = useCallback((project_id: string | undefined) => { + if (project_id) { + // Preload the project view route to reduce loading time + import('@/pages/projects/projectView/project-view').catch(() => { + // Silently fail if preload doesn't work + }); + + // Also preload critical task management components + import('@/components/task-management/task-list-board').catch(() => { + // Silently fail if preload doesn't work + }); + } + }, []); + // Define table columns directly in the component to avoid hooks order issues const tableColumns: ColumnsType = useMemo( () => [ @@ -629,6 +644,7 @@ const ProjectList: React.FC = () => { locale={{ emptyText }} onRow={record => ({ onClick: () => navigateToProject(record.id, record.team_member_default_view), + onMouseEnter: () => handleProjectHover(record.id), })} /> ) : ( diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.css b/worklenz-frontend/src/pages/projects/projectView/project-view.css index 8b468b8a..c61b7af4 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.css +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.css @@ -4,3 +4,222 @@ height: 8px; width: 8px; } + +/* Enhanced Project View Tab Styles - Compact */ +.project-view-tabs { + margin-top: 16px; +} + +/* Remove default tab border */ +.project-view-tabs .ant-tabs-nav::before { + border: none !important; +} + +/* Tab bar container */ +.project-view-tabs .ant-tabs-nav { + margin-bottom: 8px; + background: transparent; + padding: 0 12px; +} + +/* Individual tab styling - Compact */ +.project-view-tabs .ant-tabs-tab { + position: relative; + margin: 0 4px 0 0; + padding: 8px 16px; + border-radius: 6px 6px 0 0; + background: transparent; + border: 1px solid transparent; + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + font-weight: 500; + font-size: 13px; + min-height: 36px; + display: flex; + align-items: center; +} + +/* Light mode tab styles */ +[data-theme="default"] .project-view-tabs .ant-tabs-tab { + color: #64748b; + background: #f8fafc; + border-color: #e2e8f0; +} + +[data-theme="default"] .project-view-tabs .ant-tabs-tab:hover { + color: #3b82f6; + background: #eff6ff; + border-color: #bfdbfe; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15); +} + +[data-theme="default"] .project-view-tabs .ant-tabs-tab-active { + color: #1e40af !important; + background: #ffffff !important; + border-color: #3b82f6 !important; + border-bottom-color: #ffffff !important; + box-shadow: 0 -2px 8px rgba(59, 130, 246, 0.1), 0 4px 16px rgba(59, 130, 246, 0.1); + z-index: 1; +} + +/* Dark mode tab styles */ +[data-theme="dark"] .project-view-tabs .ant-tabs-tab { + color: #94a3b8; + background: #1e293b; + border-color: #334155; +} + +[data-theme="dark"] .project-view-tabs .ant-tabs-tab:hover { + color: #60a5fa; + background: #1e3a8a; + border-color: #3b82f6; + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(96, 165, 250, 0.2); +} + +[data-theme="dark"] .project-view-tabs .ant-tabs-tab-active { + color: #60a5fa !important; + background: #0f172a !important; + border-color: #3b82f6 !important; + border-bottom-color: #0f172a !important; + box-shadow: 0 -2px 8px rgba(96, 165, 250, 0.15), 0 4px 16px rgba(96, 165, 250, 0.15); + z-index: 1; +} + +/* Tab content area - Compact */ +.project-view-tabs .ant-tabs-content-holder { + background: transparent; + border-radius: 6px; + position: relative; + z-index: 0; + margin-top: 4px; +} + +[data-theme="default"] .project-view-tabs .ant-tabs-content-holder { + background: #ffffff; + border: 1px solid #e2e8f0; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); +} + +[data-theme="dark"] .project-view-tabs .ant-tabs-content-holder { + background: #0f172a; + border: 1px solid #334155; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); +} + +.project-view-tabs .ant-tabs-tabpane { + padding: 0; + min-height: 300px; +} + +/* Pin button styling - Compact */ +.project-view-tabs .borderless-icon-btn { + margin-left: 6px; + padding: 2px; + border-radius: 3px; + transition: all 0.2s ease; + opacity: 0.7; +} + +.project-view-tabs .borderless-icon-btn:hover { + opacity: 1; + transform: scale(1.05); +} + +[data-theme="default"] .project-view-tabs .borderless-icon-btn:hover { + background: rgba(59, 130, 246, 0.1) !important; +} + +[data-theme="dark"] .project-view-tabs .borderless-icon-btn:hover { + background: rgba(96, 165, 250, 0.1) !important; +} + +/* Pinned tab indicator */ +.project-view-tabs .ant-tabs-tab-active .borderless-icon-btn { + opacity: 1; +} + +[data-theme="default"] .project-view-tabs .ant-tabs-tab-active .borderless-icon-btn { + background: rgba(59, 130, 246, 0.1) !important; +} + +[data-theme="dark"] .project-view-tabs .ant-tabs-tab-active .borderless-icon-btn { + background: rgba(96, 165, 250, 0.1) !important; +} + +/* Tab label flex container */ +.project-view-tabs .ant-tabs-tab .ant-tabs-tab-btn { + display: flex; + align-items: center; + width: 100%; +} + +/* Responsive adjustments - Compact */ +@media (max-width: 768px) { + .project-view-tabs .ant-tabs-nav { + padding: 0 8px; + } + + .project-view-tabs .ant-tabs-tab { + margin: 0 2px 0 0; + padding: 6px 12px; + font-size: 12px; + min-height: 32px; + } + + .project-view-tabs .borderless-icon-btn { + margin-left: 4px; + padding: 1px; + } +} + +@media (max-width: 480px) { + .project-view-tabs .ant-tabs-tab { + padding: 6px 10px; + font-size: 11px; + min-height: 30px; + } + + .project-view-tabs .borderless-icon-btn { + display: none; /* Hide pin buttons on very small screens */ + } +} + +/* Animation for tab switching */ +.project-view-tabs .ant-tabs-content { + position: relative; +} + +.project-view-tabs .ant-tabs-tabpane-active { + animation: fadeInUp 0.3s ease-out; +} + +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* Focus states for accessibility - Compact */ +.project-view-tabs .ant-tabs-tab:focus-visible { + outline: 1px solid #3b82f6; + outline-offset: 1px; + border-radius: 6px; +} + +[data-theme="dark"] .project-view-tabs .ant-tabs-tab:focus-visible { + outline-color: #60a5fa; +} + +/* Loading state for tab content */ +.project-view-tabs .ant-tabs-tabpane .suspense-fallback { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; +} diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx index c6e88633..77428cbb 100644 --- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import React, { useEffect, useState, useMemo, useCallback, Suspense } from 'react'; import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom'; import { createPortal } from 'react-dom'; @@ -33,32 +33,57 @@ import { resetFields } from '@/features/task-management/taskListFields.slice'; import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice'; import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; import { tabItems } from '@/lib/project/project-view-constants'; -import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice'; +import { setSelectedTaskId, setShowTaskDrawer, resetTaskDrawer } from '@/features/task-drawer/task-drawer.slice'; +import { resetState as resetEnhancedKanbanState } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { setProjectId as setInsightsProjectId } from '@/features/projects/insights/project-insights.slice'; +import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback'; +// Import critical components synchronously to avoid suspense interruptions +import TaskDrawer from '@components/task-drawer/task-drawer'; + +// Lazy load non-critical components with better error handling const DeleteStatusDrawer = React.lazy(() => import('@/components/project-task-filters/delete-status-drawer/delete-status-drawer')); -const PhaseDrawer = React.lazy(() => import('@features/projects/singleProject/phase/PhaseDrawer')); -const StatusDrawer = React.lazy( - () => import('@/components/project-task-filters/create-status-drawer/create-status-drawer') -); -const ProjectMemberDrawer = React.lazy( - () => import('@/components/projects/project-member-invite-drawer/project-member-invite-drawer') -); -const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer')); +const PhaseDrawer = React.lazy(() => import('@/features/projects/singleProject/phase/PhaseDrawer')); +const StatusDrawer = React.lazy(() => import('@/components/project-task-filters/create-status-drawer/create-status-drawer')); +const ProjectMemberDrawer = React.lazy(() => import('@/components/projects/project-member-invite-drawer/project-member-invite-drawer')); -const ProjectView = () => { + + +const ProjectView = React.memo(() => { const location = useLocation(); const navigate = useNavigate(); const dispatch = useAppDispatch(); const [searchParams] = useSearchParams(); const { projectId } = useParams(); + // Memoized selectors to prevent unnecessary re-renders const selectedProject = useAppSelector(state => state.projectReducer.project); + const projectLoading = useAppSelector(state => state.projectReducer.projectLoading); + + // Optimize document title updates useDocumentTitle(selectedProject?.name || 'Project View'); - const [activeTab, setActiveTab] = useState(searchParams.get('tab') || tabItems[0].key); - const [pinnedTab, setPinnedTab] = useState(searchParams.get('pinned_tab') || ''); - const [taskid, setTaskId] = useState(searchParams.get('task') || ''); + + // Memoize URL params to prevent unnecessary state updates + const urlParams = useMemo(() => ({ + tab: searchParams.get('tab') || tabItems[0].key, + pinnedTab: searchParams.get('pinned_tab') || '', + taskId: searchParams.get('task') || '' + }), [searchParams]); - const resetProjectData = useCallback(() => { + const [activeTab, setActiveTab] = useState(urlParams.tab); + const [pinnedTab, setPinnedTab] = useState(urlParams.pinnedTab); + const [taskid, setTaskId] = useState(urlParams.taskId); + const [isInitialized, setIsInitialized] = useState(false); + + // Update local state when URL params change + useEffect(() => { + setActiveTab(urlParams.tab); + setPinnedTab(urlParams.pinnedTab); + setTaskId(urlParams.taskId); + }, [urlParams]); + + // Comprehensive cleanup function for when leaving project view entirely + const resetAllProjectData = useCallback(() => { dispatch(setProjectId(null)); dispatch(resetStatuses()); dispatch(deselectAll()); @@ -68,140 +93,259 @@ const ProjectView = () => { dispatch(resetGrouping()); dispatch(resetSelection()); dispatch(resetFields()); + dispatch(resetEnhancedKanbanState()); + + // Reset project insights + dispatch(setInsightsProjectId('')); + + // Reset task drawer completely + dispatch(resetTaskDrawer()); }, [dispatch]); + // Effect for handling component unmount (leaving project view entirely) useEffect(() => { - if (projectId) { - dispatch(setProjectId(projectId)); - dispatch(getProject(projectId)).then((res: any) => { - if (!res.payload) { - navigate('/worklenz/projects'); - return; - } - dispatch(fetchStatuses(projectId)); - dispatch(fetchLabels()); - }); + // This cleanup only runs when the component unmounts + return () => { + resetAllProjectData(); + }; + }, []); // Empty dependency array - only runs on mount/unmount + + // Effect for handling route changes (when navigating away from project view) + useEffect(() => { + const currentPath = location.pathname; + + // If we're not on a project view path, clean up + if (!currentPath.includes('/worklenz/projects/') || currentPath === '/worklenz/projects') { + resetAllProjectData(); } - if (taskid) { - dispatch(setSelectedTaskId(taskid || '')); + }, [location.pathname, resetAllProjectData]); + + // Optimized project data loading with better error handling and performance tracking + useEffect(() => { + if (projectId && !isInitialized) { + const loadProjectData = async () => { + try { + // Clean up previous project data before loading new project + dispatch(resetTaskListData()); + dispatch(resetBoardData()); + dispatch(resetTaskManagement()); + dispatch(resetEnhancedKanbanState()); + dispatch(deselectAll()); + + // Load new project data + dispatch(setProjectId(projectId)); + + // Load project and essential data in parallel + const [projectResult] = await Promise.allSettled([ + dispatch(getProject(projectId)), + dispatch(fetchStatuses(projectId)), + dispatch(fetchLabels()) + ]); + + if (projectResult.status === 'fulfilled' && !projectResult.value.payload) { + navigate('/worklenz/projects'); + return; + } + + setIsInitialized(true); + } catch (error) { + console.error('Error loading project data:', error); + navigate('/worklenz/projects'); + } + }; + + loadProjectData(); + } + }, [dispatch, navigate, projectId]); + + // Reset initialization when project changes + useEffect(() => { + setIsInitialized(false); + }, [projectId]); + + // Effect for handling task drawer opening from URL params + useEffect(() => { + if (taskid && isInitialized) { + dispatch(setSelectedTaskId(taskid)); dispatch(setShowTaskDrawer(true)); } + }, [dispatch, taskid, isInitialized]); - return () => { - resetProjectData(); - }; - }, [dispatch, navigate, projectId, taskid, resetProjectData]); - + // Optimized pin tab function with better error handling const pinToDefaultTab = useCallback(async (itemKey: string) => { if (!itemKey || !projectId) return; - const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD'; - const res = await projectsApiService.updateDefaultTab({ - project_id: projectId, - default_view: defaultView, - }); - - if (res.done) { - setPinnedTab(itemKey); - tabItems.forEach(item => { - if (item.key === itemKey) { - item.isPinned = true; - } else { - item.isPinned = false; - } + try { + const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD'; + const res = await projectsApiService.updateDefaultTab({ + project_id: projectId, + default_view: defaultView, }); - navigate({ - pathname: `/worklenz/projects/${projectId}`, - search: new URLSearchParams({ - tab: activeTab, - pinned_tab: itemKey - }).toString(), - }); + if (res.done) { + setPinnedTab(itemKey); + + // Optimize tab items update + tabItems.forEach(item => { + item.isPinned = item.key === itemKey; + }); + + navigate({ + pathname: `/worklenz/projects/${projectId}`, + search: new URLSearchParams({ + tab: activeTab, + pinned_tab: itemKey + }).toString(), + }, { replace: true }); // Use replace to avoid history pollution + } + } catch (error) { + console.error('Error updating default tab:', error); } }, [projectId, activeTab, navigate]); + // Optimized tab change handler const handleTabChange = useCallback((key: string) => { setActiveTab(key); dispatch(setProjectView(key === 'board' ? 'kanban' : 'list')); + + // Use replace for better performance and history management navigate({ pathname: location.pathname, search: new URLSearchParams({ tab: key, pinned_tab: pinnedTab, }).toString(), - }); + }, { replace: true }); }, [dispatch, location.pathname, navigate, pinnedTab]); - const tabMenuItems = useMemo(() => tabItems.map(item => ({ - key: item.key, - label: ( - - {item.label} - {item.key === 'tasks-list' || item.key === 'board' ? ( - -