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.
This commit is contained in:
@@ -967,4 +967,212 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
log_error(`Error updating task weight: ${error}`);
|
log_error(`Error updating task weight: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getTasksV3(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
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<string, string> = {
|
||||||
|
"0": "low",
|
||||||
|
"1": "medium",
|
||||||
|
"2": "high"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create status category mapping based on actual status names from database
|
||||||
|
const statusCategoryMap: Record<string, string> = {};
|
||||||
|
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<string, any> = {};
|
||||||
|
|
||||||
|
// 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<string, Record<string, string>> = {
|
||||||
|
[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<IWorkLenzResponse> {
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ tasksApiRouter.get("/list/columns/:id", idParamValidator, safeControllerFunction
|
|||||||
tasksApiRouter.put("/list/columns/:id", idParamValidator, safeControllerFunction(TaskListColumnsController.toggleColumn));
|
tasksApiRouter.put("/list/columns/:id", idParamValidator, safeControllerFunction(TaskListColumnsController.toggleColumn));
|
||||||
|
|
||||||
tasksApiRouter.get("/list/v2/:id", idParamValidator, safeControllerFunction(getList));
|
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.get("/assignees/:id", idParamValidator, safeControllerFunction(TasksController.getProjectTaskAssignees));
|
||||||
|
|
||||||
tasksApiRouter.put("/bulk/status", mapTasksToBulkUpdate, bulkTasksStatusValidator, safeControllerFunction(TasksController.bulkChangeStatus));
|
tasksApiRouter.put("/bulk/status", mapTasksToBulkUpdate, bulkTasksStatusValidator, safeControllerFunction(TasksController.bulkChangeStatus));
|
||||||
|
|||||||
11
worklenz-frontend/package-lock.json
generated
11
worklenz-frontend/package-lock.json
generated
@@ -66,6 +66,7 @@
|
|||||||
"@types/node": "^20.8.4",
|
"@types/node": "^20.8.4",
|
||||||
"@types/react": "19.0.0",
|
"@types/react": "19.0.0",
|
||||||
"@types/react-dom": "19.0.0",
|
"@types/react-dom": "19.0.0",
|
||||||
|
"@types/react-window": "^1.8.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.5.2",
|
"postcss": "^8.5.2",
|
||||||
@@ -2635,6 +2636,16 @@
|
|||||||
"@types/react": "*"
|
"@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": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
|||||||
@@ -70,6 +70,7 @@
|
|||||||
"@types/node": "^20.8.4",
|
"@types/node": "^20.8.4",
|
||||||
"@types/react": "19.0.0",
|
"@types/react": "19.0.0",
|
||||||
"@types/react-dom": "19.0.0",
|
"@types/react-dom": "19.0.0",
|
||||||
|
"@types/react-window": "^1.8.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.5.2",
|
"postcss": "^8.5.2",
|
||||||
|
|||||||
@@ -30,6 +30,22 @@ export interface ITaskListConfigV2 {
|
|||||||
isSubtasksInclude: boolean;
|
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 = {
|
export const tasksApiService = {
|
||||||
getTaskList: async (config: ITaskListConfigV2): Promise<IServerResponse<ITaskListGroup[]>> => {
|
getTaskList: async (config: ITaskListConfigV2): Promise<IServerResponse<ITaskListGroup[]>> => {
|
||||||
const q = toQueryString(config);
|
const q = toQueryString(config);
|
||||||
@@ -119,4 +135,15 @@ export const tasksApiService = {
|
|||||||
const response = await apiClient.get(`${rootUrl}/dependency-status${q}`);
|
const response = await apiClient.get(`${rootUrl}/dependency-status${q}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getTaskListV3: async (config: ITaskListConfigV2): Promise<IServerResponse<ITaskListV3Response>> => {
|
||||||
|
const q = toQueryString(config);
|
||||||
|
const response = await apiClient.get(`${rootUrl}/list/v3/${config.id}${q}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshTaskProgress: async (projectId: string): Promise<IServerResponse<{ message: string }>> => {
|
||||||
|
const response = await apiClient.post(`${rootUrl}/refresh-progress/${projectId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ const GROUP_COLORS = {
|
|||||||
done: '#52c41a',
|
done: '#52c41a',
|
||||||
},
|
},
|
||||||
priority: {
|
priority: {
|
||||||
critical: '#ff4d4f',
|
|
||||||
high: '#fa8c16',
|
high: '#fa8c16',
|
||||||
medium: '#faad14',
|
medium: '#faad14',
|
||||||
low: '#52c41a',
|
low: '#52c41a',
|
||||||
@@ -63,6 +62,9 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
|||||||
// Get all tasks from the store
|
// Get all tasks from the store
|
||||||
const allTasks = useSelector(taskManagementSelectors.selectAll);
|
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
|
// Get tasks for this group using memoization for performance
|
||||||
const groupTasks = useMemo(() => {
|
const groupTasks = useMemo(() => {
|
||||||
return group.taskIds
|
return group.taskIds
|
||||||
@@ -112,8 +114,10 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
|||||||
|
|
||||||
// Memoized style object
|
// Memoized style object
|
||||||
const containerStyle = useMemo(() => ({
|
const containerStyle = useMemo(() => ({
|
||||||
backgroundColor: isOver ? '#f0f8ff' : undefined,
|
backgroundColor: isOver
|
||||||
}), [isOver]);
|
? (isDarkMode ? '#1a2332' : '#f0f8ff')
|
||||||
|
: undefined,
|
||||||
|
}), [isOver, isDarkMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -23,7 +23,10 @@ import {
|
|||||||
moveTaskToGroup,
|
moveTaskToGroup,
|
||||||
optimisticTaskMove,
|
optimisticTaskMove,
|
||||||
setLoading,
|
setLoading,
|
||||||
fetchTasks
|
fetchTasks,
|
||||||
|
fetchTasksV3,
|
||||||
|
selectTaskGroupsV3,
|
||||||
|
selectCurrentGroupingV3
|
||||||
} from '@/features/task-management/task-management.slice';
|
} from '@/features/task-management/task-management.slice';
|
||||||
import {
|
import {
|
||||||
selectTaskGroups,
|
selectTaskGroups,
|
||||||
@@ -37,9 +40,9 @@ import {
|
|||||||
} from '@/features/task-management/selection.slice';
|
} from '@/features/task-management/selection.slice';
|
||||||
import { Task } from '@/types/task-management.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
import TaskGroup from './task-group';
|
|
||||||
import TaskRow from './task-row';
|
import TaskRow from './task-row';
|
||||||
import BulkActionBar from './bulk-action-bar';
|
import BulkActionBar from './bulk-action-bar';
|
||||||
|
import VirtualizedTaskList from './virtualized-task-list';
|
||||||
import { AppDispatch } from '@/app/store';
|
import { AppDispatch } from '@/app/store';
|
||||||
|
|
||||||
// Import the TaskListFilters component
|
// Import the TaskListFilters component
|
||||||
@@ -85,18 +88,22 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
|
|
||||||
// Refs for performance optimization
|
// Refs for performance optimization
|
||||||
const dragOverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const dragOverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Enable real-time socket updates for task changes
|
// Enable real-time socket updates for task changes
|
||||||
useTaskSocketHandlers();
|
useTaskSocketHandlers();
|
||||||
|
|
||||||
// Redux selectors using new task management slices
|
// Redux selectors using V3 API (pre-processed data, minimal loops)
|
||||||
const tasks = useSelector(taskManagementSelectors.selectAll);
|
const tasks = useSelector(taskManagementSelectors.selectAll);
|
||||||
const taskGroups = useSelector(selectTaskGroups);
|
const taskGroups = useSelector(selectTaskGroupsV3); // Pre-processed groups from backend
|
||||||
const currentGrouping = useSelector(selectCurrentGrouping);
|
const currentGrouping = useSelector(selectCurrentGroupingV3); // Current grouping from backend
|
||||||
const selectedTaskIds = useSelector(selectSelectedTaskIds);
|
const selectedTaskIds = useSelector(selectSelectedTaskIds);
|
||||||
const loading = useSelector((state: RootState) => state.taskManagement.loading);
|
const loading = useSelector((state: RootState) => state.taskManagement.loading);
|
||||||
const error = useSelector((state: RootState) => state.taskManagement.error);
|
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
|
// Drag and Drop sensors - optimized for better performance
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
@@ -112,8 +119,8 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
// Fetch task groups when component mounts or dependencies change
|
// Fetch task groups when component mounts or dependencies change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
// Fetch real tasks from API
|
// Fetch real tasks from V3 API (minimal processing needed)
|
||||||
dispatch(fetchTasks(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
}
|
}
|
||||||
}, [dispatch, projectId, currentGrouping]);
|
}, [dispatch, projectId, currentGrouping]);
|
||||||
|
|
||||||
@@ -123,7 +130,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const hasSelection = selectedTaskIds.length > 0;
|
const hasSelection = selectedTaskIds.length > 0;
|
||||||
|
|
||||||
// Memoized handlers for better performance
|
// Memoized handlers for better performance
|
||||||
const handleGroupingChange = useCallback((newGroupBy: typeof currentGrouping) => {
|
const handleGroupingChange = useCallback((newGroupBy: 'status' | 'priority' | 'phase') => {
|
||||||
dispatch(setCurrentGrouping(newGroupBy));
|
dispatch(setCurrentGrouping(newGroupBy));
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
@@ -308,7 +315,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
task={dragState.activeTask}
|
task={dragState.activeTask}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
groupId={dragState.activeGroupId}
|
groupId={dragState.activeGroupId}
|
||||||
currentGrouping={currentGrouping}
|
currentGrouping={(currentGrouping as 'status' | 'priority' | 'phase') || 'status'}
|
||||||
isSelected={false}
|
isSelected={false}
|
||||||
isDragOverlay
|
isDragOverlay
|
||||||
/>
|
/>
|
||||||
@@ -336,7 +343,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`task-list-board ${className}`}>
|
<div className={`task-list-board ${className}`} ref={containerRef}>
|
||||||
<DndContext
|
<DndContext
|
||||||
sensors={sensors}
|
sensors={sensors}
|
||||||
collisionDetection={closestCorners}
|
collisionDetection={closestCorners}
|
||||||
@@ -366,7 +373,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Task Groups Container */}
|
{/* Virtualized Task Groups Container */}
|
||||||
<div className="task-groups-container">
|
<div className="task-groups-container">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Card>
|
<Card>
|
||||||
@@ -382,18 +389,31 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="task-groups">
|
<div className="virtualized-task-groups">
|
||||||
{taskGroups.map((group) => (
|
{taskGroups.map((group, index) => {
|
||||||
<TaskGroup
|
// Calculate dynamic height for each group
|
||||||
key={group.id}
|
const groupTasks = group.taskIds.length;
|
||||||
group={group}
|
const baseHeight = 120; // Header + column headers + add task row
|
||||||
projectId={projectId}
|
const taskRowsHeight = groupTasks * 40; // 40px per task row
|
||||||
currentGrouping={currentGrouping}
|
const minGroupHeight = 300; // Minimum height for better visual appearance
|
||||||
selectedTaskIds={selectedTaskIds}
|
const maxGroupHeight = 600; // Increased maximum height per group
|
||||||
onSelectTask={handleSelectTask}
|
const calculatedHeight = baseHeight + taskRowsHeight;
|
||||||
onToggleSubtasks={handleToggleSubtasks}
|
const groupHeight = Math.max(minGroupHeight, Math.min(calculatedHeight, maxGroupHeight));
|
||||||
/>
|
|
||||||
))}
|
return (
|
||||||
|
<VirtualizedTaskList
|
||||||
|
key={group.id}
|
||||||
|
group={group}
|
||||||
|
projectId={projectId}
|
||||||
|
currentGrouping={(currentGrouping as 'status' | 'priority' | 'phase') || 'status'}
|
||||||
|
selectedTaskIds={selectedTaskIds}
|
||||||
|
onSelectTask={handleSelectTask}
|
||||||
|
onToggleSubtasks={handleToggleSubtasks}
|
||||||
|
height={groupHeight}
|
||||||
|
width={1200}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -422,13 +442,150 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
will-change: scroll-position;
|
will-change: scroll-position;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-groups {
|
.virtualized-task-groups {
|
||||||
min-width: fit-content;
|
min-width: fit-content;
|
||||||
position: relative;
|
position: relative;
|
||||||
/* GPU acceleration for drag operations */
|
/* GPU acceleration for drag operations */
|
||||||
transform: translateZ(0);
|
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 */
|
/* Optimized drag overlay styles */
|
||||||
[data-dnd-overlay] {
|
[data-dnd-overlay] {
|
||||||
/* GPU acceleration for smooth dragging */
|
/* GPU acceleration for smooth dragging */
|
||||||
@@ -503,7 +660,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Performance optimizations */
|
/* Performance optimizations */
|
||||||
.task-group {
|
.virtualized-task-group {
|
||||||
contain: layout style paint;
|
contain: layout style paint;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -515,6 +672,15 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
.task-table-cell {
|
.task-table-cell {
|
||||||
contain: layout;
|
contain: layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* React Window specific optimizations */
|
||||||
|
.react-window-list {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-window-list-item {
|
||||||
|
contain: layout style;
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
|
import { Input, Typography } from 'antd';
|
||||||
|
import type { InputRef } from 'antd';
|
||||||
import {
|
import {
|
||||||
HolderOutlined,
|
HolderOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
@@ -11,6 +13,8 @@ import {
|
|||||||
import { Task } from '@/types/task-management.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tag, Tooltip } from '@/components';
|
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 {
|
interface TaskRowProps {
|
||||||
task: Task;
|
task: Task;
|
||||||
@@ -49,6 +53,14 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
onSelect,
|
onSelect,
|
||||||
onToggleSubtasks,
|
onToggleSubtasks,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { socket, connected } = useSocket();
|
||||||
|
|
||||||
|
// Edit task name state
|
||||||
|
const [editTaskName, setEditTaskName] = useState(false);
|
||||||
|
const [taskName, setTaskName] = useState(task.title || '');
|
||||||
|
const inputRef = useRef<InputRef>(null);
|
||||||
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
@@ -69,6 +81,40 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
// Get theme from Redux store
|
// Get theme from Redux store
|
||||||
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
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
|
// Memoize style calculations - simplified
|
||||||
const style = useMemo(() => ({
|
const style = useMemo(() => ({
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
@@ -97,12 +143,11 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
? 'border-gray-700 bg-gray-900 hover:bg-gray-800'
|
? 'border-gray-700 bg-gray-900 hover:bg-gray-800'
|
||||||
: 'border-gray-200 bg-white hover:bg-gray-50';
|
: 'border-gray-200 bg-white hover:bg-gray-50';
|
||||||
const selectedClasses = isSelected
|
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
|
const overlayClasses = isDragOverlay
|
||||||
? `rounded shadow-lg border-2 ${isDarkMode ? 'bg-gray-900 border-gray-600 shadow-2xl' : 'bg-white border-gray-300 shadow-2xl'}`
|
? `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}`;
|
return `${baseClasses} ${themeClasses} ${selectedClasses} ${overlayClasses}`;
|
||||||
}, [isDarkMode, isSelected, isDragOverlay]);
|
}, [isDarkMode, isSelected, isDragOverlay]);
|
||||||
|
|
||||||
@@ -112,8 +157,8 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const taskNameClasses = useMemo(() => {
|
const taskNameClasses = useMemo(() => {
|
||||||
const baseClasses = 'text-sm font-medium flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300';
|
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' : 'text-gray-900';
|
const themeClasses = isDarkMode ? 'text-gray-100 hover:text-blue-400' : 'text-gray-900 hover:text-blue-600';
|
||||||
const completedClasses = task.progress === 100
|
const completedClasses = task.progress === 100
|
||||||
? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`
|
? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`
|
||||||
: '';
|
: '';
|
||||||
@@ -207,12 +252,36 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task Name */}
|
{/* Task Name */}
|
||||||
<div className="w-[475px] flex items-center px-2">
|
<div className={`w-[475px] flex items-center px-2 ${editTaskName ? (isDarkMode ? 'bg-blue-900/10 border border-blue-500' : 'bg-blue-50/20 border border-blue-500') : ''}`}>
|
||||||
<div className="flex-1 min-w-0 flex flex-col justify-center h-full overflow-hidden">
|
<div className="flex-1 min-w-0 flex flex-col justify-center h-full overflow-hidden">
|
||||||
<div className="flex items-center gap-2 h-5 overflow-hidden">
|
<div className="flex items-center gap-2 h-5 overflow-hidden">
|
||||||
<span className={taskNameClasses}>
|
<div ref={wrapperRef} className="flex-1 min-w-0">
|
||||||
{task.title}
|
{!editTaskName ? (
|
||||||
</span>
|
<Typography.Text
|
||||||
|
ellipsis={{ tooltip: task.title }}
|
||||||
|
onClick={() => setEditTaskName(true)}
|
||||||
|
className={taskNameClasses}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
>
|
||||||
|
{task.title}
|
||||||
|
</Typography.Text>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
ref={inputRef}
|
||||||
|
variant="borderless"
|
||||||
|
value={taskName}
|
||||||
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => 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,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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<VirtualizedTaskGroupProps> = 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 (
|
||||||
|
<div style={style}>
|
||||||
|
<div className="task-group-header">
|
||||||
|
<div className="task-group-header-row">
|
||||||
|
<div className="task-group-header-content">
|
||||||
|
<span className="task-group-header-text">
|
||||||
|
{group.title} ({groupTasks.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column headers row
|
||||||
|
if (index === 1) {
|
||||||
|
return (
|
||||||
|
<div style={style}>
|
||||||
|
<div className="task-group-column-headers">
|
||||||
|
<div className="task-group-column-headers-row">
|
||||||
|
<div className="task-table-fixed-columns">
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '40px' }}></div>
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '40px' }}></div>
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '80px' }}>
|
||||||
|
<span className="column-header-text">Key</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '475px' }}>
|
||||||
|
<span className="column-header-text">Task</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="task-table-scrollable-columns">
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '90px' }}>
|
||||||
|
<span className="column-header-text">Progress</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '150px' }}>
|
||||||
|
<span className="column-header-text">Members</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '200px' }}>
|
||||||
|
<span className="column-header-text">Labels</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
||||||
|
<span className="column-header-text">Status</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
||||||
|
<span className="column-header-text">Priority</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '120px' }}>
|
||||||
|
<span className="column-header-text">Time Tracking</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task rows
|
||||||
|
const taskIndex = index - 2;
|
||||||
|
if (taskIndex >= 0 && taskIndex < groupTasks.length) {
|
||||||
|
const task = groupTasks[taskIndex];
|
||||||
|
return (
|
||||||
|
<div style={style}>
|
||||||
|
<TaskRow
|
||||||
|
task={task}
|
||||||
|
projectId={projectId}
|
||||||
|
groupId={group.id}
|
||||||
|
currentGrouping={currentGrouping}
|
||||||
|
isSelected={selectedTaskIds.includes(task.id)}
|
||||||
|
index={taskIndex}
|
||||||
|
onSelect={onSelectTask}
|
||||||
|
onToggleSubtasks={onToggleSubtasks}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add task row (last row)
|
||||||
|
if (taskIndex === groupTasks.length) {
|
||||||
|
return (
|
||||||
|
<div style={style}>
|
||||||
|
<div className="task-group-add-task">
|
||||||
|
<div className="task-table-fixed-columns">
|
||||||
|
<div style={{ width: '380px', padding: '8px 12px' }}>
|
||||||
|
<span className="text-gray-500">+ Add task</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="virtualized-task-group">
|
||||||
|
<SortableContext items={group.taskIds} strategy={verticalListSortingStrategy}>
|
||||||
|
<List
|
||||||
|
height={Math.min(height, totalHeight)}
|
||||||
|
width={width}
|
||||||
|
itemCount={groupTasks.length + 3} // +3 for header, column headers, and add task row
|
||||||
|
itemSize={TASK_ROW_HEIGHT}
|
||||||
|
overscanCount={5} // Render 5 extra items for smooth scrolling
|
||||||
|
>
|
||||||
|
{Row}
|
||||||
|
</List>
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default VirtualizedTaskGroup;
|
||||||
@@ -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<VirtualizedTaskListProps> = 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 (
|
||||||
|
<div style={style}>
|
||||||
|
<div className="task-group-header">
|
||||||
|
<div className="task-group-header-row">
|
||||||
|
<div
|
||||||
|
className="task-group-header-content"
|
||||||
|
style={{
|
||||||
|
backgroundColor: group.color || '#f0f0f0',
|
||||||
|
borderLeft: `4px solid ${group.color || '#f0f0f0'}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="task-group-header-text">
|
||||||
|
{group.title} ({groupTasks.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column headers row
|
||||||
|
if (index === 1) {
|
||||||
|
return (
|
||||||
|
<div style={style}>
|
||||||
|
<div
|
||||||
|
className="task-group-column-headers"
|
||||||
|
style={{ borderLeft: `4px solid ${group.color || '#f0f0f0'}` }}
|
||||||
|
>
|
||||||
|
<div className="task-group-column-headers-row">
|
||||||
|
<div className="task-table-fixed-columns">
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '40px' }}></div>
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '40px' }}></div>
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '80px' }}>
|
||||||
|
<span className="column-header-text">Key</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '475px' }}>
|
||||||
|
<span className="column-header-text">Task</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="task-table-scrollable-columns">
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '90px' }}>
|
||||||
|
<span className="column-header-text">Progress</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '150px' }}>
|
||||||
|
<span className="column-header-text">Members</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '200px' }}>
|
||||||
|
<span className="column-header-text">Labels</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
||||||
|
<span className="column-header-text">Status</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
||||||
|
<span className="column-header-text">Priority</span>
|
||||||
|
</div>
|
||||||
|
<div className="task-table-cell task-table-header-cell" style={{ width: '120px' }}>
|
||||||
|
<span className="column-header-text">Time Tracking</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task rows
|
||||||
|
const taskIndex = index - 2;
|
||||||
|
if (taskIndex >= 0 && taskIndex < groupTasks.length) {
|
||||||
|
const task = groupTasks[taskIndex];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="task-row-container"
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
'--group-color': group.color || '#f0f0f0'
|
||||||
|
} as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<TaskRow
|
||||||
|
task={task}
|
||||||
|
projectId={projectId}
|
||||||
|
groupId={group.id}
|
||||||
|
currentGrouping={currentGrouping}
|
||||||
|
isSelected={selectedTaskIds.includes(task.id)}
|
||||||
|
index={taskIndex}
|
||||||
|
onSelect={onSelectTask}
|
||||||
|
onToggleSubtasks={onToggleSubtasks}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="virtualized-task-list" style={{ height: height }}>
|
||||||
|
<SortableContext items={group.taskIds} strategy={verticalListSortingStrategy}>
|
||||||
|
<List
|
||||||
|
height={listHeight}
|
||||||
|
width={width}
|
||||||
|
itemCount={getItemCount()}
|
||||||
|
itemSize={TASK_ROW_HEIGHT}
|
||||||
|
overscanCount={15} // Render 15 extra items for smooth scrolling
|
||||||
|
className="react-window-list"
|
||||||
|
>
|
||||||
|
{Row}
|
||||||
|
</List>
|
||||||
|
</SortableContext>
|
||||||
|
|
||||||
|
{/* Add Task Row - Always show at the bottom */}
|
||||||
|
<div
|
||||||
|
className="task-group-add-task"
|
||||||
|
style={{ borderLeft: `4px solid ${group.color || '#f0f0f0'}` }}
|
||||||
|
>
|
||||||
|
<AddTaskListRow groupId={group.id} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>{`
|
||||||
|
.virtualized-task-list {
|
||||||
|
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;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtualized-task-list:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-window-list {
|
||||||
|
outline: none;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-window-list-item {
|
||||||
|
contain: layout style;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task row container styles */
|
||||||
|
.task-row-container {
|
||||||
|
position: relative;
|
||||||
|
background: var(--task-bg-primary, white);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row-container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background-color: var(--group-color, #f0f0f0);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure no gaps between list items */
|
||||||
|
.react-window-list > div {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task group header styles */
|
||||||
|
.task-group-header {
|
||||||
|
background: var(--task-bg-primary, white);
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-group-header-row {
|
||||||
|
display: inline-flex;
|
||||||
|
height: auto;
|
||||||
|
max-height: none;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-group-header-content {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 40px;
|
||||||
|
z-index: 19;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-group-column-headers-row {
|
||||||
|
display: flex;
|
||||||
|
height: 40px;
|
||||||
|
max-height: 40px;
|
||||||
|
overflow: visible;
|
||||||
|
position: relative;
|
||||||
|
min-width: 1200px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Performance optimizations */
|
||||||
|
.virtualized-task-list {
|
||||||
|
contain: layout style paint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support */
|
||||||
|
:root {
|
||||||
|
--task-bg-primary: #ffffff;
|
||||||
|
--task-bg-secondary: #f5f5f5;
|
||||||
|
--task-bg-tertiary: #f8f9fa;
|
||||||
|
--task-border-primary: #e8e8e8;
|
||||||
|
--task-border-secondary: #f0f0f0;
|
||||||
|
--task-border-tertiary: #d9d9d9;
|
||||||
|
--task-text-primary: #262626;
|
||||||
|
--task-text-secondary: #595959;
|
||||||
|
--task-text-tertiary: #8c8c8c;
|
||||||
|
--task-shadow: rgba(0, 0, 0, 0.1);
|
||||||
|
--task-hover-bg: #fafafa;
|
||||||
|
--task-selected-bg: #e6f7ff;
|
||||||
|
--task-selected-border: #1890ff;
|
||||||
|
--task-drag-over-bg: #f0f8ff;
|
||||||
|
--task-drag-over-border: #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .virtualized-task-list,
|
||||||
|
[data-theme="dark"] .virtualized-task-list {
|
||||||
|
--task-bg-primary: #1f1f1f;
|
||||||
|
--task-bg-secondary: #141414;
|
||||||
|
--task-bg-tertiary: #262626;
|
||||||
|
--task-border-primary: #303030;
|
||||||
|
--task-border-secondary: #404040;
|
||||||
|
--task-border-tertiary: #505050;
|
||||||
|
--task-text-primary: #ffffff;
|
||||||
|
--task-text-secondary: #d9d9d9;
|
||||||
|
--task-text-tertiary: #8c8c8c;
|
||||||
|
--task-shadow: rgba(0, 0, 0, 0.3);
|
||||||
|
--task-hover-bg: #2a2a2a;
|
||||||
|
--task-selected-bg: #1a2332;
|
||||||
|
--task-selected-border: #1890ff;
|
||||||
|
--task-drag-over-bg: #1a2332;
|
||||||
|
--task-drag-over-border: #40a9ff;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default VirtualizedTaskList;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { createSlice, createEntityAdapter, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
|
import { createSlice, createEntityAdapter, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { Task, TaskManagementState } from '@/types/task-management.types';
|
import { Task, TaskManagementState } from '@/types/task-management.types';
|
||||||
import { RootState } from '@/app/store';
|
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';
|
import logger from '@/utils/errorLogger';
|
||||||
|
|
||||||
// Entity adapter for normalized state
|
// Entity adapter for normalized state
|
||||||
@@ -14,6 +14,8 @@ const initialState: TaskManagementState = {
|
|||||||
ids: [],
|
ids: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
groups: [],
|
||||||
|
grouping: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Async thunk to fetch tasks from API
|
// Async thunk to fetch tasks from API
|
||||||
@@ -59,6 +61,31 @@ export const fetchTasks = createAsyncThunk(
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Create a mapping from status IDs to group names
|
||||||
|
const statusIdToNameMap: Record<string, string> = {};
|
||||||
|
const priorityIdToNameMap: Record<string, string> = {};
|
||||||
|
|
||||||
|
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
|
// Transform the API response to our Task type
|
||||||
const tasks: Task[] = response.body.flatMap((group: any) =>
|
const tasks: Task[] = response.body.flatMap((group: any) =>
|
||||||
group.tasks.map((task: any) => ({
|
group.tasks.map((task: any) => ({
|
||||||
@@ -66,8 +93,8 @@ export const fetchTasks = createAsyncThunk(
|
|||||||
task_key: task.task_key || '',
|
task_key: task.task_key || '',
|
||||||
title: task.name || '',
|
title: task.name || '',
|
||||||
description: task.description || '',
|
description: task.description || '',
|
||||||
status: task.status_name?.toLowerCase() || 'todo',
|
status: statusIdToNameMap[task.status] || 'todo',
|
||||||
priority: task.priority_name?.toLowerCase() || 'medium',
|
priority: priorityIdToNameMap[task.priority] || 'medium',
|
||||||
phase: task.phase_name || 'Development',
|
phase: task.phase_name || 'Development',
|
||||||
progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0,
|
progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0,
|
||||||
assignees: task.assignees?.map((a: any) => a.team_member_id) || [],
|
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({
|
const taskManagementSlice = createSlice({
|
||||||
name: 'taskManagement',
|
name: 'taskManagement',
|
||||||
initialState: tasksAdapter.getInitialState(initialState),
|
initialState: tasksAdapter.getInitialState(initialState),
|
||||||
@@ -234,6 +320,33 @@ const taskManagementSlice = createSlice({
|
|||||||
.addCase(fetchTasks.rejected, (state, action) => {
|
.addCase(fetchTasks.rejected, (state, action) => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.error = action.payload as string || 'Failed to fetch tasks';
|
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';
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -271,3 +384,7 @@ export const selectTasksByPhase = (state: RootState, phase: string) =>
|
|||||||
|
|
||||||
export const selectTasksLoading = (state: RootState) => state.taskManagement.loading;
|
export const selectTasksLoading = (state: RootState) => state.taskManagement.loading;
|
||||||
export const selectTasksError = (state: RootState) => state.taskManagement.error;
|
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;
|
||||||
@@ -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("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/customOverrides.css");
|
||||||
|
@import url("./styles/task-management.css");
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
|
|||||||
@@ -1,54 +1,49 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Flex } from 'antd';
|
import { Flex } from 'antd';
|
||||||
import TaskListFilters from './taskListFilters/TaskListFilters';
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||||
import { fetchTaskGroups } from '@/features/tasks/tasks.slice';
|
import { fetchTasksV3 } from '@/features/task-management/task-management.slice';
|
||||||
import { ITaskListConfigV2 } from '@/types/tasks/taskList.types';
|
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||||
import TanStackTable from '../task-list/task-list-custom';
|
import TaskListBoard from '@/components/task-management/task-list-board';
|
||||||
import TaskListCustom from '../task-list/task-list-custom';
|
|
||||||
import TaskListTableWrapper from '../task-list/task-list-table-wrapper/task-list-table-wrapper';
|
|
||||||
|
|
||||||
const ProjectViewTaskList = () => {
|
const ProjectViewTaskList = () => {
|
||||||
// sample data from task reducer
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { taskGroups, loadingGroups } = useAppSelector(state => state.taskReducer);
|
|
||||||
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
|
|
||||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||||
|
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
const config: ITaskListConfigV2 = {
|
// Use the optimized V3 API for faster loading
|
||||||
id: projectId,
|
dispatch(fetchTasksV3(projectId));
|
||||||
field: 'id',
|
|
||||||
order: 'desc',
|
|
||||||
search: '',
|
|
||||||
statuses: '',
|
|
||||||
members: '',
|
|
||||||
projects: '',
|
|
||||||
isSubtasksInclude: true,
|
|
||||||
};
|
|
||||||
dispatch(fetchTaskGroups(config));
|
|
||||||
}
|
}
|
||||||
if (!statusCategories.length) {
|
if (!statusCategories.length) {
|
||||||
dispatch(fetchStatusesCategories());
|
dispatch(fetchStatusesCategories());
|
||||||
}
|
}
|
||||||
}, [dispatch, projectId]);
|
}, [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 (
|
||||||
|
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||||
|
<div>No project selected</div>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||||
<TaskListFilters position="list" />
|
<TaskListBoard
|
||||||
|
projectId={projectId}
|
||||||
{taskGroups.map(group => (
|
className="task-list-board"
|
||||||
<TaskListTableWrapper
|
/>
|
||||||
key={group.id}
|
|
||||||
taskList={group}
|
|
||||||
name={group.name || ''}
|
|
||||||
color={group.color_code || ''}
|
|
||||||
groupId={group.id || ''}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -213,389 +213,283 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode support */
|
/* Dark mode support using class-based selectors */
|
||||||
[data-theme="dark"] .task-list-board {
|
.dark .task-list-board {
|
||||||
background-color: #141414;
|
background-color: #141414;
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
.dark .task-group {
|
||||||
.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 {
|
|
||||||
background-color: #1f1f1f;
|
background-color: #1f1f1f;
|
||||||
border-color: #303030;
|
border-color: #303030;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-group.drag-over {
|
.dark .task-group.drag-over {
|
||||||
border-color: #1890ff !important;
|
border-color: #1890ff !important;
|
||||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3);
|
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3);
|
||||||
background-color: rgba(24, 144, 255, 0.1);
|
background-color: rgba(24, 144, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-group .group-header {
|
.dark .task-group .group-header {
|
||||||
background: #262626;
|
background: #262626;
|
||||||
border-bottom-color: #303030;
|
border-bottom-color: #303030;
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-group .group-header:hover {
|
.dark .task-group .group-header:hover {
|
||||||
background: #2f2f2f;
|
background: #2f2f2f;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-row {
|
.dark .task-row {
|
||||||
background-color: #1f1f1f;
|
background-color: #1f1f1f;
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
border-color: #303030;
|
border-color: #303030;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-row:hover {
|
.dark .task-row:hover {
|
||||||
background-color: #262626 !important;
|
background-color: #262626 !important;
|
||||||
border-left-color: #595959;
|
border-left-color: #595959;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-row.selected {
|
.dark .task-row.selected {
|
||||||
background-color: rgba(24, 144, 255, 0.15) !important;
|
background-color: rgba(24, 144, 255, 0.15) !important;
|
||||||
border-left-color: #1890ff;
|
border-left-color: #1890ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-row .drag-handle {
|
.dark .task-row .drag-handle {
|
||||||
color: rgba(255, 255, 255, 0.45);
|
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);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .bulk-action-bar {
|
.dark .bulk-action-bar {
|
||||||
background: rgba(24, 144, 255, 0.15);
|
background: rgba(24, 144, 255, 0.15);
|
||||||
border-color: rgba(24, 144, 255, 0.3);
|
border-color: rgba(24, 144, 255, 0.3);
|
||||||
color: rgba(255, 255, 255, 0.85);
|
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);
|
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;
|
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;
|
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;
|
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);
|
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;
|
border-left-color: #303030;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-subtasks .task-row {
|
.dark .task-subtasks .task-row {
|
||||||
background-color: #141414;
|
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;
|
background-color: #1f1f1f !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .text-red-500 {
|
.dark .text-red-500 {
|
||||||
color: #ff7875 !important;
|
color: #ff7875 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .text-orange-500 {
|
.dark .text-orange-500 {
|
||||||
color: #ffa940 !important;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,8 @@ export interface TaskManagementState {
|
|||||||
ids: string[];
|
ids: string[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
groups: TaskGroup[]; // Pre-processed groups from V3 API
|
||||||
|
grouping: string | null; // Current grouping from V3 API
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskGroupsState {
|
export interface TaskGroupsState {
|
||||||
|
|||||||
Reference in New Issue
Block a user