From 2dd756bbb874ee0f4d4e76ae92a7fe378dfa932a Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Mon, 23 Jun 2025 16:34:57 +0530 Subject: [PATCH] feat(tasks): implement V3 API for task management and enhance UI components - Introduced `getTasksV3` and `refreshTaskProgress` methods in `TasksControllerV2` to optimize task retrieval and progress refreshing. - Updated API routes to include new endpoints for V3 task management. - Enhanced frontend components to utilize the new V3 API, improving performance by reducing frontend processing. - Added `VirtualizedTaskList` and `VirtualizedTaskGroup` components for efficient rendering of task lists. - Updated task management slice to support new V3 data structure and improved state management. - Refactored styles for better dark mode support and overall UI consistency. --- .../src/controllers/tasks-controller-v2.ts | 208 +++++++ .../src/routes/apis/tasks-api-router.ts | 2 + worklenz-frontend/package-lock.json | 11 + worklenz-frontend/package.json | 1 + .../src/api/tasks/tasks.api.service.ts | 27 + .../components/task-management/task-group.tsx | 10 +- .../task-management/task-list-board.tsx | 216 +++++++- .../components/task-management/task-row.tsx | 87 ++- .../virtualized-task-group.tsx | 163 ++++++ .../task-management/virtualized-task-list.tsx | 429 +++++++++++++++ .../task-management/task-management.slice.ts | 125 ++++- worklenz-frontend/src/index.css | 1 + .../taskList/ProjectViewTaskList.tsx | 57 +- .../src/styles/task-management.css | 518 +++++++----------- .../src/types/task-management.types.ts | 2 + 15 files changed, 1473 insertions(+), 384 deletions(-) create mode 100644 worklenz-frontend/src/components/task-management/virtualized-task-group.tsx create mode 100644 worklenz-frontend/src/components/task-management/virtualized-task-list.tsx diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 10c556d3..6c6d5e0e 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -967,4 +967,212 @@ export default class TasksControllerV2 extends TasksControllerBase { log_error(`Error updating task weight: ${error}`); } } + + @HandleExceptions() + public static async getTasksV3(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const isSubTasks = !!req.query.parent_task; + const groupBy = (req.query.group || GroupBy.STATUS) as string; + const archived = req.query.archived === "true"; + + // Skip heavy progress calculation for initial load to improve performance + // Progress values are already calculated and stored in the database + // Only refresh if explicitly requested + if (req.query.refresh_progress === "true" && req.params.id) { + await this.refreshProjectTaskProgressValues(req.params.id); + } + + const q = TasksControllerV2.getQuery(req.user?.id as string, req.query); + const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null]; + + const result = await db.query(q, params); + const tasks = [...result.rows]; + + // Get groups metadata dynamically from database + const groups = await this.getGroups(groupBy, req.params.id); + + // Create priority value to name mapping + const priorityMap: Record = { + "0": "low", + "1": "medium", + "2": "high" + }; + + // Create status category mapping based on actual status names from database + const statusCategoryMap: Record = {}; + for (const group of groups) { + if (groupBy === GroupBy.STATUS && group.id) { + // Use the actual status name from database, convert to lowercase for consistency + statusCategoryMap[group.id] = group.name.toLowerCase().replace(/\s+/g, "_"); + } + } + + // Transform tasks with all necessary data preprocessing + const transformedTasks = tasks.map((task, index) => { + // Update task with calculated values (lightweight version) + TasksControllerV2.updateTaskViewModel(task); + task.index = index; + + // 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 (value && typeof value === "object") { + 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; + }; + + return { + id: task.id, + task_key: task.task_key || "", + title: task.name || "", + description: task.description || "", + // Use dynamic status mapping from database + status: statusCategoryMap[task.status] || task.status, + // Pre-processed priority using mapping + priority: priorityMap[task.priority_value?.toString()] || "medium", + // Use actual phase name from database + phase: task.phase_name || "Development", + progress: typeof task.complete_ratio === "number" ? task.complete_ratio : 0, + assignees: task.assignees?.map((a: any) => a.team_member_id) || [], + assignee_names: task.assignee_names || task.names || [], + labels: task.labels?.map((l: any) => ({ + id: l.id || l.label_id, + name: l.name, + color: l.color_code || "#1890ff", + end: l.end, + names: l.names + })) || [], + dueDate: task.end_date, + timeTracking: { + estimated: convertTimeValue(task.total_time), + logged: convertTimeValue(task.time_spent), + }, + customFields: {}, + createdAt: task.created_at || new Date().toISOString(), + updatedAt: task.updated_at || new Date().toISOString(), + order: typeof task.sort_order === "number" ? task.sort_order : 0, + // Additional metadata for frontend + originalStatusId: task.status, + originalPriorityId: task.priority, + statusColor: task.status_color, + priorityColor: task.priority_color, + }; + }); + + // Create groups based on dynamic data from database + const groupedResponse: Record = {}; + + // Initialize groups from database data + groups.forEach(group => { + const groupKey = groupBy === GroupBy.STATUS + ? group.name.toLowerCase().replace(/\s+/g, "_") + : groupBy === GroupBy.PRIORITY + ? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase() + : group.name.toLowerCase().replace(/\s+/g, "_"); + + groupedResponse[groupKey] = { + id: group.id, + title: group.name, + groupType: groupBy, + groupValue: groupKey, + collapsed: false, + tasks: [], + taskIds: [], + color: group.color_code || this.getDefaultGroupColor(groupBy, groupKey), + // Include additional metadata from database + category_id: group.category_id, + start_date: group.start_date, + end_date: group.end_date, + sort_index: (group as any).sort_index, + }; + }); + + // Distribute tasks into groups + transformedTasks.forEach(task => { + let groupKey: string; + if (groupBy === GroupBy.STATUS) { + groupKey = task.status; + } else if (groupBy === GroupBy.PRIORITY) { + groupKey = task.priority; + } else { + groupKey = task.phase.toLowerCase().replace(/\s+/g, "_"); + } + + if (groupedResponse[groupKey]) { + groupedResponse[groupKey].tasks.push(task); + groupedResponse[groupKey].taskIds.push(task.id); + } + }); + + // Sort tasks within each group by order + Object.values(groupedResponse).forEach((group: any) => { + group.tasks.sort((a: any, b: any) => a.order - b.order); + }); + + // Convert to array format expected by frontend, maintaining database order + const responseGroups = groups + .map(group => { + const groupKey = groupBy === GroupBy.STATUS + ? group.name.toLowerCase().replace(/\s+/g, "_") + : groupBy === GroupBy.PRIORITY + ? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase() + : group.name.toLowerCase().replace(/\s+/g, "_"); + + return groupedResponse[groupKey]; + }) + .filter(group => group && (group.tasks.length > 0 || req.query.include_empty === "true")); + + return res.status(200).send(new ServerResponse(true, { + groups: responseGroups, + allTasks: transformedTasks, + grouping: groupBy, + totalTasks: transformedTasks.length + })); + } + + private static getDefaultGroupColor(groupBy: string, groupValue: string): string { + const colorMaps: Record> = { + [GroupBy.STATUS]: { + todo: "#f0f0f0", + doing: "#1890ff", + done: "#52c41a", + }, + [GroupBy.PRIORITY]: { + critical: "#ff4d4f", + high: "#ff7a45", + medium: "#faad14", + low: "#52c41a", + }, + [GroupBy.PHASE]: { + planning: "#722ed1", + development: "#1890ff", + testing: "#faad14", + deployment: "#52c41a", + }, + }; + + return colorMaps[groupBy]?.[groupValue] || "#d9d9d9"; + } + + @HandleExceptions() + public static async refreshTaskProgress(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + try { + if (req.params.id) { + await this.refreshProjectTaskProgressValues(req.params.id); + return res.status(200).send(new ServerResponse(true, { message: "Task progress refreshed successfully" })); + } + return res.status(400).send(new ServerResponse(false, "Project ID is required")); + } catch (error) { + log_error(`Error refreshing task progress: ${error}`); + return res.status(500).send(new ServerResponse(false, "Failed to refresh task progress")); + } + } } diff --git a/worklenz-backend/src/routes/apis/tasks-api-router.ts b/worklenz-backend/src/routes/apis/tasks-api-router.ts index bb6af547..905728ea 100644 --- a/worklenz-backend/src/routes/apis/tasks-api-router.ts +++ b/worklenz-backend/src/routes/apis/tasks-api-router.ts @@ -42,6 +42,8 @@ tasksApiRouter.get("/list/columns/:id", idParamValidator, safeControllerFunction tasksApiRouter.put("/list/columns/:id", idParamValidator, safeControllerFunction(TaskListColumnsController.toggleColumn)); tasksApiRouter.get("/list/v2/:id", idParamValidator, safeControllerFunction(getList)); +tasksApiRouter.get("/list/v3/:id", idParamValidator, safeControllerFunction(TasksControllerV2.getTasksV3)); +tasksApiRouter.post("/refresh-progress/:id", idParamValidator, safeControllerFunction(TasksControllerV2.refreshTaskProgress)); tasksApiRouter.get("/assignees/:id", idParamValidator, safeControllerFunction(TasksController.getProjectTaskAssignees)); tasksApiRouter.put("/bulk/status", mapTasksToBulkUpdate, bulkTasksStatusValidator, safeControllerFunction(TasksController.bulkChangeStatus)); diff --git a/worklenz-frontend/package-lock.json b/worklenz-frontend/package-lock.json index 68978bd0..4a7afbfa 100644 --- a/worklenz-frontend/package-lock.json +++ b/worklenz-frontend/package-lock.json @@ -66,6 +66,7 @@ "@types/node": "^20.8.4", "@types/react": "19.0.0", "@types/react-dom": "19.0.0", + "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "postcss": "^8.5.2", @@ -2635,6 +2636,16 @@ "@types/react": "*" } }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", diff --git a/worklenz-frontend/package.json b/worklenz-frontend/package.json index 9eaa43ff..33e5571b 100644 --- a/worklenz-frontend/package.json +++ b/worklenz-frontend/package.json @@ -70,6 +70,7 @@ "@types/node": "^20.8.4", "@types/react": "19.0.0", "@types/react-dom": "19.0.0", + "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "postcss": "^8.5.2", diff --git a/worklenz-frontend/src/api/tasks/tasks.api.service.ts b/worklenz-frontend/src/api/tasks/tasks.api.service.ts index cd3d80dd..c8710a36 100644 --- a/worklenz-frontend/src/api/tasks/tasks.api.service.ts +++ b/worklenz-frontend/src/api/tasks/tasks.api.service.ts @@ -30,6 +30,22 @@ export interface ITaskListConfigV2 { isSubtasksInclude: boolean; } +export interface ITaskListV3Response { + groups: Array<{ + id: string; + title: string; + groupType: 'status' | 'priority' | 'phase'; + groupValue: string; + collapsed: boolean; + tasks: any[]; + taskIds: string[]; + color: string; + }>; + allTasks: any[]; + grouping: string; + totalTasks: number; +} + export const tasksApiService = { getTaskList: async (config: ITaskListConfigV2): Promise> => { const q = toQueryString(config); @@ -119,4 +135,15 @@ export const tasksApiService = { const response = await apiClient.get(`${rootUrl}/dependency-status${q}`); return response.data; }, + + getTaskListV3: async (config: ITaskListConfigV2): Promise> => { + const q = toQueryString(config); + const response = await apiClient.get(`${rootUrl}/list/v3/${config.id}${q}`); + return response.data; + }, + + refreshTaskProgress: async (projectId: string): Promise> => { + const response = await apiClient.post(`${rootUrl}/refresh-progress/${projectId}`); + return response.data; + }, }; diff --git a/worklenz-frontend/src/components/task-management/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx index 9919c313..335ebbef 100644 --- a/worklenz-frontend/src/components/task-management/task-group.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -31,7 +31,6 @@ const GROUP_COLORS = { done: '#52c41a', }, priority: { - critical: '#ff4d4f', high: '#fa8c16', medium: '#faad14', low: '#52c41a', @@ -63,6 +62,9 @@ const TaskGroup: React.FC = React.memo(({ // Get all tasks from the store const allTasks = useSelector(taskManagementSelectors.selectAll); + // Get theme from Redux store + const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark'); + // Get tasks for this group using memoization for performance const groupTasks = useMemo(() => { return group.taskIds @@ -112,8 +114,10 @@ const TaskGroup: React.FC = React.memo(({ // Memoized style object const containerStyle = useMemo(() => ({ - backgroundColor: isOver ? '#f0f8ff' : undefined, - }), [isOver]); + backgroundColor: isOver + ? (isDarkMode ? '#1a2332' : '#f0f8ff') + : undefined, + }), [isOver, isDarkMode]); return (
= ({ projectId, className = '' // Refs for performance optimization const dragOverTimeoutRef = useRef(null); + const containerRef = useRef(null); // Enable real-time socket updates for task changes useTaskSocketHandlers(); - // Redux selectors using new task management slices + // Redux selectors using V3 API (pre-processed data, minimal loops) const tasks = useSelector(taskManagementSelectors.selectAll); - const taskGroups = useSelector(selectTaskGroups); - const currentGrouping = useSelector(selectCurrentGrouping); + const taskGroups = useSelector(selectTaskGroupsV3); // Pre-processed groups from backend + const currentGrouping = useSelector(selectCurrentGroupingV3); // Current grouping from backend const selectedTaskIds = useSelector(selectSelectedTaskIds); const loading = useSelector((state: RootState) => state.taskManagement.loading); const error = useSelector((state: RootState) => state.taskManagement.error); + + // Get theme from Redux store + const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark'); // Drag and Drop sensors - optimized for better performance const sensors = useSensors( @@ -112,8 +119,8 @@ const TaskListBoard: React.FC = ({ projectId, className = '' // Fetch task groups when component mounts or dependencies change useEffect(() => { if (projectId) { - // Fetch real tasks from API - dispatch(fetchTasks(projectId)); + // Fetch real tasks from V3 API (minimal processing needed) + dispatch(fetchTasksV3(projectId)); } }, [dispatch, projectId, currentGrouping]); @@ -123,7 +130,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const hasSelection = selectedTaskIds.length > 0; // Memoized handlers for better performance - const handleGroupingChange = useCallback((newGroupBy: typeof currentGrouping) => { + const handleGroupingChange = useCallback((newGroupBy: 'status' | 'priority' | 'phase') => { dispatch(setCurrentGrouping(newGroupBy)); }, [dispatch]); @@ -308,7 +315,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' task={dragState.activeTask} projectId={projectId} groupId={dragState.activeGroupId} - currentGrouping={currentGrouping} + currentGrouping={(currentGrouping as 'status' | 'priority' | 'phase') || 'status'} isSelected={false} isDragOverlay /> @@ -336,7 +343,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' } return ( -
+
= ({ projectId, className = '' /> )} - {/* Task Groups Container */} + {/* Virtualized Task Groups Container */}
{loading ? ( @@ -382,18 +389,31 @@ const TaskListBoard: React.FC = ({ projectId, className = '' /> ) : ( -
- {taskGroups.map((group) => ( - - ))} +
+ {taskGroups.map((group, index) => { + // Calculate dynamic height for each group + const groupTasks = group.taskIds.length; + const baseHeight = 120; // Header + column headers + add task row + const taskRowsHeight = groupTasks * 40; // 40px per task row + const minGroupHeight = 300; // Minimum height for better visual appearance + const maxGroupHeight = 600; // Increased maximum height per group + const calculatedHeight = baseHeight + taskRowsHeight; + const groupHeight = Math.max(minGroupHeight, Math.min(calculatedHeight, maxGroupHeight)); + + return ( + + ); + })}
)}
@@ -422,13 +442,150 @@ const TaskListBoard: React.FC = ({ projectId, className = '' will-change: scroll-position; } - .task-groups { + .virtualized-task-groups { min-width: fit-content; position: relative; /* GPU acceleration for drag operations */ transform: translateZ(0); } + .virtualized-task-group { + border: 1px solid var(--task-border-primary, #e8e8e8); + border-radius: 8px; + margin-bottom: 16px; + background: var(--task-bg-primary, white); + box-shadow: 0 1px 3px var(--task-shadow, rgba(0, 0, 0, 0.1)); + overflow: hidden; + transition: all 0.3s ease; + position: relative; + } + + .virtualized-task-group:last-child { + margin-bottom: 0; + } + + /* Task group header styles */ + .task-group-header { + background: var(--task-bg-primary, white); + transition: background-color 0.3s ease; + } + + .task-group-header-row { + display: inline-flex; + height: auto; + max-height: none; + overflow: hidden; + } + + .task-group-header-content { + display: inline-flex; + align-items: center; + padding: 8px 12px; + border-radius: 6px 6px 0 0; + background-color: #f0f0f0; + color: white; + font-weight: 500; + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; + } + + .task-group-header-text { + color: white !important; + font-size: 13px !important; + font-weight: 600 !important; + margin: 0 !important; + } + + /* Column headers styles */ + .task-group-column-headers { + background: var(--task-bg-secondary, #f5f5f5); + border-bottom: 1px solid var(--task-border-tertiary, #d9d9d9); + transition: background-color 0.3s ease; + } + + .task-group-column-headers-row { + display: flex; + height: 40px; + max-height: 40px; + overflow: visible; + position: relative; + min-width: 1200px; + } + + .task-table-header-cell { + background: var(--task-bg-secondary, #f5f5f5); + font-weight: 600; + color: var(--task-text-secondary, #595959); + text-transform: uppercase; + letter-spacing: 0.5px; + border-bottom: 1px solid var(--task-border-tertiary, #d9d9d9); + height: 32px; + max-height: 32px; + overflow: hidden; + transition: all 0.3s ease; + } + + .column-header-text { + font-size: 11px; + font-weight: 600; + color: var(--task-text-secondary, #595959); + text-transform: uppercase; + letter-spacing: 0.5px; + transition: color 0.3s ease; + } + + /* Add task row styles */ + .task-group-add-task { + background: var(--task-bg-primary, white); + border-top: 1px solid var(--task-border-secondary, #f0f0f0); + transition: all 0.3s ease; + padding: 0 12px; + width: 100%; + min-height: 40px; + display: flex; + align-items: center; + } + + .task-group-add-task:hover { + background: var(--task-hover-bg, #fafafa); + } + + .task-table-fixed-columns { + display: flex; + background: var(--task-bg-secondary, #f5f5f5); + position: sticky; + left: 0; + z-index: 11; + border-right: 2px solid var(--task-border-primary, #e8e8e8); + box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1); + transition: all 0.3s ease; + } + + .task-table-scrollable-columns { + display: flex; + flex: 1; + min-width: 0; + } + + .task-table-cell { + display: flex; + align-items: center; + padding: 0 12px; + border-right: 1px solid var(--task-border-secondary, #f0f0f0); + font-size: 12px; + white-space: nowrap; + height: 40px; + max-height: 40px; + min-height: 40px; + overflow: hidden; + color: var(--task-text-primary, #262626); + transition: all 0.3s ease; + } + + .task-table-cell:last-child { + border-right: none; + } + /* Optimized drag overlay styles */ [data-dnd-overlay] { /* GPU acceleration for smooth dragging */ @@ -503,7 +660,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' } /* Performance optimizations */ - .task-group { + .virtualized-task-group { contain: layout style paint; } @@ -515,6 +672,15 @@ const TaskListBoard: React.FC = ({ projectId, className = '' .task-table-cell { contain: layout; } + + /* React Window specific optimizations */ + .react-window-list { + outline: none; + } + + .react-window-list-item { + contain: layout style; + } `}
); diff --git a/worklenz-frontend/src/components/task-management/task-row.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx index 4f604d03..d95a281e 100644 --- a/worklenz-frontend/src/components/task-management/task-row.tsx +++ b/worklenz-frontend/src/components/task-management/task-row.tsx @@ -1,7 +1,9 @@ -import React, { useMemo, useCallback } from 'react'; +import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { useSelector } from 'react-redux'; +import { Input, Typography } from 'antd'; +import type { InputRef } from 'antd'; import { HolderOutlined, MessageOutlined, @@ -11,6 +13,8 @@ import { import { Task } from '@/types/task-management.types'; import { RootState } from '@/app/store'; import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tag, Tooltip } from '@/components'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; interface TaskRowProps { task: Task; @@ -49,6 +53,14 @@ const TaskRow: React.FC = React.memo(({ onSelect, onToggleSubtasks, }) => { + const { socket, connected } = useSocket(); + + // Edit task name state + const [editTaskName, setEditTaskName] = useState(false); + const [taskName, setTaskName] = useState(task.title || ''); + const inputRef = useRef(null); + const wrapperRef = useRef(null); + const { attributes, listeners, @@ -69,6 +81,40 @@ const TaskRow: React.FC = React.memo(({ // Get theme from Redux store const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark'); + // Click outside detection for edit mode + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) { + handleTaskNameSave(); + } + }; + + if (editTaskName) { + document.addEventListener('mousedown', handleClickOutside); + inputRef.current?.focus(); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [editTaskName]); + + // Handle task name save + const handleTaskNameSave = useCallback(() => { + const newTaskName = inputRef.current?.input?.value; + if (newTaskName?.trim() !== '' && connected && newTaskName !== task.title) { + socket?.emit( + SocketEvents.TASK_NAME_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + name: newTaskName, + parent_task: null, // Assuming top-level tasks for now + }) + ); + } + setEditTaskName(false); + }, [connected, socket, task.id, task.title]); + // Memoize style calculations - simplified const style = useMemo(() => ({ transform: CSS.Transform.toString(transform), @@ -97,12 +143,11 @@ const TaskRow: React.FC = React.memo(({ ? 'border-gray-700 bg-gray-900 hover:bg-gray-800' : 'border-gray-200 bg-white hover:bg-gray-50'; const selectedClasses = isSelected - ? (isDarkMode ? 'bg-blue-900/20 border-l-4 border-l-blue-500' : 'bg-blue-50 border-l-4 border-l-blue-500') + ? (isDarkMode ? 'bg-blue-900/20' : 'bg-blue-50') : ''; const overlayClasses = isDragOverlay ? `rounded shadow-lg border-2 ${isDarkMode ? 'bg-gray-900 border-gray-600 shadow-2xl' : 'bg-white border-gray-300 shadow-2xl'}` : ''; - return `${baseClasses} ${themeClasses} ${selectedClasses} ${overlayClasses}`; }, [isDarkMode, isSelected, isDragOverlay]); @@ -112,8 +157,8 @@ const TaskRow: React.FC = React.memo(({ ); const taskNameClasses = useMemo(() => { - const baseClasses = 'text-sm font-medium flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300'; - const themeClasses = isDarkMode ? 'text-gray-100' : 'text-gray-900'; + const baseClasses = 'text-sm font-medium flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300 cursor-pointer'; + const themeClasses = isDarkMode ? 'text-gray-100 hover:text-blue-400' : 'text-gray-900 hover:text-blue-600'; const completedClasses = task.progress === 100 ? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}` : ''; @@ -207,12 +252,36 @@ const TaskRow: React.FC = React.memo(({
{/* Task Name */} -
+
- - {task.title} - +
+ {!editTaskName ? ( + setEditTaskName(true)} + className={taskNameClasses} + style={{ cursor: 'pointer' }} + > + {task.title} + + ) : ( + ) => setTaskName(e.target.value)} + onPressEnter={handleTaskNameSave} + className={`${isDarkMode ? 'bg-gray-800 text-gray-100 border-gray-600' : 'bg-white text-gray-900 border-gray-300'}`} + style={{ + width: '100%', + padding: '2px 4px', + fontSize: '14px', + fontWeight: 500, + }} + /> + )} +
diff --git a/worklenz-frontend/src/components/task-management/virtualized-task-group.tsx b/worklenz-frontend/src/components/task-management/virtualized-task-group.tsx new file mode 100644 index 00000000..70ae7c2c --- /dev/null +++ b/worklenz-frontend/src/components/task-management/virtualized-task-group.tsx @@ -0,0 +1,163 @@ +import React, { useMemo, useCallback } from 'react'; +import { FixedSizeList as List } from 'react-window'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { useSelector } from 'react-redux'; +import { taskManagementSelectors } from '@/features/task-management/task-management.slice'; +import { Task } from '@/types/task-management.types'; +import TaskRow from './task-row'; + +interface VirtualizedTaskGroupProps { + group: any; + projectId: string; + currentGrouping: 'status' | 'priority' | 'phase'; + selectedTaskIds: string[]; + onSelectTask: (taskId: string, selected: boolean) => void; + onToggleSubtasks: (taskId: string) => void; + height: number; + width: number; +} + +const VirtualizedTaskGroup: React.FC = React.memo(({ + group, + projectId, + currentGrouping, + selectedTaskIds, + onSelectTask, + onToggleSubtasks, + height, + width +}) => { + const allTasks = useSelector(taskManagementSelectors.selectAll); + + // Get tasks for this group using memoization for performance + const groupTasks = useMemo(() => { + return group.taskIds + .map((taskId: string) => allTasks.find((task: Task) => task.id === taskId)) + .filter((task: Task | undefined): task is Task => task !== undefined); + }, [group.taskIds, allTasks]); + + const TASK_ROW_HEIGHT = 40; + const GROUP_HEADER_HEIGHT = 40; + const COLUMN_HEADER_HEIGHT = 40; + const ADD_TASK_ROW_HEIGHT = 40; + + // Calculate total height for the group + const totalHeight = GROUP_HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + (groupTasks.length * TASK_ROW_HEIGHT) + ADD_TASK_ROW_HEIGHT; + + // Row renderer for virtualization + const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => { + // Header row + if (index === 0) { + return ( +
+
+
+
+ + {group.title} ({groupTasks.length}) + +
+
+
+
+ ); + } + + // Column headers row + if (index === 1) { + return ( +
+
+
+
+
+
+
+ Key +
+
+ Task +
+
+
+
+ Progress +
+
+ Members +
+
+ Labels +
+
+ Status +
+
+ Priority +
+
+ Time Tracking +
+
+
+
+
+ ); + } + + // Task rows + const taskIndex = index - 2; + if (taskIndex >= 0 && taskIndex < groupTasks.length) { + const task = groupTasks[taskIndex]; + return ( +
+ +
+ ); + } + + // Add task row (last row) + if (taskIndex === groupTasks.length) { + return ( +
+
+
+
+ + Add task +
+
+
+
+ ); + } + + return null; + }, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks]); + + return ( +
+ + + {Row} + + +
+ ); +}); + +export default VirtualizedTaskGroup; \ 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 new file mode 100644 index 00000000..5fe42380 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx @@ -0,0 +1,429 @@ +import React, { useMemo, useCallback, useEffect } from 'react'; +import { FixedSizeList as List } from 'react-window'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { useSelector } from 'react-redux'; +import { taskManagementSelectors } from '@/features/task-management/task-management.slice'; +import { Task } from '@/types/task-management.types'; +import TaskRow from './task-row'; +import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row'; + +interface VirtualizedTaskListProps { + group: any; + projectId: string; + currentGrouping: 'status' | 'priority' | 'phase'; + selectedTaskIds: string[]; + onSelectTask: (taskId: string, selected: boolean) => void; + onToggleSubtasks: (taskId: string) => void; + height: number; + width: number; +} + +const VirtualizedTaskList: React.FC = React.memo(({ + group, + projectId, + currentGrouping, + selectedTaskIds, + onSelectTask, + onToggleSubtasks, + height, + width +}) => { + const allTasks = useSelector(taskManagementSelectors.selectAll); + + // Get tasks for this group using memoization for performance + const groupTasks = useMemo(() => { + return group.taskIds + .map((taskId: string) => allTasks.find((task: Task) => task.id === taskId)) + .filter((task: Task | undefined): task is Task => task !== undefined); + }, [group.taskIds, allTasks]); + + const TASK_ROW_HEIGHT = 40; + const HEADER_HEIGHT = 40; + const COLUMN_HEADER_HEIGHT = 40; + + // Calculate the actual height needed for the virtualized list + const actualContentHeight = HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + (groupTasks.length * TASK_ROW_HEIGHT); + const listHeight = Math.min(height - 40, actualContentHeight); + + // Calculate item count - only include actual content + const getItemCount = () => { + return groupTasks.length + 2; // +2 for header and column headers only + }; + + // Debug logging + useEffect(() => { + console.log('VirtualizedTaskList:', { + groupId: group.id, + groupTasks: groupTasks.length, + height, + listHeight, + itemCount: getItemCount(), + isVirtualized: groupTasks.length > 10, // Show if virtualization should be active + minHeight: 300, + maxHeight: 600 + }); + }, [group.id, groupTasks.length, height, listHeight]); + + // Row renderer for virtualization + const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => { + // Header row + if (index === 0) { + return ( +
+
+
+
+ + {group.title} ({groupTasks.length}) + +
+
+
+
+ ); + } + + // Column headers row + if (index === 1) { + return ( +
+
+
+
+
+
+
+ Key +
+
+ Task +
+
+
+
+ Progress +
+
+ Members +
+
+ Labels +
+
+ Status +
+
+ Priority +
+
+ Time Tracking +
+
+
+
+
+ ); + } + + // Task rows + const taskIndex = index - 2; + if (taskIndex >= 0 && taskIndex < groupTasks.length) { + const task = groupTasks[taskIndex]; + return ( +
+ +
+ ); + } + + return null; + }, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks]); + + return ( +
+ + + {Row} + + + + {/* Add Task Row - Always show at the bottom */} +
+ +
+ + +
+ ); +}); + +export default VirtualizedTaskList; \ No newline at end of file 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 861ff4a1..248cd1e7 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -1,7 +1,7 @@ import { createSlice, createEntityAdapter, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; import { Task, TaskManagementState } from '@/types/task-management.types'; import { RootState } from '@/app/store'; -import { tasksApiService, ITaskListConfigV2 } from '@/api/tasks/tasks.api.service'; +import { tasksApiService, ITaskListConfigV2, ITaskListV3Response } from '@/api/tasks/tasks.api.service'; import logger from '@/utils/errorLogger'; // Entity adapter for normalized state @@ -14,6 +14,8 @@ const initialState: TaskManagementState = { ids: [], loading: false, error: null, + groups: [], + grouping: null, }; // Async thunk to fetch tasks from API @@ -59,6 +61,31 @@ export const fetchTasks = createAsyncThunk( return 0; }; + // Create a mapping from status IDs to group names + const statusIdToNameMap: Record = {}; + const priorityIdToNameMap: Record = {}; + + response.body.forEach((group: any) => { + statusIdToNameMap[group.id] = group.name.toLowerCase(); + }); + + // For priority mapping, we need to get priority names from the tasks themselves + // Since the API doesn't provide priority names in the group structure + response.body.forEach((group: any) => { + group.tasks.forEach((task: any) => { + // Map priority value to name (this is an assumption based on common patterns) + if (task.priority_value !== undefined) { + switch (task.priority_value) { + case 0: priorityIdToNameMap[task.priority] = 'low'; break; + case 1: priorityIdToNameMap[task.priority] = 'medium'; break; + case 2: priorityIdToNameMap[task.priority] = 'high'; break; + case 3: priorityIdToNameMap[task.priority] = 'critical'; break; + default: priorityIdToNameMap[task.priority] = 'medium'; + } + } + }); + }); + // Transform the API response to our Task type const tasks: Task[] = response.body.flatMap((group: any) => group.tasks.map((task: any) => ({ @@ -66,8 +93,8 @@ export const fetchTasks = createAsyncThunk( task_key: task.task_key || '', title: task.name || '', description: task.description || '', - status: task.status_name?.toLowerCase() || 'todo', - priority: task.priority_name?.toLowerCase() || 'medium', + status: statusIdToNameMap[task.status] || 'todo', + priority: priorityIdToNameMap[task.priority] || 'medium', phase: task.phase_name || 'Development', progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0, assignees: task.assignees?.map((a: any) => a.team_member_id) || [], @@ -102,6 +129,65 @@ export const fetchTasks = createAsyncThunk( } ); +// New V3 fetch that minimizes frontend processing +export const fetchTasksV3 = createAsyncThunk( + 'taskManagement/fetchTasksV3', + async (projectId: string, { rejectWithValue, getState }) => { + try { + const state = getState() as RootState; + const currentGrouping = state.grouping.currentGrouping; + + const config: ITaskListConfigV2 = { + id: projectId, + archived: false, + group: currentGrouping, + field: '', + order: '', + search: '', + statuses: '', + members: '', + projects: '', + isSubtasksInclude: false, + labels: '', + priorities: '', + }; + + const response = await tasksApiService.getTaskListV3(config); + + // Minimal processing - tasks are already processed by backend + return { + tasks: response.body.allTasks, + groups: response.body.groups, + grouping: response.body.grouping, + totalTasks: response.body.totalTasks + }; + } catch (error) { + logger.error('Fetch Tasks V3', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to fetch tasks'); + } + } +); + +// Refresh task progress separately to avoid slowing down initial load +export const refreshTaskProgress = createAsyncThunk( + 'taskManagement/refreshTaskProgress', + async (projectId: string, { rejectWithValue }) => { + try { + const response = await tasksApiService.refreshTaskProgress(projectId); + return response.body; + } catch (error) { + logger.error('Refresh Task Progress', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to refresh task progress'); + } + } +); + const taskManagementSlice = createSlice({ name: 'taskManagement', initialState: tasksAdapter.getInitialState(initialState), @@ -234,6 +320,33 @@ const taskManagementSlice = createSlice({ .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; + }) + .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; + }) + .addCase(fetchTasksV3.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string || 'Failed to fetch tasks'; + }) + .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'; }); }, }); @@ -270,4 +383,8 @@ export const selectTasksByPhase = (state: RootState, phase: string) => taskManagementSelectors.selectAll(state).filter(task => task.phase === phase); export const selectTasksLoading = (state: RootState) => state.taskManagement.loading; -export const selectTasksError = (state: RootState) => state.taskManagement.error; \ No newline at end of file +export const selectTasksError = (state: RootState) => state.taskManagement.error; + +// V3 API selectors - no processing needed, data is pre-processed by backend +export const selectTaskGroupsV3 = (state: RootState) => state.taskManagement.groups; +export const selectCurrentGroupingV3 = (state: RootState) => state.taskManagement.grouping; \ No newline at end of file diff --git a/worklenz-frontend/src/index.css b/worklenz-frontend/src/index.css index 3c1af53d..d6902d25 100644 --- a/worklenz-frontend/src/index.css +++ b/worklenz-frontend/src/index.css @@ -1,5 +1,6 @@ @import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"); @import url("./styles/customOverrides.css"); +@import url("./styles/task-management.css"); @tailwind base; @tailwind components; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/ProjectViewTaskList.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/ProjectViewTaskList.tsx index a3ec005b..e58d7793 100644 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/ProjectViewTaskList.tsx +++ b/worklenz-frontend/src/pages/projects/project-view-1/taskList/ProjectViewTaskList.tsx @@ -1,54 +1,49 @@ import { useEffect } from 'react'; import { Flex } from 'antd'; -import TaskListFilters from './taskListFilters/TaskListFilters'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; -import { fetchTaskGroups } from '@/features/tasks/tasks.slice'; -import { ITaskListConfigV2 } from '@/types/tasks/taskList.types'; -import TanStackTable from '../task-list/task-list-custom'; -import TaskListCustom from '../task-list/task-list-custom'; -import TaskListTableWrapper from '../task-list/task-list-table-wrapper/task-list-table-wrapper'; +import { fetchTasksV3 } from '@/features/task-management/task-management.slice'; +import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; +import TaskListBoard from '@/components/task-management/task-list-board'; const ProjectViewTaskList = () => { - // sample data from task reducer const dispatch = useAppDispatch(); - const { taskGroups, loadingGroups } = useAppSelector(state => state.taskReducer); - const { statusCategories } = useAppSelector(state => state.taskStatusReducer); const projectId = useAppSelector(state => state.projectReducer.projectId); + const { statusCategories } = useAppSelector(state => state.taskStatusReducer); useEffect(() => { if (projectId) { - const config: ITaskListConfigV2 = { - id: projectId, - field: 'id', - order: 'desc', - search: '', - statuses: '', - members: '', - projects: '', - isSubtasksInclude: true, - }; - dispatch(fetchTaskGroups(config)); + // Use the optimized V3 API for faster loading + dispatch(fetchTasksV3(projectId)); } if (!statusCategories.length) { dispatch(fetchStatusesCategories()); } }, [dispatch, projectId]); + // Cleanup effect - reset values when component is destroyed + useEffect(() => { + return () => { + // Clear any selected tasks when component unmounts + dispatch(deselectAll()); + }; + }, [dispatch]); + + if (!projectId) { + return ( + +
No project selected
+
+ ); + } + return ( - - - {taskGroups.map(group => ( - - ))} + ); }; diff --git a/worklenz-frontend/src/styles/task-management.css b/worklenz-frontend/src/styles/task-management.css index e1f797c8..82b18b33 100644 --- a/worklenz-frontend/src/styles/task-management.css +++ b/worklenz-frontend/src/styles/task-management.css @@ -213,389 +213,283 @@ outline-offset: 2px; } -/* Dark mode support */ -[data-theme="dark"] .task-list-board { +/* Dark mode support using class-based selectors */ +.dark .task-list-board { background-color: #141414; color: rgba(255, 255, 255, 0.85); } -@media (prefers-color-scheme: dark) { - .task-list-board { - background-color: #141414; - color: rgba(255, 255, 255, 0.85); - } - - /* Task Groups */ - .task-group { - background-color: #1f1f1f; - border-color: #303030; - } - - .task-group.drag-over { - border-color: #1890ff !important; - box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3); - background-color: rgba(24, 144, 255, 0.1); - } - - .task-group .group-header { - background: #262626; - border-bottom-color: #303030; - color: rgba(255, 255, 255, 0.85); - } - - .task-group .group-header:hover { - background: #2f2f2f; - } - - /* Task Rows */ - .task-row { - background-color: #1f1f1f; - color: rgba(255, 255, 255, 0.85); - border-color: #303030; - } - - .task-row:hover { - background-color: #262626 !important; - border-left-color: #595959; - } - - .task-row.selected { - background-color: rgba(24, 144, 255, 0.15) !important; - border-left-color: #1890ff; - } - - .task-row .drag-handle { - color: rgba(255, 255, 255, 0.45); - } - - .task-row .drag-handle:hover { - color: rgba(255, 255, 255, 0.85); - } - - /* Progress bars */ - .ant-progress-bg { - background-color: #303030; - } - - /* Text colors */ - .task-row .ant-typography { - color: rgba(255, 255, 255, 0.85); - } - - .task-row .text-gray-500 { - color: rgba(255, 255, 255, 0.45) !important; - } - - .task-row .text-gray-600 { - color: rgba(255, 255, 255, 0.65) !important; - } - - .task-row .text-gray-400 { - color: rgba(255, 255, 255, 0.45) !important; - } - - /* Completed task styling */ - .task-row .line-through { - color: rgba(255, 255, 255, 0.45); - } - - /* Bulk Action Bar */ - .bulk-action-bar { - background: rgba(24, 144, 255, 0.15); - border-color: rgba(24, 144, 255, 0.3); - color: rgba(255, 255, 255, 0.85); - } - - /* Cards and containers */ - .ant-card { - background-color: #1f1f1f; - border-color: #303030; - color: rgba(255, 255, 255, 0.85); - } - - .ant-card-head { - background-color: #262626; - border-bottom-color: #303030; - color: rgba(255, 255, 255, 0.85); - } - - .ant-card-body { - background-color: #1f1f1f; - color: rgba(255, 255, 255, 0.85); - } - - /* Buttons */ - .ant-btn { - border-color: #303030; - color: rgba(255, 255, 255, 0.85); - } - - .ant-btn:hover { - border-color: #595959; - color: rgba(255, 255, 255, 0.85); - } - - .ant-btn-primary { - background-color: #1890ff; - border-color: #1890ff; - } - - .ant-btn-primary:hover { - background-color: #40a9ff; - border-color: #40a9ff; - } - - /* Dropdowns and menus */ - .ant-dropdown-menu { - background-color: #1f1f1f; - border-color: #303030; - } - - .ant-dropdown-menu-item { - color: rgba(255, 255, 255, 0.85); - } - - .ant-dropdown-menu-item:hover { - background-color: #262626; - } - - /* Select components */ - .ant-select-selector { - background-color: #1f1f1f !important; - border-color: #303030 !important; - color: rgba(255, 255, 255, 0.85) !important; - } - - .ant-select-arrow { - color: rgba(255, 255, 255, 0.45); - } - - /* Checkboxes */ - .ant-checkbox-wrapper { - color: rgba(255, 255, 255, 0.85); - } - - .ant-checkbox-inner { - background-color: #1f1f1f; - border-color: #303030; - } - - .ant-checkbox-checked .ant-checkbox-inner { - background-color: #1890ff; - border-color: #1890ff; - } - - /* Tags and labels */ - .ant-tag { - background-color: #262626; - border-color: #303030; - color: rgba(255, 255, 255, 0.85); - } - - /* Avatars */ - .ant-avatar { - background-color: #595959; - color: rgba(255, 255, 255, 0.85); - } - - /* Tooltips */ - .ant-tooltip-inner { - background-color: #262626; - color: rgba(255, 255, 255, 0.85); - } - - .ant-tooltip-arrow-content { - background-color: #262626; - } - - /* Popconfirm */ - .ant-popover-inner { - background-color: #1f1f1f; - color: rgba(255, 255, 255, 0.85); - } - - .ant-popover-arrow-content { - background-color: #1f1f1f; - } - - /* Subtasks */ - .task-subtasks { - border-left-color: #303030; - } - - .task-subtasks .task-row { - background-color: #141414; - } - - .task-subtasks .task-row:hover { - background-color: #1f1f1f !important; - } - - /* Scrollbars */ - .task-groups-container::-webkit-scrollbar-track { - background: #141414; - } - - .task-groups-container::-webkit-scrollbar-thumb { - background: #595959; - } - - .task-groups-container::-webkit-scrollbar-thumb:hover { - background: #777777; - } - - /* Loading states */ - .ant-spin-dot-item { - background-color: #1890ff; - } - - /* Empty states */ - .ant-empty { - color: rgba(255, 255, 255, 0.45); - } - - .ant-empty-description { - color: rgba(255, 255, 255, 0.45); - } - - /* Focus styles for dark mode */ - .task-row:focus-within { - outline-color: #40a9ff; - } - - .drag-handle:focus { - outline-color: #40a9ff; - } - - /* Border colors */ - .border-gray-100 { - border-color: #303030 !important; - } - - .border-gray-200 { - border-color: #404040 !important; - } - - .border-gray-300 { - border-color: #595959 !important; - } - - /* Background utilities */ - .bg-gray-50 { - background-color: #141414 !important; - } - - .bg-gray-100 { - background-color: #1f1f1f !important; - } - - .bg-white { - background-color: #1f1f1f !important; - } - - /* Due date colors in dark mode */ - .text-red-500 { - color: #ff7875 !important; - } - - .text-orange-500 { - color: #ffa940 !important; - } - - /* Group progress bar in dark mode */ - .task-group .group-header .bg-gray-200 { - background-color: #303030 !important; - } -} - -/* Specific dark mode styles using data-theme attribute */ -[data-theme="dark"] .task-group { +.dark .task-group { background-color: #1f1f1f; border-color: #303030; } -[data-theme="dark"] .task-group.drag-over { +.dark .task-group.drag-over { border-color: #1890ff !important; box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3); background-color: rgba(24, 144, 255, 0.1); } -[data-theme="dark"] .task-group .group-header { +.dark .task-group .group-header { background: #262626; border-bottom-color: #303030; color: rgba(255, 255, 255, 0.85); } -[data-theme="dark"] .task-group .group-header:hover { +.dark .task-group .group-header:hover { background: #2f2f2f; } -[data-theme="dark"] .task-row { +.dark .task-row { background-color: #1f1f1f; color: rgba(255, 255, 255, 0.85); border-color: #303030; } -[data-theme="dark"] .task-row:hover { +.dark .task-row:hover { background-color: #262626 !important; border-left-color: #595959; } -[data-theme="dark"] .task-row.selected { +.dark .task-row.selected { background-color: rgba(24, 144, 255, 0.15) !important; border-left-color: #1890ff; } -[data-theme="dark"] .task-row .drag-handle { +.dark .task-row .drag-handle { color: rgba(255, 255, 255, 0.45); } -[data-theme="dark"] .task-row .drag-handle:hover { +.dark .task-row .drag-handle:hover { color: rgba(255, 255, 255, 0.85); } -[data-theme="dark"] .bulk-action-bar { +.dark .bulk-action-bar { background: rgba(24, 144, 255, 0.15); border-color: rgba(24, 144, 255, 0.3); color: rgba(255, 255, 255, 0.85); } -[data-theme="dark"] .task-row .ant-typography { +.dark .task-row .ant-typography { color: rgba(255, 255, 255, 0.85); } -[data-theme="dark"] .task-row .text-gray-500 { +.dark .task-row .text-gray-500 { color: rgba(255, 255, 255, 0.45) !important; } -[data-theme="dark"] .task-row .text-gray-600 { +.dark .task-row .text-gray-600 { color: rgba(255, 255, 255, 0.65) !important; } -[data-theme="dark"] .task-row .text-gray-400 { +.dark .task-row .text-gray-400 { color: rgba(255, 255, 255, 0.45) !important; } -[data-theme="dark"] .task-row .line-through { +.dark .task-row .line-through { color: rgba(255, 255, 255, 0.45); } -[data-theme="dark"] .task-subtasks { +.dark .ant-card { + background-color: #1f1f1f; + border-color: #303030; +} + +.dark .ant-card-head { + background-color: #262626; + border-bottom-color: #303030; +} + +.dark .ant-card-body { + background-color: #1f1f1f; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-btn { + background-color: #262626; + border-color: #404040; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-btn:hover { + background-color: #2f2f2f; + border-color: #505050; +} + +.dark .ant-btn-primary { + background-color: #1890ff; + border-color: #1890ff; +} + +.dark .ant-btn-primary:hover { + background-color: #40a9ff; + border-color: #40a9ff; +} + +.dark .ant-dropdown-menu { + background-color: #1f1f1f; + border-color: #303030; +} + +.dark .ant-dropdown-menu-item { + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-dropdown-menu-item:hover { + background-color: #262626; +} + +.dark .ant-select-selector { + background-color: #262626 !important; + border-color: #404040 !important; + color: rgba(255, 255, 255, 0.85) !important; +} + +.dark .ant-select-arrow { + color: rgba(255, 255, 255, 0.45); +} + +.dark .ant-checkbox-wrapper { + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-checkbox-inner { + background-color: #262626; + border-color: #404040; +} + +.dark .ant-checkbox-checked .ant-checkbox-inner { + background-color: #1890ff; + border-color: #1890ff; +} + +.dark .ant-tag { + background-color: #262626; + border-color: #404040; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-avatar { + background-color: #404040; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-tooltip-inner { + background-color: #1f1f1f; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-tooltip-arrow-content { + background-color: #1f1f1f; +} + +.dark .ant-popover-inner { + background-color: #1f1f1f; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-popover-arrow-content { + background-color: #1f1f1f; +} + +.dark .task-subtasks { border-left-color: #303030; } -[data-theme="dark"] .task-subtasks .task-row { +.dark .task-subtasks .task-row { background-color: #141414; } -[data-theme="dark"] .task-subtasks .task-row:hover { +.dark .task-subtasks .task-row:hover { + background-color: #1a1a1a; +} + +.dark .task-groups-container::-webkit-scrollbar-track { + background-color: #262626; +} + +.dark .task-groups-container::-webkit-scrollbar-thumb { + background-color: #404040; +} + +.dark .task-groups-container::-webkit-scrollbar-thumb:hover { + background-color: #505050; +} + +.dark .ant-spin-dot-item { + background-color: #1890ff; +} + +.dark .ant-empty { + color: rgba(255, 255, 255, 0.45); +} + +.dark .ant-empty-description { + color: rgba(255, 255, 255, 0.45); +} + +.dark .task-row:focus-within { + outline-color: #1890ff; +} + +.dark .drag-handle:focus { + outline-color: #1890ff; +} + +.dark .border-gray-100 { + border-color: #262626 !important; +} + +.dark .border-gray-200 { + border-color: #303030 !important; +} + +.dark .border-gray-300 { + border-color: #404040 !important; +} + +.dark .bg-gray-50 { + background-color: #141414 !important; +} + +.dark .bg-gray-100 { + background-color: #1a1a1a !important; +} + +.dark .bg-white { background-color: #1f1f1f !important; } -[data-theme="dark"] .text-red-500 { +.dark .text-red-500 { color: #ff7875 !important; } -[data-theme="dark"] .text-orange-500 { +.dark .text-orange-500 { color: #ffa940 !important; +} + +.dark .task-group .group-header .bg-gray-200 { + background-color: #262626 !important; +} + +/* System preference fallback */ +@media (prefers-color-scheme: dark) { + .task-list-board:not(.light) { + color: rgba(255, 255, 255, 0.85); + } + + .task-group:not(.light) { + background-color: #1f1f1f; + } + + .task-row:not(.light) { + background-color: #1f1f1f; + color: rgba(255, 255, 255, 0.85); + border-color: #303030; + } + + .task-row:not(.light):hover { + background-color: #262626 !important; + border-left-color: #595959; + } } \ 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 f2fd5f66..e40279b9 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -70,6 +70,8 @@ export interface TaskManagementState { ids: string[]; loading: boolean; error: string | null; + groups: TaskGroup[]; // Pre-processed groups from V3 API + grouping: string | null; // Current grouping from V3 API } export interface TaskGroupsState {