+ {visibleColumns.map((column) => {
+ const columnStyle: ColumnStyle = {
+ width: column.width,
+ };
+
+ return (
+
+ {column.id === 'dragHandle' ? (
+
+ ) : (
+ column.label
+ )}
+
+ );
+ })}
+
+ ), [visibleColumns]);
+
+ // Render functions
+ const renderGroup = useCallback((groupIndex: number) => {
+ const group = virtuosoGroups[groupIndex];
+ const isGroupEmpty = group.count === 0;
+
+ return (
+ Loading...
;
+ if (error) return = memo(({ task, visibleColumns }) => {
+ // Drag and drop functionality
+ const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
+ id: task.id,
+ data: {
+ type: 'task',
+ task,
+ },
+ });
+
+ // Memoize style object to prevent unnecessary re-renders
+ const style = useMemo(() => ({
+ transform: CSS.Transform.toString(transform),
+ transition,
+ opacity: isDragging ? 0.5 : 1,
+ }), [transform, transition, isDragging]);
+
+ // Get dark mode from Redux state
+ const themeMode = useAppSelector(state => state.themeReducer.mode);
+ const isDarkMode = themeMode === 'dark';
+
+ // Memoize task display name
+ const taskDisplayName = useMemo(() => getTaskDisplayName(task), [task.title, task.name, task.task_key]);
+
+ // Memoize converted task for AssigneeSelector to prevent recreation
+ const convertedTask = useMemo(() => ({
+ id: task.id,
+ name: taskDisplayName,
+ task_key: task.task_key || taskDisplayName,
+ assignees:
+ task.assignee_names?.map((assignee: InlineMember, index: number) => ({
+ team_member_id: assignee.team_member_id || `assignee-${index}`,
+ id: assignee.team_member_id || `assignee-${index}`,
+ project_member_id: assignee.team_member_id || `assignee-${index}`,
+ name: assignee.name || '',
+ })) || [],
+ parent_task_id: task.parent_task_id,
+ status_id: undefined,
+ project_id: undefined,
+ manual_progress: undefined,
+ }), [task.id, taskDisplayName, task.task_key, task.assignee_names, task.parent_task_id]);
+
+ // Memoize formatted dates
+ const formattedDueDate = useMemo(() =>
+ task.dueDate ? formatDate(task.dueDate) : null,
+ [task.dueDate]
+ );
+
+ const formattedStartDate = useMemo(() =>
+ task.startDate ? formatDate(task.startDate) : null,
+ [task.startDate]
+ );
+
+ const formattedCompletedDate = useMemo(() =>
+ task.completedAt ? formatDate(task.completedAt) : null,
+ [task.completedAt]
+ );
+
+ const formattedCreatedDate = useMemo(() =>
+ task.created_at ? formatDate(task.created_at) : null,
+ [task.created_at]
+ );
+
+ const formattedUpdatedDate = useMemo(() =>
+ task.updatedAt ? formatDate(task.updatedAt) : null,
+ [task.updatedAt]
+ );
+
+ // Memoize status style
+ const statusStyle = useMemo(() => ({
+ backgroundColor: task.statusColor ? `${task.statusColor}20` : 'rgb(229, 231, 235)',
+ color: task.statusColor || 'rgb(31, 41, 55)',
+ }), [task.statusColor]);
+
+ // Memoize priority style
+ const priorityStyle = useMemo(() => ({
+ backgroundColor: task.priorityColor ? `${task.priorityColor}20` : 'rgb(229, 231, 235)',
+ color: task.priorityColor || 'rgb(31, 41, 55)',
+ }), [task.priorityColor]);
+
+ // Memoize labels display
+ const labelsDisplay = useMemo(() => {
+ if (!task.labels || task.labels.length === 0) return null;
+
+ const visibleLabels = task.labels.slice(0, 2);
+ const remainingCount = task.labels.length - 2;
+
+ return {
+ visibleLabels,
+ remainingCount: remainingCount > 0 ? remainingCount : null,
+ };
+ }, [task.labels]);
+
+ const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number) => {
+ const baseStyle = { width };
+
+ switch (columnId) {
+ case 'dragHandle':
+ return (
+
+
+
+ );
+
+ case 'taskKey':
+ return (
+
+
+ {task.task_key || 'N/A'}
+
+
+ );
+
+ case 'title':
+ return (
+
+
+ {taskDisplayName}
+
+
+ );
+
+ case 'status':
+ return (
+
+
+ {task.status}
+
+
+ );
+
+ case 'assignees':
+ return (
+
+ );
+
+ case 'priority':
+ return (
+
+
+ {task.priority}
+
+
+ );
+
+ case 'dueDate':
+ return (
+
+ {formattedDueDate && (
+
+ {formattedDueDate}
+
+ )}
+
+ );
+
+ case 'progress':
+ return (
+
+ {task.progress !== undefined &&
+ task.progress >= 0 &&
+ (task.progress === 100 ? (
+
+
+
+ ) : (
+
+ ))}
+
+ );
+
+ case 'labels':
+ return (
+
+ {labelsDisplay?.visibleLabels.map((label, index) => (
+
+ {label.name}
+
+ ))}
+ {labelsDisplay?.remainingCount && (
+
+ +{labelsDisplay.remainingCount}
+
+ )}
+
+ );
+
+ case 'phase':
+ return (
+
+
+ {task.phase}
+
+
+ );
+
+ case 'timeTracking':
+ return (
+
+
+
+ {task.timeTracking?.logged || 0}h
+
+ {task.timeTracking?.estimated && (
+
+ /{task.timeTracking.estimated}h
+
+ )}
+
+ );
+
+ case 'estimation':
+ return (
+
+ {task.timeTracking?.estimated && (
+
+ {task.timeTracking.estimated}h
+
+ )}
+
+ );
+
+ case 'startDate':
+ return (
+
+ {formattedStartDate && (
+
+ {formattedStartDate}
+
+ )}
+
+ );
+
+ case 'completedDate':
+ return (
+
+ {formattedCompletedDate && (
+
+ {formattedCompletedDate}
+
+ )}
+
+ );
+
+ case 'createdDate':
+ return (
+
+ {formattedCreatedDate && (
+
+ {formattedCreatedDate}
+
+ )}
+
+ );
+
+ case 'lastUpdated':
+ return (
+
+ {formattedUpdatedDate && (
+
+ {formattedUpdatedDate}
+
+ )}
+
+ );
+
+ case 'reporter':
+ return (
+
+ {task.reporter && (
+ {task.reporter}
+ )}
+
+ );
+
+ default:
+ return null;
+ }
+ }, [
+ attributes,
+ listeners,
+ task.task_key,
+ task.status,
+ task.priority,
+ task.phase,
+ task.reporter,
+ task.assignee_names,
+ task.timeTracking,
+ task.progress,
+ task.sub_tasks,
+ taskDisplayName,
+ statusStyle,
+ priorityStyle,
+ formattedDueDate,
+ formattedStartDate,
+ formattedCompletedDate,
+ formattedCreatedDate,
+ formattedUpdatedDate,
+ labelsDisplay,
+ isDarkMode,
+ convertedTask,
+ ]);
+
+ return (
+
+ {visibleColumns.map((column, index) =>
+ renderColumn(column.id, column.width, column.isSticky, index)
+ )}
+
+ );
+});
+
+TaskRow.displayName = 'TaskRow';
+
+export default TaskRow;
diff --git a/worklenz-frontend/src/components/task-management/drag-drop-optimized.css b/worklenz-frontend/src/components/task-management/drag-drop-optimized.css
index 6f21e39c..e69de29b 100644
--- a/worklenz-frontend/src/components/task-management/drag-drop-optimized.css
+++ b/worklenz-frontend/src/components/task-management/drag-drop-optimized.css
@@ -1,40 +0,0 @@
-/* MINIMAL DRAG AND DROP CSS - SHOW ONLY TASK NAME */
-
-/* Basic drag handle styling */
-.drag-handle-optimized {
- cursor: grab;
- opacity: 0.6;
- transition: opacity 0.2s ease;
-}
-
-.drag-handle-optimized:hover {
- opacity: 1;
-}
-
-.drag-handle-optimized:active {
- cursor: grabbing;
-}
-
-/* Simple drag overlay - just show task name */
-[data-dnd-overlay] {
- background: white;
- border: 1px solid #d9d9d9;
- border-radius: 4px;
- padding: 8px 12px;
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
- pointer-events: none;
- z-index: 9999;
-}
-
-/* Dark mode support for drag overlay */
-.dark [data-dnd-overlay],
-[data-theme="dark"] [data-dnd-overlay] {
- background: #1f1f1f;
- border-color: #404040;
- color: white;
-}
-
-/* Hide drag handle during drag */
-[data-dnd-dragging="true"] .drag-handle-optimized {
- opacity: 0;
-}
diff --git a/worklenz-frontend/src/components/task-management/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx
index a79c0b3f..07d0b679 100644
--- a/worklenz-frontend/src/components/task-management/task-group.tsx
+++ b/worklenz-frontend/src/components/task-management/task-group.tsx
@@ -1,7 +1,7 @@
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
-import { useSelector } from 'react-redux';
+import { useSelector, useDispatch } from 'react-redux';
import {
Button,
Typography,
@@ -11,12 +11,15 @@ import {
DownOutlined,
} from '@/shared/antd-imports';
import { TaskGroup as TaskGroupType, Task } from '@/types/task-management.types';
-import { taskManagementSelectors } from '@/features/task-management/task-management.slice';
+import { taskManagementSelectors, selectAllTasks } from '@/features/task-management/task-management.slice';
import { RootState } from '@/app/store';
import TaskRow from './task-row';
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
import { TaskListField } from '@/features/task-management/taskListFields.slice';
import { Checkbox } from '@/components';
+import { selectIsGroupCollapsed, toggleGroupCollapsed } from '@/features/task-management/grouping.slice';
+import { selectIsTaskSelected } from '@/features/task-management/selection.slice';
+import { Draggable } from 'react-beautiful-dnd';
const { Text } = Typography;
@@ -58,6 +61,7 @@ const TaskGroup: React.FC = React.memo(
onSelectTask,
onToggleSubtasks,
}) => {
+ const dispatch = useDispatch();
const [isCollapsed, setIsCollapsed] = useState(group.collapsed || false);
const { setNodeRef, isOver } = useDroppable({
@@ -69,7 +73,7 @@ const TaskGroup: React.FC = React.memo(
});
// Get all tasks from the store
- const allTasks = useSelector(taskManagementSelectors.selectAll);
+ const allTasks = useSelector(selectAllTasks);
// Get theme from Redux store
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
@@ -328,19 +332,29 @@ const TaskGroup: React.FC = React.memo(
{groupTasks.map((task, index) => (
-
+
+ {(provided, snapshot) => (
+
+
+
+ )}
+
))}
diff --git a/worklenz-frontend/src/components/task-management/task-list-board.tsx b/worklenz-frontend/src/components/task-management/task-list-board.tsx
index ca89af5e..d4fd98cc 100644
--- a/worklenz-frontend/src/components/task-management/task-list-board.tsx
+++ b/worklenz-frontend/src/components/task-management/task-list-board.tsx
@@ -17,33 +17,50 @@ import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { Card, Spin, Empty, Alert } from 'antd';
import { RootState } from '@/app/store';
import {
- taskManagementSelectors,
+ selectAllTasks,
+ selectGroups,
+ selectGrouping,
+ selectLoading,
+ selectError,
+ selectSelectedPriorities,
+ selectSearch,
reorderTasks,
moveTaskToGroup,
+ moveTaskBetweenGroups,
optimisticTaskMove,
reorderTasksInGroup,
setLoading,
- fetchTasks,
+ setError,
+ setSelectedPriorities,
+ setSearch,
+ resetTaskManagement,
+ toggleTaskExpansion,
+ addSubtaskToParent,
fetchTasksV3,
- selectTaskGroupsV3,
- selectCurrentGroupingV3,
} from '@/features/task-management/task-management.slice';
import {
- selectTaskGroups,
selectCurrentGrouping,
- setCurrentGrouping,
+ selectCollapsedGroups,
+ selectIsGroupCollapsed,
+ toggleGroupCollapsed,
+ expandAllGroups,
+ collapseAllGroups,
} from '@/features/task-management/grouping.slice';
import {
selectSelectedTaskIds,
+ selectLastSelectedTaskId,
+ selectIsTaskSelected,
+ selectTask,
+ deselectTask,
toggleTaskSelection,
+ selectRange,
clearSelection,
} from '@/features/task-management/selection.slice';
import {
- selectTaskIds,
selectTasks,
deselectAll as deselectAllBulk,
} from '@/features/projects/bulkActions/bulkActionSlice';
-import { Task } from '@/types/task-management.types';
+import { Task, TaskGroup } from '@/types/task-management.types';
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
@@ -155,16 +172,19 @@ const TaskListBoard: React.FC = ({ projectId, className = ''
const { socket, connected } = useSocket();
// Redux selectors using V3 API (pre-processed data, minimal loops)
- const tasks = useSelector(taskManagementSelectors.selectAll);
+ const tasks = useSelector(selectAllTasks);
+ const groups = useSelector(selectGroups);
+ const grouping = useSelector(selectGrouping);
+ const loading = useSelector(selectLoading);
+ const error = useSelector(selectError);
+ const selectedPriorities = useSelector(selectSelectedPriorities);
+ const searchQuery = useSelector(selectSearch);
const taskGroups = useSelector(selectTaskGroupsV3, shallowEqual);
- const currentGrouping = useSelector(selectCurrentGroupingV3, shallowEqual);
- // Use bulk action slice for selected tasks instead of selection slice
- const selectedTaskIds = useSelector(
- (state: RootState) => state.bulkActionReducer.selectedTaskIdsList
- );
+ const currentGrouping = useSelector(selectCurrentGrouping);
+ const collapsedGroups = useSelector(selectCollapsedGroups);
+ const selectedTaskIds = useSelector(selectSelectedTaskIds);
+ const lastSelectedTaskId = useSelector(selectLastSelectedTaskId);
const selectedTasks = useSelector((state: RootState) => state.bulkActionReducer.selectedTasks);
- const loading = useSelector((state: RootState) => state.taskManagement.loading, shallowEqual);
- const error = useSelector((state: RootState) => state.taskManagement.error);
// Bulk action selectors
const statusList = useSelector((state: RootState) => state.taskStatusReducer.status);
diff --git a/worklenz-frontend/src/components/task-management/task-list-filters.tsx b/worklenz-frontend/src/components/task-management/task-list-filters.tsx
new file mode 100644
index 00000000..0e5db48c
--- /dev/null
+++ b/worklenz-frontend/src/components/task-management/task-list-filters.tsx
@@ -0,0 +1,54 @@
+import React from 'react';
+
+interface TaskListFiltersProps {
+ selectedPriorities: string[];
+ onPriorityChange: (priorities: string[]) => void;
+ searchQuery: string;
+ onSearchChange: (query: string) => void;
+}
+
+const TaskListFilters: React.FC = ({
+ selectedPriorities,
+ onPriorityChange,
+ searchQuery,
+ onSearchChange,
+}) => {
+ const priorities = ['High', 'Medium', 'Low'];
+
+ return (
+
+
+
+
+ {priorities.map(priority => (
+
+ ))}
+
+
+
+
+ onSearchChange(e.target.value)}
+ placeholder="Search tasks..."
+ className="search-input"
+ />
+
+
+ );
+};
+
+export default TaskListFilters;
\ No newline at end of file
diff --git a/worklenz-frontend/src/components/task-management/task-list-group.tsx b/worklenz-frontend/src/components/task-management/task-list-group.tsx
new file mode 100644
index 00000000..47e9dda6
--- /dev/null
+++ b/worklenz-frontend/src/components/task-management/task-list-group.tsx
@@ -0,0 +1,94 @@
+import React from 'react';
+import { useSortable } from '@dnd-kit/sortable';
+import { CSS } from '@dnd-kit/utilities';
+import { Task, TaskGroup } from '@/types/task-management.types';
+import TaskRow from './task-row';
+
+interface TaskListGroupProps {
+ group: TaskGroup;
+ tasks: Task[];
+ isCollapsed: boolean;
+ onCollapse: () => void;
+ onTaskSelect: (taskId: string, event: React.MouseEvent) => void;
+ selectedTaskIds: string[];
+ projectId: string;
+ currentGrouping: 'status' | 'priority' | 'phase';
+}
+
+const TaskListGroup: React.FC = ({
+ group,
+ tasks,
+ isCollapsed,
+ onCollapse,
+ onTaskSelect,
+ selectedTaskIds,
+ projectId,
+ currentGrouping,
+}) => {
+ const groupStyle = {
+ backgroundColor: group.color ? `${group.color}10` : undefined,
+ borderColor: group.color,
+ };
+
+ const headerStyle = {
+ backgroundColor: group.color ? `${group.color}20` : undefined,
+ };
+
+ return (
+
+
+
+
+ {isCollapsed ? 'â–º' : 'â–¼'}
+
+
{group.title}
+ ({tasks.length})
+
+
+ {!isCollapsed && (
+
+ {tasks.map((task, index) => {
+ const {
+ attributes,
+ listeners,
+ setNodeRef,
+ transform,
+ transition,
+ isDragging,
+ } = useSortable({
+ id: task.id,
+ });
+
+ const style = {
+ transform: CSS.Transform.toString(transform),
+ transition,
+ };
+
+ return (
+
+ onTaskSelect(taskId, {} as React.MouseEvent)}
+ index={index}
+ />
+
+ );
+ })}
+
+ )}
+
+ );
+};
+
+export default TaskListGroup;
\ No newline at end of file
diff --git a/worklenz-frontend/src/components/task-management/task-list-header.tsx b/worklenz-frontend/src/components/task-management/task-list-header.tsx
new file mode 100644
index 00000000..0bbdac89
--- /dev/null
+++ b/worklenz-frontend/src/components/task-management/task-list-header.tsx
@@ -0,0 +1,32 @@
+import React from 'react';
+
+interface TaskListHeaderProps {
+ onExpandAll: () => void;
+ onCollapseAll: () => void;
+}
+
+const TaskListHeader: React.FC = ({
+ onExpandAll,
+ onCollapseAll,
+}) => {
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default TaskListHeader;
\ No newline at end of file
diff --git a/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx
index 548b3e9c..0304c3a1 100644
--- a/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx
+++ b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx
@@ -9,6 +9,14 @@ import {
taskManagementSelectors,
toggleTaskExpansion,
fetchSubTasks,
+ selectAllTasks,
+ selectTaskIds,
+ selectGroups,
+ selectGrouping,
+ selectLoading,
+ selectError,
+ selectSelectedPriorities,
+ selectSearch,
} from '@/features/task-management/task-management.slice';
import { toggleGroupCollapsed } from '@/features/task-management/grouping.slice';
import { Task } from '@/types/task-management.types';
diff --git a/worklenz-frontend/src/features/task-management/grouping.slice.ts b/worklenz-frontend/src/features/task-management/grouping.slice.ts
index 531dac24..182d0903 100644
--- a/worklenz-frontend/src/features/task-management/grouping.slice.ts
+++ b/worklenz-frontend/src/features/task-management/grouping.slice.ts
@@ -1,10 +1,24 @@
import { createSlice, PayloadAction, createSelector } from '@reduxjs/toolkit';
-import { GroupingState, TaskGroup } from '@/types/task-management.types';
+import { TaskGroup } from '@/types/task-management.types';
import { RootState } from '@/app/store';
-import { taskManagementSelectors } from './task-management.slice';
+import { selectAllTasksArray } from './task-management.slice';
-const initialState: GroupingState = {
- currentGrouping: 'status',
+type GroupingType = 'status' | 'priority' | 'phase';
+
+interface LocalGroupingState {
+ currentGrouping: GroupingType | null;
+ customPhases: string[];
+ groupOrder: {
+ status: string[];
+ priority: string[];
+ phase: string[];
+ };
+ groupStates: Record;
+ collapsedGroups: string[];
+}
+
+const initialState: LocalGroupingState = {
+ currentGrouping: null,
customPhases: ['Planning', 'Development', 'Testing', 'Deployment'],
groupOrder: {
status: ['todo', 'doing', 'done'],
@@ -12,13 +26,14 @@ const initialState: GroupingState = {
phase: ['Planning', 'Development', 'Testing', 'Deployment'],
},
groupStates: {},
+ collapsedGroups: [],
};
const groupingSlice = createSlice({
name: 'grouping',
initialState,
reducers: {
- setCurrentGrouping: (state, action: PayloadAction<'status' | 'priority' | 'phase'>) => {
+ setCurrentGrouping: (state, action: PayloadAction) => {
state.currentGrouping = action.payload;
},
@@ -41,17 +56,19 @@ const groupingSlice = createSlice({
state.groupOrder.phase = action.payload;
},
- updateGroupOrder: (state, action: PayloadAction<{ groupType: string; order: string[] }>) => {
+ updateGroupOrder: (state, action: PayloadAction<{ groupType: keyof LocalGroupingState['groupOrder']; order: string[] }>) => {
const { groupType, order } = action.payload;
state.groupOrder[groupType] = order;
},
toggleGroupCollapsed: (state, action: PayloadAction) => {
const groupId = action.payload;
- if (!state.groupStates[groupId]) {
- state.groupStates[groupId] = { collapsed: false };
+ const isCollapsed = state.collapsedGroups.includes(groupId);
+ if (isCollapsed) {
+ state.collapsedGroups = state.collapsedGroups.filter(id => id !== groupId);
+ } else {
+ state.collapsedGroups.push(groupId);
}
- state.groupStates[groupId].collapsed = !state.groupStates[groupId].collapsed;
},
setGroupCollapsed: (state, action: PayloadAction<{ groupId: string; collapsed: boolean }>) => {
@@ -62,16 +79,12 @@ const groupingSlice = createSlice({
state.groupStates[groupId].collapsed = collapsed;
},
- collapseAllGroups: state => {
- Object.keys(state.groupStates).forEach(groupId => {
- state.groupStates[groupId].collapsed = true;
- });
+ collapseAllGroups: (state, action: PayloadAction) => {
+ state.collapsedGroups = action.payload;
},
expandAllGroups: state => {
- Object.keys(state.groupStates).forEach(groupId => {
- state.groupStates[groupId].collapsed = false;
- });
+ state.collapsedGroups = [];
},
resetGrouping: () => initialState,
@@ -96,56 +109,59 @@ export const selectCurrentGrouping = (state: RootState) => state.grouping.curren
export const selectCustomPhases = (state: RootState) => state.grouping.customPhases;
export const selectGroupOrder = (state: RootState) => state.grouping.groupOrder;
export const selectGroupStates = (state: RootState) => state.grouping.groupStates;
+export const selectCollapsedGroups = (state: RootState) => new Set(state.grouping.collapsedGroups);
+export const selectIsGroupCollapsed = (state: RootState, groupId: string) =>
+ state.grouping.collapsedGroups.includes(groupId);
// Complex selectors using createSelector for memoization
export const selectCurrentGroupOrder = createSelector(
[selectCurrentGrouping, selectGroupOrder],
- (currentGrouping, groupOrder) => groupOrder[currentGrouping] || []
+ (currentGrouping, groupOrder) => {
+ if (!currentGrouping) return [];
+ return groupOrder[currentGrouping] || [];
+ }
);
export const selectTaskGroups = createSelector(
- [
- taskManagementSelectors.selectAll,
- selectCurrentGrouping,
- selectCurrentGroupOrder,
- selectGroupStates,
- ],
+ [selectAllTasksArray, selectCurrentGrouping, selectCurrentGroupOrder, selectGroupStates],
(tasks, currentGrouping, groupOrder, groupStates) => {
const groups: TaskGroup[] = [];
+ if (!currentGrouping) return groups;
+
// Get unique values for the current grouping
const groupValues =
groupOrder.length > 0
? groupOrder
- : [
- ...new Set(
- tasks.map(task => {
- if (currentGrouping === 'status') return task.status;
- if (currentGrouping === 'priority') return task.priority;
- return task.phase;
- })
- ),
- ];
+ : Array.from(new Set(
+ tasks.map(task => {
+ if (currentGrouping === 'status') return task.status;
+ if (currentGrouping === 'priority') return task.priority;
+ return task.phase;
+ })
+ ));
groupValues.forEach(value => {
+ if (!value) return; // Skip undefined values
+
const tasksInGroup = tasks
.filter(task => {
if (currentGrouping === 'status') return task.status === value;
if (currentGrouping === 'priority') return task.priority === value;
return task.phase === value;
})
- .sort((a, b) => a.order - b.order);
+ .sort((a, b) => (a.order || 0) - (b.order || 0));
const groupId = `${currentGrouping}-${value}`;
groups.push({
id: groupId,
title: value.charAt(0).toUpperCase() + value.slice(1),
- groupType: currentGrouping,
- groupValue: value,
- collapsed: groupStates[groupId]?.collapsed || false,
taskIds: tasksInGroup.map(task => task.id),
+ type: currentGrouping,
color: getGroupColor(currentGrouping, value),
+ collapsed: groupStates[groupId]?.collapsed || false,
+ groupValue: value,
});
});
@@ -154,15 +170,17 @@ export const selectTaskGroups = createSelector(
);
export const selectTasksByCurrentGrouping = createSelector(
- [taskManagementSelectors.selectAll, selectCurrentGrouping],
+ [selectAllTasksArray, selectCurrentGrouping],
(tasks, currentGrouping) => {
const grouped: Record = {};
+ if (!currentGrouping) return grouped;
+
tasks.forEach(task => {
let key: string;
if (currentGrouping === 'status') key = task.status;
else if (currentGrouping === 'priority') key = task.priority;
- else key = task.phase;
+ else key = task.phase || 'Development';
if (!grouped[key]) grouped[key] = [];
grouped[key].push(task);
@@ -170,7 +188,7 @@ export const selectTasksByCurrentGrouping = createSelector(
// Sort tasks within each group by order
Object.keys(grouped).forEach(key => {
- grouped[key].sort((a, b) => a.order - b.order);
+ grouped[key].sort((a, b) => (a.order || 0) - (b.order || 0));
});
return grouped;
@@ -178,7 +196,7 @@ export const selectTasksByCurrentGrouping = createSelector(
);
// Helper function to get group colors
-const getGroupColor = (groupType: string, value: string): string => {
+const getGroupColor = (groupType: GroupingType, value: string): string => {
const colorMaps = {
status: {
todo: '#f0f0f0',
@@ -199,7 +217,8 @@ const getGroupColor = (groupType: string, value: string): string => {
},
};
- return colorMaps[groupType as keyof typeof colorMaps]?.[value as keyof any] || '#d9d9d9';
+ const colorMap = colorMaps[groupType];
+ return (colorMap as any)?.[value] || '#d9d9d9';
};
export default groupingSlice.reducer;
diff --git a/worklenz-frontend/src/features/task-management/selection.slice.ts b/worklenz-frontend/src/features/task-management/selection.slice.ts
index 7157facc..97f69e7e 100644
--- a/worklenz-frontend/src/features/task-management/selection.slice.ts
+++ b/worklenz-frontend/src/features/task-management/selection.slice.ts
@@ -1,121 +1,71 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
-import { SelectionState } from '@/types/task-management.types';
+import { TaskSelection } from '@/types/task-management.types';
import { RootState } from '@/app/store';
-const initialState: SelectionState = {
+const initialState: TaskSelection = {
selectedTaskIds: [],
- lastSelectedId: null,
+ lastSelectedTaskId: null,
};
const selectionSlice = createSlice({
- name: 'selection',
+ name: 'taskManagementSelection',
initialState,
reducers: {
- toggleTaskSelection: (state, action: PayloadAction) => {
- const taskId = action.payload;
- const index = state.selectedTaskIds.indexOf(taskId);
-
- if (index === -1) {
- state.selectedTaskIds.push(taskId);
- } else {
- state.selectedTaskIds.splice(index, 1);
- }
-
- state.lastSelectedId = taskId;
- },
-
selectTask: (state, action: PayloadAction) => {
const taskId = action.payload;
if (!state.selectedTaskIds.includes(taskId)) {
state.selectedTaskIds.push(taskId);
}
- state.lastSelectedId = taskId;
+ state.lastSelectedTaskId = taskId;
},
-
deselectTask: (state, action: PayloadAction) => {
const taskId = action.payload;
state.selectedTaskIds = state.selectedTaskIds.filter(id => id !== taskId);
- if (state.lastSelectedId === taskId) {
- state.lastSelectedId = state.selectedTaskIds[state.selectedTaskIds.length - 1] || null;
+ if (state.lastSelectedTaskId === taskId) {
+ state.lastSelectedTaskId = state.selectedTaskIds[state.selectedTaskIds.length - 1] || null;
}
},
-
- selectMultipleTasks: (state, action: PayloadAction) => {
+ toggleTaskSelection: (state, action: PayloadAction) => {
+ const taskId = action.payload;
+ const index = state.selectedTaskIds.indexOf(taskId);
+ if (index === -1) {
+ state.selectedTaskIds.push(taskId);
+ state.lastSelectedTaskId = taskId;
+ } else {
+ state.selectedTaskIds.splice(index, 1);
+ state.lastSelectedTaskId = state.selectedTaskIds[state.selectedTaskIds.length - 1] || null;
+ }
+ },
+ selectRange: (state, action: PayloadAction) => {
const taskIds = action.payload;
- // Add new task IDs that aren't already selected
- taskIds.forEach(id => {
- if (!state.selectedTaskIds.includes(id)) {
- state.selectedTaskIds.push(id);
- }
- });
- state.lastSelectedId = taskIds[taskIds.length - 1] || state.lastSelectedId;
+ const uniqueIds = Array.from(new Set([...state.selectedTaskIds, ...taskIds]));
+ state.selectedTaskIds = uniqueIds;
+ state.lastSelectedTaskId = taskIds[taskIds.length - 1];
},
-
- selectRangeTasks: (
- state,
- action: PayloadAction<{ startId: string; endId: string; allTaskIds: string[] }>
- ) => {
- const { startId, endId, allTaskIds } = action.payload;
- const startIndex = allTaskIds.indexOf(startId);
- const endIndex = allTaskIds.indexOf(endId);
-
- if (startIndex !== -1 && endIndex !== -1) {
- const [start, end] =
- startIndex <= endIndex ? [startIndex, endIndex] : [endIndex, startIndex];
- const rangeIds = allTaskIds.slice(start, end + 1);
-
- // Add range IDs that aren't already selected
- rangeIds.forEach(id => {
- if (!state.selectedTaskIds.includes(id)) {
- state.selectedTaskIds.push(id);
- }
- });
-
- state.lastSelectedId = endId;
- }
- },
-
- selectAllTasks: (state, action: PayloadAction) => {
- state.selectedTaskIds = action.payload;
- state.lastSelectedId = action.payload[action.payload.length - 1] || null;
- },
-
clearSelection: state => {
state.selectedTaskIds = [];
- state.lastSelectedId = null;
+ state.lastSelectedTaskId = null;
},
-
- setSelection: (state, action: PayloadAction) => {
- state.selectedTaskIds = action.payload;
- state.lastSelectedId = action.payload[action.payload.length - 1] || null;
+ resetSelection: state => {
+ state.selectedTaskIds = [];
+ state.lastSelectedTaskId = null;
},
-
- resetSelection: () => initialState,
},
});
export const {
- toggleTaskSelection,
selectTask,
deselectTask,
- selectMultipleTasks,
- selectRangeTasks,
- selectAllTasks,
+ toggleTaskSelection,
+ selectRange,
clearSelection,
- setSelection,
resetSelection,
} = selectionSlice.actions;
// Selectors
-export const selectSelectedTaskIds = (state: RootState) =>
- state.taskManagementSelection.selectedTaskIds;
-export const selectLastSelectedId = (state: RootState) =>
- state.taskManagementSelection.lastSelectedId;
-export const selectHasSelection = (state: RootState) =>
- state.taskManagementSelection.selectedTaskIds.length > 0;
-export const selectSelectionCount = (state: RootState) =>
- state.taskManagementSelection.selectedTaskIds.length;
-export const selectIsTaskSelected = (taskId: string) => (state: RootState) =>
+export const selectSelectedTaskIds = (state: RootState) => state.taskManagementSelection.selectedTaskIds;
+export const selectLastSelectedTaskId = (state: RootState) => state.taskManagementSelection.lastSelectedTaskId;
+export const selectIsTaskSelected = (state: RootState, taskId: string) =>
state.taskManagementSelection.selectedTaskIds.includes(taskId);
export default selectionSlice.reducer;
diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts
index 20493847..e865903d 100644
--- a/worklenz-frontend/src/features/task-management/task-management.slice.ts
+++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts
@@ -3,8 +3,10 @@ import {
createEntityAdapter,
PayloadAction,
createAsyncThunk,
+ EntityState,
+ EntityId,
} from '@reduxjs/toolkit';
-import { Task, TaskManagementState } from '@/types/task-management.types';
+import { Task, TaskManagementState, TaskGroup, TaskGrouping } from '@/types/task-management.types';
import { RootState } from '@/app/store';
import {
tasksApiService,
@@ -12,6 +14,25 @@ import {
ITaskListV3Response,
} from '@/api/tasks/tasks.api.service';
import logger from '@/utils/errorLogger';
+import { DEFAULT_TASK_NAME } from '@/shared/constants';
+
+// Helper function to safely convert time values
+const convertTimeValue = (value: any): number => {
+ if (typeof value === 'number') return value;
+ if (typeof value === 'string') {
+ const parsed = parseFloat(value);
+ return isNaN(parsed) ? 0 : parsed;
+ }
+ if (typeof value === 'object' && value !== null) {
+ // Handle time objects like {hours: 2, minutes: 30}
+ if ('hours' in value || 'minutes' in value) {
+ const hours = Number(value.hours || 0);
+ const minutes = Number(value.minutes || 0);
+ return hours + minutes / 60;
+ }
+ }
+ return 0;
+};
export enum IGroupBy {
STATUS = 'status',
@@ -21,17 +42,16 @@ export enum IGroupBy {
}
// Entity adapter for normalized state
-const tasksAdapter = createEntityAdapter({
- sortComparer: (a, b) => a.order - b.order,
-});
+const tasksAdapter = createEntityAdapter();
+// Get the initial state from the adapter
const initialState: TaskManagementState = {
- entities: {},
ids: [],
+ entities: {},
loading: false,
error: null,
groups: [],
- grouping: null,
+ grouping: undefined,
selectedPriorities: [],
search: '',
};
@@ -47,7 +67,7 @@ export const fetchTasks = createAsyncThunk(
const config: ITaskListConfigV2 = {
id: projectId,
archived: false,
- group: currentGrouping,
+ group: currentGrouping || '',
field: '',
order: '',
search: '',
@@ -118,7 +138,7 @@ export const fetchTasks = createAsyncThunk(
group.tasks.map((task: any) => ({
id: task.id,
task_key: task.task_key || '',
- title: task.name || '',
+ title: (task.title && task.title.trim()) ? task.title.trim() : DEFAULT_TASK_NAME,
description: task.description || '',
status: statusIdToNameMap[task.status] || 'todo',
priority: priorityIdToNameMap[task.priority] || 'medium',
@@ -167,24 +187,18 @@ export const fetchTasksV3 = createAsyncThunk(
// Get selected labels from taskReducer
const selectedLabels = state.taskReducer.labels
- ? state.taskReducer.labels
- .filter(l => l.selected)
- .map(l => l.id)
- .join(' ')
- : '';
+ .filter((l: any) => l.selected && l.id)
+ .map((l: any) => l.id)
+ .join(' ');
// Get selected assignees from taskReducer
const selectedAssignees = state.taskReducer.taskAssignees
- ? state.taskReducer.taskAssignees
- .filter(m => m.selected)
- .map(m => m.id)
- .join(' ')
- : '';
+ .filter((m: any) => m.selected && m.id)
+ .map((m: any) => m.id)
+ .join(' ');
- // Get selected priorities from taskReducer (consistent with other slices)
- const selectedPriorities = state.taskReducer.priorities
- ? state.taskReducer.priorities.join(' ')
- : '';
+ // Get selected priorities from taskReducer
+ const selectedPriorities = state.taskReducer.priorities.join(' ');
// Get search value from taskReducer
const searchValue = state.taskReducer.search || '';
@@ -192,7 +206,7 @@ export const fetchTasksV3 = createAsyncThunk(
const config: ITaskListConfigV2 = {
id: projectId,
archived: false,
- group: currentGrouping,
+ group: currentGrouping || '',
field: '',
order: '',
search: searchValue,
@@ -206,10 +220,88 @@ export const fetchTasksV3 = createAsyncThunk(
const response = await tasksApiService.getTaskListV3(config);
- // Minimal processing - tasks are already processed by backend
+ // Log raw response for debugging
+ console.log('Raw API response:', response.body);
+ console.log('Sample task from backend:', response.body.allTasks?.[0]);
+ console.log('Task key from backend:', response.body.allTasks?.[0]?.task_key);
+
+ // Ensure tasks are properly normalized
+ const tasks = response.body.allTasks.map((task: any) => {
+ const now = new Date().toISOString();
+
+
+
+ return {
+ id: task.id,
+ task_key: task.task_key || task.key || '',
+ title: (task.title && task.title.trim()) ? task.title.trim() : DEFAULT_TASK_NAME,
+ description: task.description || '',
+ status: task.status || 'todo',
+ priority: task.priority || 'medium',
+ phase: task.phase || 'Development',
+ progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0,
+ assignees: task.assignees?.map((a: { team_member_id: string }) => a.team_member_id) || [],
+ assignee_names: task.assignee_names || task.names || [],
+ labels: task.labels?.map((l: { id: string; label_id: string; name: string; color_code: string; end: boolean; names: string[] }) => ({
+ id: l.id || l.label_id,
+ name: l.name,
+ color: l.color_code || '#1890ff',
+ end: l.end,
+ names: l.names,
+ })) || [],
+ due_date: task.end_date || '',
+ timeTracking: {
+ estimated: convertTimeValue(task.total_time),
+ logged: convertTimeValue(task.time_spent),
+ },
+ created_at: task.created_at || now,
+ updated_at: task.updated_at || now,
+ order: typeof task.sort_order === 'number' ? task.sort_order : 0,
+ sub_tasks: task.sub_tasks || [],
+ sub_tasks_count: task.sub_tasks_count || 0,
+ show_sub_tasks: task.show_sub_tasks || false,
+ parent_task_id: task.parent_task_id || '',
+ weight: task.weight || 0,
+ color: task.color || '',
+ statusColor: task.status_color || '',
+ priorityColor: task.priority_color || '',
+ comments_count: task.comments_count || 0,
+ attachments_count: task.attachments_count || 0,
+ has_dependencies: !!task.has_dependencies,
+ schedule_id: task.schedule_id || null,
+ } as Task;
+ });
+
+ // Map groups to match TaskGroup interface
+ const mappedGroups = response.body.groups.map((group: any) => ({
+ id: group.id,
+ title: group.title,
+ taskIds: group.taskIds || [],
+ type: group.groupType as 'status' | 'priority' | 'phase' | 'members',
+ color: group.color,
+ }));
+
+ // Log normalized data for debugging
+ console.log('Normalized data:', {
+ tasks,
+ groups: mappedGroups,
+ grouping: response.body.grouping,
+ totalTasks: response.body.totalTasks,
+ });
+
+ // Verify task IDs match group taskIds
+ const taskIds = new Set(tasks.map(t => t.id));
+ const groupTaskIds = new Set(mappedGroups.flatMap(g => g.taskIds));
+ console.log('Task ID verification:', {
+ taskIds: Array.from(taskIds),
+ groupTaskIds: Array.from(groupTaskIds),
+ allTaskIdsInGroups: Array.from(groupTaskIds).every(id => taskIds.has(id)),
+ allGroupTaskIdsInTasks: Array.from(taskIds).every(id => groupTaskIds.has(id)),
+ });
+
return {
- tasks: response.body.allTasks,
- groups: response.body.groups,
+ tasks: tasks,
+ groups: mappedGroups,
grouping: response.body.grouping,
totalTasks: response.body.totalTasks,
};
@@ -237,7 +329,7 @@ export const fetchSubTasks = createAsyncThunk(
const config: ITaskListConfigV2 = {
id: projectId,
archived: false,
- group: currentGrouping,
+ group: currentGrouping || '',
field: '',
order: '',
search: '',
@@ -343,327 +435,182 @@ export const moveTaskToGroupWithAPI = createAsyncThunk(
}
);
+// Add action to update task with subtasks
+export const updateTaskWithSubtasks = createAsyncThunk(
+ 'taskManagement/updateTaskWithSubtasks',
+ async ({ taskId, subtasks }: { taskId: string; subtasks: any[] }, { getState }) => {
+ return { taskId, subtasks };
+ }
+);
+
+// Create the slice
const taskManagementSlice = createSlice({
name: 'taskManagement',
- initialState: tasksAdapter.getInitialState(initialState),
+ initialState,
reducers: {
- // Basic CRUD operations
setTasks: (state, action: PayloadAction) => {
- tasksAdapter.setAll(state, action.payload);
- state.loading = false;
- state.error = null;
+ const tasks = action.payload;
+ state.ids = tasks.map(task => task.id);
+ state.entities = tasks.reduce((acc, task) => {
+ acc[task.id] = task;
+ return acc;
+ }, {} as Record);
},
-
addTask: (state, action: PayloadAction) => {
- tasksAdapter.addOne(state, action.payload);
+ const task = action.payload;
+ state.ids.push(task.id);
+ state.entities[task.id] = task;
},
-
- addTaskToGroup: (state, action: PayloadAction<{ task: Task; groupId?: string }>) => {
+ addTaskToGroup: (state, action: PayloadAction<{ task: Task; groupId: string }>) => {
const { task, groupId } = action.payload;
-
- // Add to entity adapter
- tasksAdapter.addOne(state, task);
-
- // Add to groups array for V3 API compatibility
- if (state.groups && state.groups.length > 0) {
- // Find the target group using the provided UUID
- const targetGroup = state.groups.find(group => {
- // If a specific groupId (UUID) is provided, use it directly
- if (groupId && group.id === groupId) {
- return true;
- }
-
- return false;
- });
-
- if (targetGroup) {
- // Add task ID to the end of the group's taskIds array (newest last)
- targetGroup.taskIds.push(task.id);
-
- // Also add to the tasks array if it exists (for backward compatibility)
- if ((targetGroup as any).tasks) {
- (targetGroup as any).tasks.push(task);
- }
- }
+ state.ids.push(task.id);
+ state.entities[task.id] = task;
+ const group = state.groups.find(g => g.id === groupId);
+ if (group) {
+ group.taskIds.push(task.id);
}
},
-
- updateTask: (state, action: PayloadAction<{ id: string; changes: Partial }>) => {
- tasksAdapter.updateOne(state, {
- id: action.payload.id,
- changes: {
- ...action.payload.changes,
- updatedAt: new Date().toISOString(),
- },
+ updateTask: (state, action: PayloadAction) => {
+ const task = action.payload;
+ state.entities[task.id] = task;
+ },
+ deleteTask: (state, action: PayloadAction) => {
+ const taskId = action.payload;
+ delete state.entities[taskId];
+ state.ids = state.ids.filter(id => id !== taskId);
+ state.groups = state.groups.map(group => ({
+ ...group,
+ taskIds: group.taskIds.filter(id => id !== taskId),
+ }));
+ },
+ bulkUpdateTasks: (state, action: PayloadAction) => {
+ action.payload.forEach(task => {
+ state.entities[task.id] = task;
});
},
-
- deleteTask: (state, action: PayloadAction) => {
- tasksAdapter.removeOne(state, action.payload);
- },
-
- // Bulk operations
- bulkUpdateTasks: (state, action: PayloadAction<{ ids: string[]; changes: Partial }>) => {
- const { ids, changes } = action.payload;
- const updates = ids.map(id => ({
- id,
- changes: {
- ...changes,
- updatedAt: new Date().toISOString(),
- },
- }));
- tasksAdapter.updateMany(state, updates);
- },
-
bulkDeleteTasks: (state, action: PayloadAction) => {
- tasksAdapter.removeMany(state, action.payload);
- },
-
- // Optimized drag and drop operations
- reorderTasks: (state, action: PayloadAction<{ taskIds: string[]; newOrder: number[] }>) => {
- const { taskIds, newOrder } = action.payload;
-
- // Batch update for better performance
- const updates = taskIds.map((id, index) => ({
- id,
- changes: {
- order: newOrder[index],
- updatedAt: new Date().toISOString(),
- },
+ const taskIds = action.payload;
+ taskIds.forEach(taskId => {
+ delete state.entities[taskId];
+ });
+ state.ids = state.ids.filter(id => !taskIds.includes(id));
+ state.groups = state.groups.map(group => ({
+ ...group,
+ taskIds: group.taskIds.filter(id => !taskIds.includes(id)),
}));
-
- tasksAdapter.updateMany(state, updates);
},
-
- moveTaskToGroup: (
- state,
- action: PayloadAction<{
- taskId: string;
- groupType: 'status' | 'priority' | 'phase';
- groupValue: string;
- }>
- ) => {
- const { taskId, groupType, groupValue } = action.payload;
- const changes: Partial = {
- updatedAt: new Date().toISOString(),
- };
-
- // Update the appropriate field based on group type
- if (groupType === 'status') {
- changes.status = groupValue as Task['status'];
- } else if (groupType === 'priority') {
- changes.priority = groupValue as Task['priority'];
- } else if (groupType === 'phase') {
- changes.phase = groupValue;
+ reorderTasks: (state, action: PayloadAction<{ taskIds: string[]; groupId: string }>) => {
+ const { taskIds, groupId } = action.payload;
+ const group = state.groups.find(g => g.id === groupId);
+ if (group) {
+ group.taskIds = taskIds;
}
-
- tasksAdapter.updateOne(state, { id: taskId, changes });
},
-
- // New action to move task between groups with proper group management
+ moveTaskToGroup: (state, action: PayloadAction<{ taskId: string; groupId: string }>) => {
+ const { taskId, groupId } = action.payload;
+ state.groups = state.groups.map(group => ({
+ ...group,
+ taskIds:
+ group.id === groupId
+ ? [...group.taskIds, taskId]
+ : group.taskIds.filter(id => id !== taskId),
+ }));
+ },
moveTaskBetweenGroups: (
state,
action: PayloadAction<{
taskId: string;
- fromGroupId: string;
- toGroupId: string;
- taskUpdate: Partial;
+ sourceGroupId: string;
+ targetGroupId: string;
}>
) => {
- const { taskId, fromGroupId, toGroupId, taskUpdate } = action.payload;
-
- // Update the task entity with new values
- tasksAdapter.updateOne(state, {
- id: taskId,
- changes: {
- ...taskUpdate,
- updatedAt: new Date().toISOString(),
- },
- });
-
- // Update groups if they exist
- if (state.groups && state.groups.length > 0) {
- // Remove task from old group
- const fromGroup = state.groups.find(group => group.id === fromGroupId);
- if (fromGroup) {
- fromGroup.taskIds = fromGroup.taskIds.filter(id => id !== taskId);
- }
-
- // Add task to new group
- const toGroup = state.groups.find(group => group.id === toGroupId);
- if (toGroup) {
- // Add to the end of the group (newest last)
- toGroup.taskIds.push(taskId);
- }
- }
+ const { taskId, sourceGroupId, targetGroupId } = action.payload;
+ state.groups = state.groups.map(group => ({
+ ...group,
+ taskIds:
+ group.id === targetGroupId
+ ? [...group.taskIds, taskId]
+ : group.id === sourceGroupId
+ ? group.taskIds.filter(id => id !== taskId)
+ : group.taskIds,
+ }));
},
-
- // Optimistic update for drag operations - reduces perceived lag
optimisticTaskMove: (
- state,
- action: PayloadAction<{ taskId: string; newGroupId: string; newIndex: number }>
- ) => {
- const { taskId, newGroupId, newIndex } = action.payload;
- const task = state.entities[taskId];
-
- if (task) {
- // Parse group ID to determine new values
- const [groupType, ...groupValueParts] = newGroupId.split('-');
- const groupValue = groupValueParts.join('-');
-
- const changes: Partial = {
- order: newIndex,
- updatedAt: new Date().toISOString(),
- };
-
- // Update group-specific field
- if (groupType === 'status') {
- changes.status = groupValue as Task['status'];
- } else if (groupType === 'priority') {
- changes.priority = groupValue as Task['priority'];
- } else if (groupType === 'phase') {
- changes.phase = groupValue;
- }
-
- // Update the task entity
- tasksAdapter.updateOne(state, { id: taskId, changes });
-
- // Update groups if they exist
- if (state.groups && state.groups.length > 0) {
- // Find the target group
- const targetGroup = state.groups.find(group => group.id === newGroupId);
- if (targetGroup) {
- // Remove task from all groups first
- state.groups.forEach(group => {
- group.taskIds = group.taskIds.filter(id => id !== taskId);
- });
-
- // Add task to target group at the specified index
- if (newIndex >= targetGroup.taskIds.length) {
- targetGroup.taskIds.push(taskId);
- } else {
- targetGroup.taskIds.splice(newIndex, 0, taskId);
- }
- }
- }
- }
- },
-
- // Proper reorder action that handles both task entities and group arrays
- reorderTasksInGroup: (
state,
action: PayloadAction<{
taskId: string;
- fromGroupId: string;
- toGroupId: string;
- fromIndex: number;
- toIndex: number;
- groupType: 'status' | 'priority' | 'phase';
- groupValue: string;
+ sourceGroupId: string;
+ targetGroupId: string;
}>
) => {
- const { taskId, fromGroupId, toGroupId, fromIndex, toIndex, groupType, groupValue } =
- action.payload;
-
- // Update the task entity
- const changes: Partial = {
- order: toIndex,
- updatedAt: new Date().toISOString(),
- };
-
- // Update group-specific field
- if (groupType === 'status') {
- changes.status = groupValue as Task['status'];
- } else if (groupType === 'priority') {
- changes.priority = groupValue as Task['priority'];
- } else if (groupType === 'phase') {
- changes.phase = groupValue;
- }
-
- tasksAdapter.updateOne(state, { id: taskId, changes });
-
- // Update groups if they exist
- if (state.groups && state.groups.length > 0) {
- // Remove task from source group
- const fromGroup = state.groups.find(group => group.id === fromGroupId);
- if (fromGroup) {
- fromGroup.taskIds = fromGroup.taskIds.filter(id => id !== taskId);
- }
-
- // Add task to target group
- const toGroup = state.groups.find(group => group.id === toGroupId);
- if (toGroup) {
- if (toIndex >= toGroup.taskIds.length) {
- toGroup.taskIds.push(taskId);
- } else {
- toGroup.taskIds.splice(toIndex, 0, taskId);
- }
- }
+ const { taskId, sourceGroupId, targetGroupId } = action.payload;
+ state.groups = state.groups.map(group => ({
+ ...group,
+ taskIds:
+ group.id === targetGroupId
+ ? [...group.taskIds, taskId]
+ : group.id === sourceGroupId
+ ? group.taskIds.filter(id => id !== taskId)
+ : group.taskIds,
+ }));
+ },
+ reorderTasksInGroup: (
+ state,
+ action: PayloadAction<{ taskIds: string[]; groupId: string }>
+ ) => {
+ const { taskIds, groupId } = action.payload;
+ const group = state.groups.find(g => g.id === groupId);
+ if (group) {
+ group.taskIds = taskIds;
}
},
-
- // Loading states
setLoading: (state, action: PayloadAction) => {
state.loading = action.payload;
},
-
setError: (state, action: PayloadAction) => {
state.error = action.payload;
- state.loading = false;
},
-
- // Filter actions
setSelectedPriorities: (state, action: PayloadAction) => {
state.selectedPriorities = action.payload;
},
-
- // Search action
setSearch: (state, action: PayloadAction) => {
state.search = action.payload;
},
-
- // Reset action
resetTaskManagement: state => {
- return tasksAdapter.getInitialState(initialState);
+ state.loading = false;
+ state.error = null;
+ state.groups = [];
+ state.grouping = undefined;
+ state.selectedPriorities = [];
+ state.search = '';
+ state.ids = [];
+ state.entities = {};
},
toggleTaskExpansion: (state, action: PayloadAction) => {
- const taskId = action.payload;
- const task = state.entities[taskId];
+ const task = state.entities[action.payload];
if (task) {
task.show_sub_tasks = !task.show_sub_tasks;
}
},
- addSubtaskToParent: (state, action: PayloadAction<{ subtask: Task; parentTaskId: string }>) => {
- const { subtask, parentTaskId } = action.payload;
- const parentTask = state.entities[parentTaskId];
- if (parentTask) {
- if (!parentTask.sub_tasks) {
- parentTask.sub_tasks = [];
+ addSubtaskToParent: (
+ state,
+ action: PayloadAction<{ parentId: string; subtask: Task }>
+ ) => {
+ const { parentId, subtask } = action.payload;
+ const parent = state.entities[parentId];
+ if (parent) {
+ state.ids.push(subtask.id);
+ state.entities[subtask.id] = subtask;
+ if (!parent.sub_tasks) {
+ parent.sub_tasks = [];
}
- parentTask.sub_tasks.push(subtask);
- parentTask.sub_tasks_count = (parentTask.sub_tasks_count || 0) + 1;
- // Ensure the parent task is expanded to show the new subtask
- parentTask.show_sub_tasks = true;
- // Add the subtask to the main entities as well
- tasksAdapter.addOne(state, subtask);
+ parent.sub_tasks.push(subtask);
+ parent.sub_tasks_count = (parent.sub_tasks_count || 0) + 1;
}
},
},
extraReducers: builder => {
builder
- .addCase(fetchTasks.pending, state => {
- state.loading = true;
- state.error = null;
- })
- .addCase(fetchTasks.fulfilled, (state, action) => {
- state.loading = false;
- state.error = null;
- tasksAdapter.setAll(state, action.payload);
- })
- .addCase(fetchTasks.rejected, (state, action) => {
- state.loading = false;
- state.error = (action.payload as string) || 'Failed to fetch tasks';
- })
.addCase(fetchTasksV3.pending, state => {
state.loading = true;
state.error = null;
@@ -671,39 +618,68 @@ const taskManagementSlice = createSlice({
.addCase(fetchTasksV3.fulfilled, (state, action) => {
state.loading = false;
state.error = null;
- // Tasks are already processed by backend, minimal setup needed
- tasksAdapter.setAll(state, action.payload.tasks);
- state.groups = action.payload.groups;
- state.grouping = action.payload.grouping;
+
+ // Ensure we have tasks before updating state
+ if (action.payload.tasks && action.payload.tasks.length > 0) {
+ // Update tasks
+ const tasks = action.payload.tasks;
+ state.ids = tasks.map(task => task.id);
+ state.entities = tasks.reduce((acc, task) => {
+ acc[task.id] = task;
+ return acc;
+ }, {} as Record);
+
+ // Update groups
+ state.groups = action.payload.groups;
+ state.grouping = action.payload.grouping;
+
+ // Verify task IDs match group taskIds
+ const taskIds = new Set(Object.keys(state.entities));
+ const groupTaskIds = new Set(state.groups.flatMap(g => g.taskIds));
+
+ // Ensure all tasks have IDs and all group taskIds exist
+ const validTaskIds = new Set(Object.keys(state.entities));
+ state.groups = state.groups.map((group: TaskGroup) => ({
+ ...group,
+ taskIds: group.taskIds.filter((id: string) => validTaskIds.has(id)),
+ }));
+ } else {
+ // Set empty state but don't show error
+ state.ids = [];
+ state.entities = {} as Record;
+ state.groups = [];
+ }
})
.addCase(fetchTasksV3.rejected, (state, action) => {
state.loading = false;
- state.error = (action.payload as string) || 'Failed to fetch tasks';
+ // Provide a more descriptive error message
+ state.error = action.error.message || action.payload || 'An error occurred while fetching tasks. Please try again.';
+ // Clear task data on error to prevent stale state
+ state.ids = [];
+ state.entities = {} as Record;
+ state.groups = [];
+ })
+ .addCase(fetchSubTasks.pending, (state, action) => {
+ // Don't set global loading state for subtasks
+ state.error = null;
})
.addCase(fetchSubTasks.fulfilled, (state, action) => {
const { parentTaskId, subtasks } = action.payload;
const parentTask = state.entities[parentTaskId];
if (parentTask) {
parentTask.sub_tasks = subtasks;
+ parentTask.sub_tasks_count = subtasks.length;
parentTask.show_sub_tasks = true;
- // Add subtasks to the main entities as well
- tasksAdapter.addMany(state, subtasks);
}
})
- .addCase(refreshTaskProgress.pending, state => {
- // Don't set loading to true for refresh to avoid UI blocking
- state.error = null;
- })
- .addCase(refreshTaskProgress.fulfilled, state => {
- state.error = null;
- // Progress refresh completed successfully
- })
- .addCase(refreshTaskProgress.rejected, (state, action) => {
- state.error = (action.payload as string) || 'Failed to refresh task progress';
+ .addCase(fetchSubTasks.rejected, (state, action) => {
+ // Set error but don't clear task data
+ state.error = action.error.message || action.payload || 'Failed to fetch subtasks. Please try again.';
});
},
});
+// Export the slice reducer and actions
export const {
setTasks,
addTask,
@@ -726,25 +702,30 @@ export const {
addSubtaskToParent,
} = taskManagementSlice.actions;
-export default taskManagementSlice.reducer;
+// Export the selectors
+export const selectAllTasks = (state: RootState) => state.taskManagement.entities;
+export const selectAllTasksArray = (state: RootState) => Object.values(state.taskManagement.entities);
+export const selectTaskById = (state: RootState, taskId: string) => state.taskManagement.entities[taskId];
+export const selectTaskIds = (state: RootState) => state.taskManagement.ids;
+export const selectGroups = (state: RootState) => state.taskManagement.groups;
+export const selectGrouping = (state: RootState) => state.taskManagement.grouping;
+export const selectLoading = (state: RootState) => state.taskManagement.loading;
+export const selectError = (state: RootState) => state.taskManagement.error;
+export const selectSelectedPriorities = (state: RootState) => state.taskManagement.selectedPriorities;
+export const selectSearch = (state: RootState) => state.taskManagement.search;
-// Selectors
-export const taskManagementSelectors = tasksAdapter.getSelectors(
- state => state.taskManagement
-);
-
-// Enhanced selectors for better performance
+// Memoized selectors
export const selectTasksByStatus = (state: RootState, status: string) =>
- taskManagementSelectors.selectAll(state).filter(task => task.status === status);
+ Object.values(state.taskManagement.entities).filter(task => task.status === status);
export const selectTasksByPriority = (state: RootState, priority: string) =>
- taskManagementSelectors.selectAll(state).filter(task => task.priority === priority);
+ Object.values(state.taskManagement.entities).filter(task => task.priority === priority);
export const selectTasksByPhase = (state: RootState, phase: string) =>
- taskManagementSelectors.selectAll(state).filter(task => task.phase === phase);
+ Object.values(state.taskManagement.entities).filter(task => task.phase === phase);
-export const selectTasksLoading = (state: RootState) => state.taskManagement.loading;
-export const selectTasksError = (state: RootState) => state.taskManagement.error;
+// Export the reducer as default
+export default taskManagementSlice.reducer;
// V3 API selectors - no processing needed, data is pre-processed by backend
export const selectTaskGroupsV3 = (state: RootState) => state.taskManagement.groups;
diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts
index 3cd6fc89..bf6348a3 100644
--- a/worklenz-frontend/src/lib/project/project-view-constants.ts
+++ b/worklenz-frontend/src/lib/project/project-view-constants.ts
@@ -4,6 +4,7 @@ import { InlineSuspenseFallback } from '@/components/suspense-fallback/suspense-
// Import core components synchronously to avoid suspense in main tabs
import ProjectViewEnhancedTasks from '@/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks';
import ProjectViewEnhancedBoard from '@/pages/projects/projectView/enhancedBoard/project-view-enhanced-board';
+import TaskListV2 from '@/components/task-list-v2/TaskListV2';
// Lazy load less critical components
const ProjectViewInsights = React.lazy(
@@ -35,7 +36,7 @@ export const tabItems: TabItems[] = [
key: 'tasks-list',
label: 'Task List',
isPinned: true,
- element: React.createElement(ProjectViewEnhancedTasks),
+ element: React.createElement(TaskListV2),
},
{
index: 1,
diff --git a/worklenz-frontend/src/lib/reporting/reporting-constants.ts b/worklenz-frontend/src/lib/reporting/reporting-constants.ts
index 25d23cbd..dfb975f9 100644
--- a/worklenz-frontend/src/lib/reporting/reporting-constants.ts
+++ b/worklenz-frontend/src/lib/reporting/reporting-constants.ts
@@ -1,11 +1,11 @@
-import React, { ReactNode } from 'react';
-import OverviewReports from '@/pages/reporting/overview-reports/overview-reports';
-import ProjectsReports from '@/pages/reporting/projects-reports/projects-reports';
-import MembersReports from '@/pages/reporting/members-reports/members-reports';
-import OverviewTimeReports from '@/pages/reporting/timeReports/overview-time-reports';
-import ProjectsTimeReports from '@/pages/reporting/timeReports/projects-time-reports';
-import MembersTimeReports from '@/pages/reporting/timeReports/members-time-reports';
-import EstimatedVsActualTimeReports from '@/pages/reporting/timeReports/estimated-vs-actual-time-reports';
+import React, { ReactNode, lazy } from 'react';
+const OverviewReports = lazy(() => import('@/pages/reporting/overview-reports/overview-reports'));
+const ProjectsReports = lazy(() => import('@/pages/reporting/projects-reports/projects-reports'));
+const MembersReports = lazy(() => import('@/pages/reporting/members-reports/members-reports'));
+const OverviewTimeReports = lazy(() => import('@/pages/reporting/timeReports/overview-time-reports'));
+const ProjectsTimeReports = lazy(() => import('@/pages/reporting/timeReports/projects-time-reports'));
+const MembersTimeReports = lazy(() => import('@/pages/reporting/timeReports/members-time-reports'));
+const EstimatedVsActualTimeReports = lazy(() => import('@/pages/reporting/timeReports/estimated-vs-actual-time-reports'));
// Type definition for a menu item
export type ReportingMenuItems = {
diff --git a/worklenz-frontend/src/lib/settings/settings-constants.ts b/worklenz-frontend/src/lib/settings/settings-constants.ts
index 7f9beb2a..8823bd7e 100644
--- a/worklenz-frontend/src/lib/settings/settings-constants.ts
+++ b/worklenz-frontend/src/lib/settings/settings-constants.ts
@@ -13,20 +13,20 @@ import {
UserSwitchOutlined,
BulbOutlined,
} from '@ant-design/icons';
-import React, { ReactNode } from 'react';
-import ProfileSettings from '../../pages/settings/profile/profile-settings';
-import NotificationsSettings from '../../pages/settings/notifications/notifications-settings';
-import ClientsSettings from '../../pages/settings/clients/clients-settings';
-import JobTitlesSettings from '@/pages/settings/job-titles/job-titles-settings';
-import LabelsSettings from '../../pages/settings/labels/labels-settings';
-import CategoriesSettings from '../../pages/settings/categories/categories-settings';
-import ProjectTemplatesSettings from '@/pages/settings/project-templates/project-templates-settings';
-import TaskTemplatesSettings from '@/pages/settings/task-templates/task-templates-settings';
-import TeamMembersSettings from '@/pages/settings/team-members/team-members-settings';
-import TeamsSettings from '../../pages/settings/teams/teams-settings';
-import ChangePassword from '@/pages/settings/change-password/change-password';
-import LanguageAndRegionSettings from '@/pages/settings/language-and-region/language-and-region-settings';
-import AppearanceSettings from '@/pages/settings/appearance/appearance-settings';
+import React, { ReactNode, lazy } from 'react';
+const ProfileSettings = lazy(() => import('../../pages/settings/profile/profile-settings'));
+const NotificationsSettings = lazy(() => import('../../pages/settings/notifications/notifications-settings'));
+const ClientsSettings = lazy(() => import('../../pages/settings/clients/clients-settings'));
+const JobTitlesSettings = lazy(() => import('@/pages/settings/job-titles/job-titles-settings'));
+const LabelsSettings = lazy(() => import('../../pages/settings/labels/labels-settings'));
+const CategoriesSettings = lazy(() => import('../../pages/settings/categories/categories-settings'));
+const ProjectTemplatesSettings = lazy(() => import('@/pages/settings/project-templates/project-templates-settings'));
+const TaskTemplatesSettings = lazy(() => import('@/pages/settings/task-templates/task-templates-settings'));
+const TeamMembersSettings = lazy(() => import('@/pages/settings/team-members/team-members-settings'));
+const TeamsSettings = lazy(() => import('../../pages/settings/teams/teams-settings'));
+const ChangePassword = lazy(() => import('@/pages/settings/change-password/change-password'));
+const LanguageAndRegionSettings = lazy(() => import('@/pages/settings/language-and-region/language-and-region-settings'));
+const AppearanceSettings = lazy(() => import('@/pages/settings/appearance/appearance-settings'));
// type of menu item in settings sidebar
type SettingMenuItems = {
diff --git a/worklenz-frontend/src/pages/admin-center/admin-center-constants.ts b/worklenz-frontend/src/pages/admin-center/admin-center-constants.ts
index 77161db2..1cee4eb8 100644
--- a/worklenz-frontend/src/pages/admin-center/admin-center-constants.ts
+++ b/worklenz-frontend/src/pages/admin-center/admin-center-constants.ts
@@ -5,12 +5,12 @@ import {
TeamOutlined,
UserOutlined,
} from '@ant-design/icons';
-import React, { ReactNode } from 'react';
-import Overview from './overview/overview';
-import Users from './users/users';
-import Teams from './teams/teams';
-import Billing from './billing/billing';
-import Projects from './projects/projects';
+import React, { ReactNode, lazy } from 'react';
+const Overview = lazy(() => import('./overview/overview'));
+const Users = lazy(() => import('./users/users'));
+const Teams = lazy(() => import('./teams/teams'));
+const Billing = lazy(() => import('./billing/billing'));
+const Projects = lazy(() => import('./projects/projects'));
// type of a menu item in admin center sidebar
type AdminCenterMenuItems = {
diff --git a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx
index 42dfc71e..cf42bfdb 100644
--- a/worklenz-frontend/src/pages/projects/projectView/project-view.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/project-view.tsx
@@ -119,7 +119,7 @@ const ProjectView = React.memo(() => {
return () => {
resetAllProjectData();
};
- }, []); // Empty dependency array - only runs on mount/unmount
+ }, [resetAllProjectData]);
// Effect for handling route changes (when navigating away from project view)
useEffect(() => {
@@ -358,10 +358,10 @@ const ProjectView = React.memo(() => {
minHeight: '36px',
}}
tabBarGutter={0}
- destroyInactiveTabPane={true} // Destroy inactive tabs to save memory
+ destroyInactiveTabPane={true}
animated={{
inkBar: true,
- tabPane: false, // Disable content animation for better performance
+ tabPane: false,
}}
size="small"
type="card"
diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-task-cell/task-list-task-cell.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-task-cell/task-list-task-cell.tsx
index 553843bf..293efc05 100644
--- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-task-cell/task-list-task-cell.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-cells/task-list-task-cell/task-list-task-cell.tsx
@@ -20,7 +20,7 @@ import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/tas
import { useState, useRef, useEffect } from 'react';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
-import { fetchSubTasks } from '@/features/tasks/tasks.slice';
+import { fetchSubTasks } from '@/features/task-management/task-management.slice';
type TaskListTaskCellProps = {
task: IProjectTask;
diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper.tsx
index 784b4b76..bee5c22a 100644
--- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper.tsx
@@ -266,6 +266,7 @@ const TaskListTableWrapper = ({
tableId={tableId}
activeId={activeId}
groupBy={groupBy}
+ isOver={isOver}
/>
diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx
index a9cec576..5f250577 100644
--- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx
+++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table.tsx
@@ -32,7 +32,7 @@ import {
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { createPortal } from 'react-dom';
-import { DragEndEvent } from '@dnd-kit/core';
+import { DragOverEvent } from '@dnd-kit/core';
import { List, Card, Avatar, Dropdown, Empty, Divider, Button } from 'antd';
import dayjs from 'dayjs';
@@ -90,6 +90,7 @@ interface TaskListTableProps {
tableId: string;
activeId?: string | null;
groupBy?: string;
+ isOver?: boolean; // Add this line
}
interface DraggableRowProps {
@@ -1291,6 +1292,7 @@ const TaskListTable: React.FC = ({ taskList, tableId, active
// Add drag state
const [dragActiveId, setDragActiveId] = useState(null);
+ const [placeholderIndex, setPlaceholderIndex] = useState(null);
// Configure sensors for drag and drop
const sensors = useSensors(
@@ -1640,6 +1642,7 @@ const TaskListTable: React.FC = ({ taskList, tableId, active
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setDragActiveId(null);
+ setPlaceholderIndex(null); // Reset placeholder index
if (!over || !active || active.id === over.id) {
return;
@@ -1794,6 +1797,7 @@ const TaskListTable: React.FC = ({ taskList, tableId, active
sensors={sensors}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
+ onDragOver={handleDragOver} // Add this line
autoScroll={false} // Disable auto-scroll animations
>
= ({ taskList, tableId, active
{displayTasks && displayTasks.length > 0 ? (
displayTasks
.filter(task => task?.id) // Filter out tasks without valid IDs
- .map(task => {
+ .map((task, index) => {
const updatedTask = findTaskInGroups(task.id || '') || task;
+ const isDraggingCurrent = dragActiveId === updatedTask.id;
return (
- {renderTaskRow(updatedTask)}
+ {placeholderIndex === index && (
+
+ |
+
+ Drop task here
+
+ |
+
+ )}
+ {!isDraggingCurrent && renderTaskRow(updatedTask)}
{updatedTask.show_sub_tasks && (
<>
{updatedTask?.sub_tasks?.map(subtask =>
@@ -1910,6 +1924,15 @@ const TaskListTable: React.FC = ({ taskList, tableId, active
)}
+ {placeholderIndex === displayTasks.length && (
+
+ |
+
+ Drop task here
+
+ |
+
+ )}
diff --git a/worklenz-frontend/src/types/task-list-field.types.ts b/worklenz-frontend/src/types/task-list-field.types.ts
new file mode 100644
index 00000000..35c5b24c
--- /dev/null
+++ b/worklenz-frontend/src/types/task-list-field.types.ts
@@ -0,0 +1,28 @@
+export interface TaskListField {
+ id: string;
+ name: string;
+ type: 'text' | 'number' | 'date' | 'select' | 'multiselect' | 'checkbox';
+ isVisible: boolean;
+ order: number;
+ width?: number;
+ options?: {
+ id: string;
+ label: string;
+ value: string;
+ color?: string;
+ }[];
+}
+
+export interface TaskListFieldGroup {
+ id: string;
+ name: string;
+ fields: TaskListField[];
+ order: number;
+}
+
+export interface TaskListFieldState {
+ fields: TaskListField[];
+ groups: TaskListFieldGroup[];
+ loading: boolean;
+ error: string | null;
+}
\ No newline at end of file
diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts
index 064855a8..708ebf20 100644
--- a/worklenz-frontend/src/types/task-management.types.ts
+++ b/worklenz-frontend/src/types/task-management.types.ts
@@ -1,43 +1,57 @@
import { InlineMember } from './teamMembers/inlineMember.types';
+import { EntityState } from '@reduxjs/toolkit';
export interface Task {
id: string;
- task_key: string;
- title: string;
+ title?: string; // Make title optional since it can be empty from database
+ name?: string; // Alternative name field
+ task_key?: string; // Task key field
description?: string;
- status: 'todo' | 'doing' | 'done';
- priority: 'critical' | 'high' | 'medium' | 'low';
- phase: string; // Custom phases like 'planning', 'development', 'testing', 'deployment'
- progress: number; // 0-100
- assignees: string[];
- assignee_names?: InlineMember[];
- labels: Label[];
- startDate?: string; // Start date for the task
- dueDate?: string; // Due date for the task
- completedAt?: string; // When the task was completed
- reporter?: string; // Who reported/created the task
- timeTracking: {
- estimated?: number;
- logged: number;
- };
- customFields: Record;
- createdAt: string;
- updatedAt: string;
- order: number;
- // Subtask-related properties
+ status: string;
+ priority: string;
+ phase?: string;
+ assignee?: string;
+ assignee_names?: InlineMember[]; // Array of assigned members
+ names?: InlineMember[]; // Alternative names field
+ due_date?: string;
+ dueDate?: string; // Alternative due date field
+ startDate?: string; // Start date field
+ completedAt?: string; // Completion date
+ updatedAt?: string; // Update timestamp
+ created_at: string;
+ updated_at: string;
+ sub_tasks?: Task[];
sub_tasks_count?: number;
show_sub_tasks?: boolean;
- sub_tasks?: Task[];
+ parent_task_id?: string;
+ progress?: number;
+ weight?: number;
+ color?: string;
+ statusColor?: string;
+ priorityColor?: string;
+ labels?: { id: string; name: string; color: string }[];
+ comments_count?: number;
+ attachments_count?: number;
+ has_dependencies?: boolean;
+ schedule_id?: string | null;
+ order?: number;
+ reporter?: string; // Reporter field
+ timeTracking?: { // Time tracking information
+ logged?: number;
+ estimated?: number;
+ };
+ // Add any other task properties as needed
}
export interface TaskGroup {
id: string;
title: string;
- groupType: 'status' | 'priority' | 'phase';
- groupValue: string; // The actual value for the group (e.g., 'todo', 'high', 'development')
- collapsed: boolean;
taskIds: string[];
- color?: string; // For visual distinction
+ type?: 'status' | 'priority' | 'phase' | 'members';
+ color?: string;
+ collapsed?: boolean;
+ groupValue?: string;
+ // Add any other group properties as needed
}
export interface GroupingConfig {
@@ -73,14 +87,14 @@ export interface Label {
// Redux State Interfaces
export interface TaskManagementState {
- entities: Record;
ids: string[];
+ entities: Record;
loading: boolean;
error: string | null;
- groups: TaskGroup[]; // Pre-processed groups from V3 API
- grouping: string | null; // Current grouping from V3 API
- selectedPriorities: string[]; // Selected priority filters
- search: string; // Search query for filtering tasks
+ groups: TaskGroup[];
+ grouping: string | undefined;
+ selectedPriorities: string[];
+ search: string;
}
export interface TaskGroupsState {
@@ -89,15 +103,20 @@ export interface TaskGroupsState {
}
export interface GroupingState {
- currentGrouping: 'status' | 'priority' | 'phase';
- customPhases: string[];
- groupOrder: Record;
- groupStates: Record; // Persist group states
+ currentGrouping: TaskGrouping | null;
+ collapsedGroups: Set;
}
-export interface SelectionState {
+export interface TaskGrouping {
+ id: string;
+ name: string;
+ field: string;
+ collapsed?: boolean;
+}
+
+export interface TaskSelection {
selectedTaskIds: string[];
- lastSelectedId: string | null;
+ lastSelectedTaskId: string | null;
}
export interface ColumnsState {
diff --git a/worklenz-frontend/src/utils/colorUtils.ts b/worklenz-frontend/src/utils/colorUtils.ts
index b905e9dd..1d7e9c0c 100644
--- a/worklenz-frontend/src/utils/colorUtils.ts
+++ b/worklenz-frontend/src/utils/colorUtils.ts
@@ -1,3 +1,20 @@
export const tagBackground = (color: string): string => {
return `${color}1A`; // 1A is 10% opacity in hex
};
+
+export const getContrastColor = (hexcolor: string): string => {
+ // If a color is not a valid hex, default to a sensible contrast
+ if (!/^#([A-Fa-f0-9]{3}){1,2}$/.test(hexcolor)) {
+ return '#000000'; // Default to black for invalid colors
+ }
+
+ const r = parseInt(hexcolor.slice(1, 3), 16);
+ const g = parseInt(hexcolor.slice(3, 5), 16);
+ const b = parseInt(hexcolor.slice(5, 7), 16);
+
+ // Perceptual luminance calculation (from WCAG 2.0)
+ const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
+
+ // Use a threshold to decide between black and white text
+ return luminance > 0.5 ? '#000000' : '#FFFFFF';
+};