diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 10c556d3..6c6d5e0e 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -967,4 +967,212 @@ export default class TasksControllerV2 extends TasksControllerBase { log_error(`Error updating task weight: ${error}`); } } + + @HandleExceptions() + public static async getTasksV3(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + const isSubTasks = !!req.query.parent_task; + const groupBy = (req.query.group || GroupBy.STATUS) as string; + const archived = req.query.archived === "true"; + + // Skip heavy progress calculation for initial load to improve performance + // Progress values are already calculated and stored in the database + // Only refresh if explicitly requested + if (req.query.refresh_progress === "true" && req.params.id) { + await this.refreshProjectTaskProgressValues(req.params.id); + } + + const q = TasksControllerV2.getQuery(req.user?.id as string, req.query); + const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null]; + + const result = await db.query(q, params); + const tasks = [...result.rows]; + + // Get groups metadata dynamically from database + const groups = await this.getGroups(groupBy, req.params.id); + + // Create priority value to name mapping + const priorityMap: Record = { + "0": "low", + "1": "medium", + "2": "high" + }; + + // Create status category mapping based on actual status names from database + const statusCategoryMap: Record = {}; + for (const group of groups) { + if (groupBy === GroupBy.STATUS && group.id) { + // Use the actual status name from database, convert to lowercase for consistency + statusCategoryMap[group.id] = group.name.toLowerCase().replace(/\s+/g, "_"); + } + } + + // Transform tasks with all necessary data preprocessing + const transformedTasks = tasks.map((task, index) => { + // Update task with calculated values (lightweight version) + TasksControllerV2.updateTaskViewModel(task); + task.index = index; + + // Convert time values + const convertTimeValue = (value: any): number => { + if (typeof value === "number") return value; + if (typeof value === "string") { + const parsed = parseFloat(value); + return isNaN(parsed) ? 0 : parsed; + } + if (value && typeof value === "object") { + if ("hours" in value || "minutes" in value) { + const hours = Number(value.hours || 0); + const minutes = Number(value.minutes || 0); + return hours + (minutes / 60); + } + } + return 0; + }; + + return { + id: task.id, + task_key: task.task_key || "", + title: task.name || "", + description: task.description || "", + // Use dynamic status mapping from database + status: statusCategoryMap[task.status] || task.status, + // Pre-processed priority using mapping + priority: priorityMap[task.priority_value?.toString()] || "medium", + // Use actual phase name from database + phase: task.phase_name || "Development", + progress: typeof task.complete_ratio === "number" ? task.complete_ratio : 0, + assignees: task.assignees?.map((a: any) => a.team_member_id) || [], + assignee_names: task.assignee_names || task.names || [], + labels: task.labels?.map((l: any) => ({ + id: l.id || l.label_id, + name: l.name, + color: l.color_code || "#1890ff", + end: l.end, + names: l.names + })) || [], + dueDate: task.end_date, + timeTracking: { + estimated: convertTimeValue(task.total_time), + logged: convertTimeValue(task.time_spent), + }, + customFields: {}, + createdAt: task.created_at || new Date().toISOString(), + updatedAt: task.updated_at || new Date().toISOString(), + order: typeof task.sort_order === "number" ? task.sort_order : 0, + // Additional metadata for frontend + originalStatusId: task.status, + originalPriorityId: task.priority, + statusColor: task.status_color, + priorityColor: task.priority_color, + }; + }); + + // Create groups based on dynamic data from database + const groupedResponse: Record = {}; + + // Initialize groups from database data + groups.forEach(group => { + const groupKey = groupBy === GroupBy.STATUS + ? group.name.toLowerCase().replace(/\s+/g, "_") + : groupBy === GroupBy.PRIORITY + ? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase() + : group.name.toLowerCase().replace(/\s+/g, "_"); + + groupedResponse[groupKey] = { + id: group.id, + title: group.name, + groupType: groupBy, + groupValue: groupKey, + collapsed: false, + tasks: [], + taskIds: [], + color: group.color_code || this.getDefaultGroupColor(groupBy, groupKey), + // Include additional metadata from database + category_id: group.category_id, + start_date: group.start_date, + end_date: group.end_date, + sort_index: (group as any).sort_index, + }; + }); + + // Distribute tasks into groups + transformedTasks.forEach(task => { + let groupKey: string; + if (groupBy === GroupBy.STATUS) { + groupKey = task.status; + } else if (groupBy === GroupBy.PRIORITY) { + groupKey = task.priority; + } else { + groupKey = task.phase.toLowerCase().replace(/\s+/g, "_"); + } + + if (groupedResponse[groupKey]) { + groupedResponse[groupKey].tasks.push(task); + groupedResponse[groupKey].taskIds.push(task.id); + } + }); + + // Sort tasks within each group by order + Object.values(groupedResponse).forEach((group: any) => { + group.tasks.sort((a: any, b: any) => a.order - b.order); + }); + + // Convert to array format expected by frontend, maintaining database order + const responseGroups = groups + .map(group => { + const groupKey = groupBy === GroupBy.STATUS + ? group.name.toLowerCase().replace(/\s+/g, "_") + : groupBy === GroupBy.PRIORITY + ? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase() + : group.name.toLowerCase().replace(/\s+/g, "_"); + + return groupedResponse[groupKey]; + }) + .filter(group => group && (group.tasks.length > 0 || req.query.include_empty === "true")); + + return res.status(200).send(new ServerResponse(true, { + groups: responseGroups, + allTasks: transformedTasks, + grouping: groupBy, + totalTasks: transformedTasks.length + })); + } + + private static getDefaultGroupColor(groupBy: string, groupValue: string): string { + const colorMaps: Record> = { + [GroupBy.STATUS]: { + todo: "#f0f0f0", + doing: "#1890ff", + done: "#52c41a", + }, + [GroupBy.PRIORITY]: { + critical: "#ff4d4f", + high: "#ff7a45", + medium: "#faad14", + low: "#52c41a", + }, + [GroupBy.PHASE]: { + planning: "#722ed1", + development: "#1890ff", + testing: "#faad14", + deployment: "#52c41a", + }, + }; + + return colorMaps[groupBy]?.[groupValue] || "#d9d9d9"; + } + + @HandleExceptions() + public static async refreshTaskProgress(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { + try { + if (req.params.id) { + await this.refreshProjectTaskProgressValues(req.params.id); + return res.status(200).send(new ServerResponse(true, { message: "Task progress refreshed successfully" })); + } + return res.status(400).send(new ServerResponse(false, "Project ID is required")); + } catch (error) { + log_error(`Error refreshing task progress: ${error}`); + return res.status(500).send(new ServerResponse(false, "Failed to refresh task progress")); + } + } } diff --git a/worklenz-backend/src/routes/apis/tasks-api-router.ts b/worklenz-backend/src/routes/apis/tasks-api-router.ts index bb6af547..905728ea 100644 --- a/worklenz-backend/src/routes/apis/tasks-api-router.ts +++ b/worklenz-backend/src/routes/apis/tasks-api-router.ts @@ -42,6 +42,8 @@ tasksApiRouter.get("/list/columns/:id", idParamValidator, safeControllerFunction tasksApiRouter.put("/list/columns/:id", idParamValidator, safeControllerFunction(TaskListColumnsController.toggleColumn)); tasksApiRouter.get("/list/v2/:id", idParamValidator, safeControllerFunction(getList)); +tasksApiRouter.get("/list/v3/:id", idParamValidator, safeControllerFunction(TasksControllerV2.getTasksV3)); +tasksApiRouter.post("/refresh-progress/:id", idParamValidator, safeControllerFunction(TasksControllerV2.refreshTaskProgress)); tasksApiRouter.get("/assignees/:id", idParamValidator, safeControllerFunction(TasksController.getProjectTaskAssignees)); tasksApiRouter.put("/bulk/status", mapTasksToBulkUpdate, bulkTasksStatusValidator, safeControllerFunction(TasksController.bulkChangeStatus)); diff --git a/worklenz-frontend/package-lock.json b/worklenz-frontend/package-lock.json index 470cbdba..9ef5d8fd 100644 --- a/worklenz-frontend/package-lock.json +++ b/worklenz-frontend/package-lock.json @@ -67,6 +67,7 @@ "@types/node": "^20.8.4", "@types/react": "19.0.0", "@types/react-dom": "19.0.0", + "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "postcss": "^8.5.2", @@ -2636,6 +2637,16 @@ "@types/react": "*" } }, + "node_modules/@types/react-window": { + "version": "1.8.8", + "resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz", + "integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react": "*" + } + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", diff --git a/worklenz-frontend/package.json b/worklenz-frontend/package.json index 873bf4eb..ac7bc185 100644 --- a/worklenz-frontend/package.json +++ b/worklenz-frontend/package.json @@ -71,6 +71,7 @@ "@types/node": "^20.8.4", "@types/react": "19.0.0", "@types/react-dom": "19.0.0", + "@types/react-window": "^1.8.8", "@vitejs/plugin-react": "^4.3.4", "autoprefixer": "^10.4.20", "postcss": "^8.5.2", diff --git a/worklenz-frontend/src/api/tasks/tasks.api.service.ts b/worklenz-frontend/src/api/tasks/tasks.api.service.ts index cd3d80dd..c8710a36 100644 --- a/worklenz-frontend/src/api/tasks/tasks.api.service.ts +++ b/worklenz-frontend/src/api/tasks/tasks.api.service.ts @@ -30,6 +30,22 @@ export interface ITaskListConfigV2 { isSubtasksInclude: boolean; } +export interface ITaskListV3Response { + groups: Array<{ + id: string; + title: string; + groupType: 'status' | 'priority' | 'phase'; + groupValue: string; + collapsed: boolean; + tasks: any[]; + taskIds: string[]; + color: string; + }>; + allTasks: any[]; + grouping: string; + totalTasks: number; +} + export const tasksApiService = { getTaskList: async (config: ITaskListConfigV2): Promise> => { const q = toQueryString(config); @@ -119,4 +135,15 @@ export const tasksApiService = { const response = await apiClient.get(`${rootUrl}/dependency-status${q}`); return response.data; }, + + getTaskListV3: async (config: ITaskListConfigV2): Promise> => { + const q = toQueryString(config); + const response = await apiClient.get(`${rootUrl}/list/v3/${config.id}${q}`); + return response.data; + }, + + refreshTaskProgress: async (projectId: string): Promise> => { + const response = await apiClient.post(`${rootUrl}/refresh-progress/${projectId}`); + return response.data; + }, }; diff --git a/worklenz-frontend/src/components/AssigneeSelector.tsx b/worklenz-frontend/src/components/AssigneeSelector.tsx new file mode 100644 index 00000000..02ac057c --- /dev/null +++ b/worklenz-frontend/src/components/AssigneeSelector.tsx @@ -0,0 +1,220 @@ +import React, { useState, useRef, useEffect, useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import { PlusOutlined, UserAddOutlined } from '@ant-design/icons'; +import { RootState } from '@/app/store'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { useAuthService } from '@/hooks/useAuth'; +import { Avatar, Button, Checkbox } from '@/components'; +import { sortTeamMembers } from '@/utils/sort-team-members'; + +interface AssigneeSelectorProps { + task: IProjectTask; + groupId?: string | null; + isDarkMode?: boolean; +} + +const AssigneeSelector: React.FC = ({ + task, + groupId = null, + isDarkMode = false +}) => { + const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [teamMembers, setTeamMembers] = useState({ data: [], total: 0 }); + const dropdownRef = useRef(null); + const searchInputRef = useRef(null); + + const { projectId } = useSelector((state: RootState) => state.projectReducer); + const members = useSelector((state: RootState) => state.teamMembersReducer.teamMembers); + const currentSession = useAuthService().getCurrentSession(); + const { socket } = useSocket(); + + const filteredMembers = useMemo(() => { + return teamMembers?.data?.filter(member => + member.name?.toLowerCase().includes(searchQuery.toLowerCase()) + ); + }, [teamMembers, searchQuery]); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + const handleDropdownToggle = () => { + if (!isOpen) { + // Prepare team members data when opening + const assignees = task?.assignees?.map(assignee => assignee.team_member_id); + const membersData = (members?.data || []).map(member => ({ + ...member, + selected: assignees?.includes(member.id), + })); + const sortedMembers = sortTeamMembers(membersData); + setTeamMembers({ data: sortedMembers }); + + // Focus search input after opening + setTimeout(() => { + searchInputRef.current?.focus(); + }, 0); + } + setIsOpen(!isOpen); + }; + + const handleMemberToggle = (memberId: string, checked: boolean) => { + if (!memberId || !projectId || !task?.id || !currentSession?.id) return; + + const body = { + team_member_id: memberId, + project_id: projectId, + task_id: task.id, + reporter_id: currentSession.id, + mode: checked ? 0 : 1, + parent_task: task.parent_task_id, + }; + + socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body)); + }; + + const checkMemberSelected = (memberId: string) => { + if (!memberId) return false; + const assignees = task?.assignees?.map(assignee => assignee.team_member_id); + return assignees?.includes(memberId) || false; + }; + + return ( +
+ + + {isOpen && ( +
+ {/* Header */} +
+ setSearchQuery(e.target.value)} + placeholder="Search members..." + className={` + w-full px-2 py-1 text-xs rounded border + ${isDarkMode + ? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500' + : 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500' + } + focus:outline-none focus:ring-1 focus:ring-blue-500 + `} + /> +
+ + {/* Members List */} +
+ {filteredMembers && filteredMembers.length > 0 ? ( + filteredMembers.map((member) => ( +
{ + if (!member.pending_invitation) { + const isSelected = checkMemberSelected(member.id || ''); + handleMemberToggle(member.id || '', !isSelected); + } + }} + > + handleMemberToggle(member.id || '', checked)} + disabled={member.pending_invitation} + isDarkMode={isDarkMode} + /> + + + +
+
+ {member.name} +
+
+ {member.email} + {member.pending_invitation && ( + (Pending) + )} +
+
+
+ )) + ) : ( +
+
No members found
+
+ )} +
+ + {/* Footer */} +
+ +
+
+ )} +
+ ); +}; + +export default AssigneeSelector; \ No newline at end of file diff --git a/worklenz-frontend/src/components/Avatar.tsx b/worklenz-frontend/src/components/Avatar.tsx new file mode 100644 index 00000000..413a4e3d --- /dev/null +++ b/worklenz-frontend/src/components/Avatar.tsx @@ -0,0 +1,89 @@ +import React from 'react'; + +interface AvatarProps { + name?: string; + size?: number | 'small' | 'default' | 'large'; + isDarkMode?: boolean; + className?: string; + src?: string; + backgroundColor?: string; + onClick?: (e: React.MouseEvent) => void; + style?: React.CSSProperties; +} + +const Avatar: React.FC = ({ + name = '', + size = 'default', + isDarkMode = false, + className = '', + src, + backgroundColor, + onClick, + style = {} +}) => { + // Handle both numeric and string sizes + const getSize = () => { + if (typeof size === 'number') { + return { width: size, height: size, fontSize: `${size * 0.4}px` }; + } + + const sizeMap = { + small: { width: 24, height: 24, fontSize: '10px' }, + default: { width: 32, height: 32, fontSize: '14px' }, + large: { width: 48, height: 48, fontSize: '18px' } + }; + + return sizeMap[size]; + }; + + const sizeStyle = getSize(); + + const lightColors = [ + '#f56565', '#4299e1', '#48bb78', '#ed8936', '#9f7aea', + '#ed64a6', '#667eea', '#38b2ac', '#f6ad55', '#4fd1c7' + ]; + + const darkColors = [ + '#e53e3e', '#3182ce', '#38a169', '#dd6b20', '#805ad5', + '#d53f8c', '#5a67d8', '#319795', '#d69e2e', '#319795' + ]; + + const colors = isDarkMode ? darkColors : lightColors; + const colorIndex = name.charCodeAt(0) % colors.length; + const defaultBgColor = backgroundColor || colors[colorIndex]; + + const handleClick = (e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(e); + }; + + const avatarStyle = { + ...sizeStyle, + backgroundColor: defaultBgColor, + ...style + }; + + if (src) { + return ( + {name} + ); + } + + return ( +
+ {name.charAt(0)?.toUpperCase() || '?'} +
+ ); +}; + +export default Avatar; \ No newline at end of file diff --git a/worklenz-frontend/src/components/AvatarGroup.tsx b/worklenz-frontend/src/components/AvatarGroup.tsx new file mode 100644 index 00000000..a0eaf410 --- /dev/null +++ b/worklenz-frontend/src/components/AvatarGroup.tsx @@ -0,0 +1,111 @@ +import React, { useCallback, useMemo } from 'react'; +import { Avatar, Tooltip } from './index'; + +interface Member { + id?: string; + team_member_id?: string; + name?: string; + names?: string[]; + avatar_url?: string; + color_code?: string; + end?: boolean; +} + +interface AvatarGroupProps { + members: Member[]; + maxCount?: number; + size?: number | 'small' | 'default' | 'large'; + isDarkMode?: boolean; + className?: string; + onClick?: (e: React.MouseEvent) => void; +} + +const AvatarGroup: React.FC = ({ + members, + maxCount, + size = 28, + isDarkMode = false, + className = '', + onClick +}) => { + const stopPropagation = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onClick?.(e); + }, [onClick]); + + const renderAvatar = useCallback((member: Member, index: number) => { + const memberName = member.end && member.names ? member.names.join(', ') : member.name || ''; + const displayName = member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase(); + + return ( + + + + ); + }, [stopPropagation, size, isDarkMode]); + + const visibleMembers = useMemo(() => { + return maxCount ? members.slice(0, maxCount) : members; + }, [members, maxCount]); + + const remainingCount = useMemo(() => { + return maxCount ? Math.max(0, members.length - maxCount) : 0; + }, [members.length, maxCount]); + + const avatarElements = useMemo(() => { + return visibleMembers.map((member, index) => renderAvatar(member, index)); + }, [visibleMembers, renderAvatar]); + + const getSizeStyle = () => { + if (typeof size === 'number') { + return { width: size, height: size, fontSize: `${size * 0.4}px` }; + } + + const sizeMap = { + small: { width: 24, height: 24, fontSize: '10px' }, + default: { width: 32, height: 32, fontSize: '14px' }, + large: { width: 48, height: 48, fontSize: '18px' } + }; + + return sizeMap[size]; + }; + + return ( +
+ {avatarElements} + {remainingCount > 0 && ( + +
+ +{remainingCount} +
+
+ )} +
+ ); +}; + +export default AvatarGroup; \ No newline at end of file diff --git a/worklenz-frontend/src/components/Button.tsx b/worklenz-frontend/src/components/Button.tsx new file mode 100644 index 00000000..e6d28be2 --- /dev/null +++ b/worklenz-frontend/src/components/Button.tsx @@ -0,0 +1,64 @@ +import React from 'react'; + +interface ButtonProps { + children?: React.ReactNode; + onClick?: () => void; + variant?: 'text' | 'default' | 'primary' | 'danger'; + size?: 'small' | 'default' | 'large'; + className?: string; + icon?: React.ReactNode; + isDarkMode?: boolean; + disabled?: boolean; + type?: 'button' | 'submit' | 'reset'; +} + +const Button: React.FC> = ({ + children, + onClick, + variant = 'default', + size = 'default', + className = '', + icon, + isDarkMode = false, + disabled = false, + type = 'button', + ...props +}) => { + const baseClasses = `inline-flex items-center justify-center font-medium transition-colors duration-200 focus:outline-none focus:ring-2 ${isDarkMode ? 'focus:ring-blue-400' : 'focus:ring-blue-500'} focus:ring-offset-1 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`; + + const variantClasses = { + text: isDarkMode + ? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700/50' + : 'text-gray-600 hover:text-gray-800 hover:bg-gray-100', + default: isDarkMode + ? 'bg-gray-800 border border-gray-600 text-gray-200 hover:bg-gray-700' + : 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50', + primary: isDarkMode + ? 'bg-blue-600 text-white hover:bg-blue-700' + : 'bg-blue-500 text-white hover:bg-blue-600', + danger: isDarkMode + ? 'bg-red-600 text-white hover:bg-red-700' + : 'bg-red-500 text-white hover:bg-red-600' + }; + + const sizeClasses = { + small: 'px-2 py-1 text-xs rounded', + default: 'px-3 py-2 text-sm rounded-md', + large: 'px-4 py-3 text-base rounded-lg' + }; + + return ( + + ); +}; + +export default Button; \ No newline at end of file diff --git a/worklenz-frontend/src/components/Checkbox.tsx b/worklenz-frontend/src/components/Checkbox.tsx new file mode 100644 index 00000000..6141331a --- /dev/null +++ b/worklenz-frontend/src/components/Checkbox.tsx @@ -0,0 +1,42 @@ +import React from 'react'; + +interface CheckboxProps { + checked: boolean; + onChange: (checked: boolean) => void; + isDarkMode?: boolean; + className?: string; + disabled?: boolean; +} + +const Checkbox: React.FC = ({ + checked, + onChange, + isDarkMode = false, + className = '', + disabled = false +}) => { + return ( + + ); +}; + +export default Checkbox; \ No newline at end of file diff --git a/worklenz-frontend/src/components/CustomColordLabel.tsx b/worklenz-frontend/src/components/CustomColordLabel.tsx new file mode 100644 index 00000000..a76359f0 --- /dev/null +++ b/worklenz-frontend/src/components/CustomColordLabel.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Tooltip } from 'antd'; +import { Label } from '@/types/task-management.types'; + +interface CustomColordLabelProps { + label: Label; + isDarkMode?: boolean; +} + +const CustomColordLabel: React.FC = ({ + label, + isDarkMode = false +}) => { + const truncatedName = label.name && label.name.length > 10 + ? `${label.name.substring(0, 10)}...` + : label.name; + + return ( + + + {truncatedName} + + + ); +}; + +export default CustomColordLabel; \ No newline at end of file diff --git a/worklenz-frontend/src/components/CustomNumberLabel.tsx b/worklenz-frontend/src/components/CustomNumberLabel.tsx new file mode 100644 index 00000000..02701724 --- /dev/null +++ b/worklenz-frontend/src/components/CustomNumberLabel.tsx @@ -0,0 +1,30 @@ +import React from 'react'; +import { Tooltip } from 'antd'; + +interface CustomNumberLabelProps { + labelList: string[]; + namesString: string; + isDarkMode?: boolean; +} + +const CustomNumberLabel: React.FC = ({ + labelList, + namesString, + isDarkMode = false +}) => { + return ( + + + {namesString} + + + ); +}; + +export default CustomNumberLabel; \ No newline at end of file diff --git a/worklenz-frontend/src/components/LabelsSelector.tsx b/worklenz-frontend/src/components/LabelsSelector.tsx new file mode 100644 index 00000000..30df5b19 --- /dev/null +++ b/worklenz-frontend/src/components/LabelsSelector.tsx @@ -0,0 +1,279 @@ +import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react'; +import { createPortal } from 'react-dom'; +import { useSelector } from 'react-redux'; +import { PlusOutlined, TagOutlined } from '@ant-design/icons'; +import { RootState } from '@/app/store'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { ITaskLabel } from '@/types/tasks/taskLabel.types'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { useAuthService } from '@/hooks/useAuth'; +import { Button, Checkbox, Tag } from '@/components'; + +interface LabelsSelectorProps { + task: IProjectTask; + isDarkMode?: boolean; +} + +const LabelsSelector: React.FC = ({ + task, + isDarkMode = false +}) => { + const [isOpen, setIsOpen] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }); + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + const searchInputRef = useRef(null); + + const { labels } = useSelector((state: RootState) => state.taskLabelsReducer); + const currentSession = useAuthService().getCurrentSession(); + const { socket } = useSocket(); + + const filteredLabels = useMemo(() => { + return (labels as ITaskLabel[])?.filter(label => + label.name?.toLowerCase().includes(searchQuery.toLowerCase()) + ) || []; + }, [labels, searchQuery]); + + // Update dropdown position + const updateDropdownPosition = useCallback(() => { + if (buttonRef.current) { + const rect = buttonRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + window.scrollY + 2, + left: rect.left + window.scrollX, + }); + } + }, []); + + // Close dropdown when clicking outside and handle scroll + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) && + buttonRef.current && !buttonRef.current.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + const handleScroll = () => { + if (isOpen) { + updateDropdownPosition(); + } + }; + + const handleResize = () => { + if (isOpen) { + updateDropdownPosition(); + } + }; + + if (isOpen) { + document.addEventListener('mousedown', handleClickOutside); + window.addEventListener('scroll', handleScroll, true); + window.addEventListener('resize', handleResize); + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + window.removeEventListener('scroll', handleScroll, true); + window.removeEventListener('resize', handleResize); + }; + } else { + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + } + }, [isOpen, updateDropdownPosition]); + + const handleDropdownToggle = (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + console.log('Labels dropdown toggle clicked, current state:', isOpen); + + if (!isOpen) { + updateDropdownPosition(); + setIsOpen(true); + // Focus search input after opening + setTimeout(() => { + searchInputRef.current?.focus(); + }, 0); + } else { + setIsOpen(false); + } + }; + + + + const handleLabelToggle = (label: ITaskLabel) => { + const labelData = { + task_id: task.id, + label_id: label.id, + parent_task: task.parent_task_id, + team_id: currentSession?.team_id, + }; + + socket?.emit(SocketEvents.TASK_LABELS_CHANGE.toString(), JSON.stringify(labelData)); + }; + + const handleCreateLabel = () => { + if (!searchQuery.trim()) return; + + const labelData = { + task_id: task.id, + label: searchQuery.trim(), + parent_task: task.parent_task_id, + team_id: currentSession?.team_id, + }; + + socket?.emit(SocketEvents.CREATE_LABEL.toString(), JSON.stringify(labelData)); + setSearchQuery(''); + }; + + const checkLabelSelected = (labelId: string) => { + return task?.all_labels?.some(existingLabel => existingLabel.id === labelId) || false; + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + const existingLabel = filteredLabels.find( + label => label.name?.toLowerCase() === searchQuery.toLowerCase() + ); + + if (!existingLabel && e.key === 'Enter') { + handleCreateLabel(); + } + }; + + return ( + <> + + + {isOpen && createPortal( +
+ {/* Header */} +
+ setSearchQuery(e.target.value)} + onKeyDown={handleKeyDown} + placeholder="Search labels..." + className={` + w-full px-2 py-1 text-xs rounded border + ${isDarkMode + ? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500' + : 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500' + } + focus:outline-none focus:ring-1 focus:ring-blue-500 + `} + /> +
+ + {/* Labels List */} +
+ {filteredLabels && filteredLabels.length > 0 ? ( + filteredLabels.map((label) => ( +
handleLabelToggle(label)} + > + handleLabelToggle(label)} + isDarkMode={isDarkMode} + /> + +
+ +
+
+ {label.name} +
+
+
+ )) + ) : ( +
+
No labels found
+ {searchQuery.trim() && ( + + )} +
+ )} +
+ + {/* Footer */} +
+ +
+
, + document.body + )} + + ); +}; + +export default LabelsSelector; \ No newline at end of file diff --git a/worklenz-frontend/src/components/Progress.tsx b/worklenz-frontend/src/components/Progress.tsx new file mode 100644 index 00000000..be89433a --- /dev/null +++ b/worklenz-frontend/src/components/Progress.tsx @@ -0,0 +1,84 @@ +import React from 'react'; + +interface ProgressProps { + percent: number; + type?: 'line' | 'circle'; + size?: number; + strokeColor?: string; + strokeWidth?: number; + showInfo?: boolean; + isDarkMode?: boolean; + className?: string; +} + +const Progress: React.FC = ({ + percent, + type = 'line', + size = 24, + strokeColor = '#1890ff', + strokeWidth = 2, + showInfo = true, + isDarkMode = false, + className = '' +}) => { + // Ensure percent is between 0 and 100 + const normalizedPercent = Math.min(Math.max(percent, 0), 100); + + if (type === 'circle') { + const radius = (size - strokeWidth) / 2; + const circumference = radius * 2 * Math.PI; + const strokeDasharray = circumference; + const strokeDashoffset = circumference - (normalizedPercent / 100) * circumference; + + return ( +
+ + + + + {showInfo && ( + + {normalizedPercent}% + + )} +
+ ); + } + + return ( +
+
+ {showInfo && ( +
+ {normalizedPercent}% +
+ )} +
+ ); +}; + +export default Progress; \ No newline at end of file diff --git a/worklenz-frontend/src/components/Tag.tsx b/worklenz-frontend/src/components/Tag.tsx new file mode 100644 index 00000000..5cdad835 --- /dev/null +++ b/worklenz-frontend/src/components/Tag.tsx @@ -0,0 +1,54 @@ +import React from 'react'; + +interface TagProps { + children: React.ReactNode; + color?: string; + backgroundColor?: string; + className?: string; + size?: 'small' | 'default'; + variant?: 'default' | 'outlined'; + isDarkMode?: boolean; +} + +const Tag: React.FC = ({ + children, + color = 'white', + backgroundColor = '#1890ff', + className = '', + size = 'default', + variant = 'default', + isDarkMode = false +}) => { + const sizeClasses = { + small: 'px-1 py-0.5 text-xs', + default: 'px-2 py-1 text-xs' + }; + + const baseClasses = `inline-flex items-center font-medium rounded ${sizeClasses[size]}`; + + if (variant === 'outlined') { + return ( + + {children} + + ); + } + + return ( + + {children} + + ); +}; + +export default Tag; \ No newline at end of file diff --git a/worklenz-frontend/src/components/Tooltip.tsx b/worklenz-frontend/src/components/Tooltip.tsx new file mode 100644 index 00000000..e61ea1ce --- /dev/null +++ b/worklenz-frontend/src/components/Tooltip.tsx @@ -0,0 +1,35 @@ +import React from 'react'; + +interface TooltipProps { + title: string | React.ReactNode; + children: React.ReactNode; + isDarkMode?: boolean; + placement?: 'top' | 'bottom' | 'left' | 'right'; + className?: string; +} + +const Tooltip: React.FC = ({ + title, + children, + isDarkMode = false, + placement = 'top', + className = '' +}) => { + const placementClasses = { + top: 'bottom-full left-1/2 transform -translate-x-1/2 mb-2', + bottom: 'top-full left-1/2 transform -translate-x-1/2 mt-2', + left: 'right-full top-1/2 transform -translate-y-1/2 mr-2', + right: 'left-full top-1/2 transform -translate-y-1/2 ml-2' + }; + + return ( +
+ {children} +
+ {title} +
+
+ ); +}; + +export default Tooltip; \ No newline at end of file diff --git a/worklenz-frontend/src/components/index.ts b/worklenz-frontend/src/components/index.ts new file mode 100644 index 00000000..dcec2980 --- /dev/null +++ b/worklenz-frontend/src/components/index.ts @@ -0,0 +1,12 @@ +// Reusable UI Components +export { default as AssigneeSelector } from './AssigneeSelector'; +export { default as Avatar } from './Avatar'; +export { default as AvatarGroup } from './AvatarGroup'; +export { default as Button } from './Button'; +export { default as Checkbox } from './Checkbox'; +export { default as CustomColordLabel } from './CustomColordLabel'; +export { default as CustomNumberLabel } from './CustomNumberLabel'; +export { default as LabelsSelector } from './LabelsSelector'; +export { default as Progress } from './Progress'; +export { default as Tag } from './Tag'; +export { default as Tooltip } from './Tooltip'; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/improved-task-filters.tsx b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx new file mode 100644 index 00000000..8df3a558 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/improved-task-filters.tsx @@ -0,0 +1,820 @@ +import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector, useDispatch } from 'react-redux'; +import { useSearchParams } from 'react-router-dom'; +import { createSelector } from '@reduxjs/toolkit'; +import { + SearchOutlined, + FilterOutlined, + CloseOutlined, + DownOutlined, + TeamOutlined, + TagOutlined, + FlagOutlined, + GroupOutlined, + EyeOutlined, + InboxOutlined, + CheckOutlined, + SettingOutlined, + MoreOutlined, +} from '@ant-design/icons'; +import { RootState } from '@/app/store'; +import { AppDispatch } from '@/app/store'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import useTabSearchParam from '@/hooks/useTabSearchParam'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { colors } from '@/styles/colors'; +import SingleAvatar from '@components/common/single-avatar/single-avatar'; +import { useFilterDataLoader } from '@/hooks/useFilterDataLoader'; + +// Import Redux actions +import { fetchTasksV3, setSelectedPriorities } from '@/features/task-management/task-management.slice'; +import { setCurrentGrouping, selectCurrentGrouping } from '@/features/task-management/grouping.slice'; +import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice'; +import { fetchLabelsByProject, fetchTaskAssignees, setMembers, setLabels } from '@/features/tasks/tasks.slice'; +import { getTeamMembers } from '@/features/team-members/team-members.slice'; +import { ITaskPriority } from '@/types/tasks/taskPriority.types'; +import { ITaskListColumn } from '@/types/tasks/taskList.types'; +import { IGroupBy } from '@/features/tasks/tasks.slice'; + +// Memoized selectors to prevent unnecessary re-renders +const selectPriorities = createSelector( + [(state: any) => state.priorityReducer.priorities], + (priorities) => priorities || [] +); + +const selectTaskPriorities = createSelector( + [(state: any) => state.taskReducer.priorities], + (priorities) => priorities || [] +); + +const selectBoardPriorities = createSelector( + [(state: any) => state.boardReducer.priorities], + (priorities) => priorities || [] +); + +const selectTaskLabels = createSelector( + [(state: any) => state.taskReducer.labels], + (labels) => labels || [] +); + +const selectBoardLabels = createSelector( + [(state: any) => state.boardReducer.labels], + (labels) => labels || [] +); + +const selectTaskAssignees = createSelector( + [(state: any) => state.taskReducer.taskAssignees], + (assignees) => assignees || [] +); + +const selectBoardAssignees = createSelector( + [(state: any) => state.boardReducer.taskAssignees], + (assignees) => assignees || [] +); + +const selectProject = createSelector( + [(state: any) => state.projectReducer.project], + (project) => project +); + +const selectSelectedPriorities = createSelector( + [(state: any) => state.taskManagement.selectedPriorities], + (selectedPriorities) => selectedPriorities || [] +); + +// Types +interface FilterOption { + id: string; + label: string; + value: string; + color?: string; + avatar?: string; + count?: number; + selected?: boolean; +} + +interface FilterSection { + id: string; + label: string; + icon: React.ComponentType<{ className?: string }>; + options: FilterOption[]; + selectedValues: string[]; + multiSelect: boolean; + searchable?: boolean; +} + +interface ImprovedTaskFiltersProps { + position: 'board' | 'list'; + className?: string; +} + +// Get real filter data from Redux state +const useFilterData = (): FilterSection[] => { + const { t } = useTranslation('task-list-filters'); + const [searchParams] = useSearchParams(); + const { projectView } = useTabSearchParam(); + + // Use memoized selectors to prevent unnecessary re-renders + const priorities = useAppSelector(selectPriorities); + const taskPriorities = useAppSelector(selectTaskPriorities); + const boardPriorities = useAppSelector(selectBoardPriorities); + const taskLabels = useAppSelector(selectTaskLabels); + const boardLabels = useAppSelector(selectBoardLabels); + const taskAssignees = useAppSelector(selectTaskAssignees); + const boardAssignees = useAppSelector(selectBoardAssignees); + const taskGroupBy = useAppSelector(state => state.taskReducer.groupBy); + const boardGroupBy = useAppSelector(state => state.boardReducer.groupBy); + const project = useAppSelector(selectProject); + const currentGrouping = useAppSelector(selectCurrentGrouping); + const selectedPriorities = useAppSelector(selectSelectedPriorities); + + const tab = searchParams.get('tab'); + const currentProjectView = tab === 'tasks-list' ? 'list' : 'kanban'; + + // Debug logging + console.log('Filter Data Debug:', { + priorities: priorities?.length, + taskAssignees: taskAssignees?.length, + boardAssignees: boardAssignees?.length, + labels: taskLabels?.length, + boardLabels: boardLabels?.length, + currentProjectView, + projectId: project?.id + }); + + return useMemo(() => { + const currentPriorities = currentProjectView === 'list' ? taskPriorities : boardPriorities; + const currentLabels = currentProjectView === 'list' ? taskLabels : boardLabels; + const currentAssignees = currentProjectView === 'list' ? taskAssignees : boardAssignees; + const groupByValue = currentGrouping || 'status'; + + return [ + { + id: 'priority', + label: 'Priority', + options: priorities.map((p: any) => ({ + value: p.id, + label: p.name, + color: p.color_code, + })), + selectedValues: selectedPriorities, + multiSelect: true, + searchable: false, + icon: FlagOutlined, + }, + { + id: 'assignees', + label: t('membersText'), + icon: TeamOutlined, + multiSelect: true, + searchable: true, + selectedValues: currentAssignees.filter((m: any) => m.selected && m.id).map((m: any) => m.id || ''), + options: currentAssignees.map((assignee: any) => ({ + id: assignee.id || '', + label: assignee.name || '', + value: assignee.id || '', + avatar: assignee.avatar_url, + selected: assignee.selected, + })), + }, + { + id: 'labels', + label: t('labelsText'), + icon: TagOutlined, + multiSelect: true, + searchable: true, + selectedValues: currentLabels.filter((l: any) => l.selected && l.id).map((l: any) => l.id || ''), + options: currentLabels.map((label: any) => ({ + id: label.id || '', + label: label.name || '', + value: label.id || '', + color: label.color_code, + selected: label.selected, + })), + }, + { + id: 'groupBy', + label: t('groupByText'), + icon: GroupOutlined, + multiSelect: false, + searchable: false, + selectedValues: [groupByValue], + options: [ + { id: 'status', label: t('statusText'), value: 'status' }, + { id: 'priority', label: t('priorityText'), value: 'priority' }, + { id: 'phase', label: project?.phase_label || t('phaseText'), value: 'phase' }, + ], + }, + ]; + }, [ + priorities, + taskPriorities, + boardPriorities, + taskLabels, + boardLabels, + taskAssignees, + boardAssignees, + taskGroupBy, + boardGroupBy, + project, + currentProjectView, + t, + currentGrouping, + selectedPriorities + ]); +}; + +// Filter Dropdown Component +const FilterDropdown: React.FC<{ + section: FilterSection; + onSelectionChange: (sectionId: string, values: string[]) => void; + isOpen: boolean; + onToggle: () => void; + themeClasses: any; + isDarkMode: boolean; + className?: string; +}> = ({ section, onSelectionChange, isOpen, onToggle, themeClasses, isDarkMode, className = '' }) => { + const [searchTerm, setSearchTerm] = useState(''); + const [filteredOptions, setFilteredOptions] = useState(section.options); + const dropdownRef = useRef(null); + + // Filter options based on search term + useEffect(() => { + if (!section.searchable || !searchTerm.trim()) { + setFilteredOptions(section.options); + return; + } + + const filtered = section.options.filter(option => + option.label.toLowerCase().includes(searchTerm.toLowerCase()) + ); + setFilteredOptions(filtered); + }, [searchTerm, section.options, section.searchable]); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + if (isOpen) onToggle(); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, [isOpen, onToggle]); + + useEffect(() => { + if (!isOpen) { + setSearchTerm(''); + } + }, [isOpen]); + + const handleOptionToggle = useCallback((optionValue: string) => { + if (section.multiSelect) { + const newValues = section.selectedValues.includes(optionValue) + ? section.selectedValues.filter(v => v !== optionValue) + : [...section.selectedValues, optionValue]; + onSelectionChange(section.id, newValues); + } else { + onSelectionChange(section.id, [optionValue]); + onToggle(); + } + }, [section, onSelectionChange, onToggle]); + + const clearSelection = useCallback(() => { + onSelectionChange(section.id, []); + }, [section.id, onSelectionChange]); + + const selectedCount = section.selectedValues.length; + const IconComponent = section.icon; + + return ( +
+ {/* Trigger Button */} + + + {/* Dropdown Panel */} + {isOpen && ( +
+ {/* Search Input */} + {section.searchable && ( +
+
+ + setSearchTerm(e.target.value)} + placeholder={`Search ${section.label.toLowerCase()}...`} + className={`w-full pl-8 pr-2 py-1 rounded border focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-150 ${ + isDarkMode + ? 'bg-gray-700 text-gray-100 placeholder-gray-400 border-gray-600' + : 'bg-white text-gray-900 placeholder-gray-400 border-gray-300' + }`} + /> +
+
+ )} + + {/* Options List */} +
+ {filteredOptions.length === 0 ? ( +
+ No options found +
+ ) : ( +
+ {filteredOptions.map((option) => { + const isSelected = section.selectedValues.includes(option.value); + + return ( + + ); + })} +
+ )} +
+
+ )} +
+ ); +}; + +// Search Component +const SearchFilter: React.FC<{ + value: string; + onChange: (value: string) => void; + placeholder?: string; + themeClasses: any; + className?: string; +}> = ({ value, onChange, placeholder = 'Search tasks...', themeClasses, className = '' }) => { + const [isExpanded, setIsExpanded] = useState(false); + const [localValue, setLocalValue] = useState(value); + const inputRef = useRef(null); + + const handleToggle = useCallback(() => { + setIsExpanded(!isExpanded); + if (!isExpanded) { + setTimeout(() => inputRef.current?.focus(), 100); + } + }, [isExpanded]); + + const handleSubmit = useCallback((e: React.FormEvent) => { + e.preventDefault(); + onChange(localValue); + }, [localValue, onChange]); + + const handleClear = useCallback(() => { + setLocalValue(''); + onChange(''); + setIsExpanded(false); + }, [onChange]); + + // Redux selectors for theme and other state + const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark'); + + return ( +
+ {!isExpanded ? ( + + ) : ( +
+
+ + setLocalValue(e.target.value)} + placeholder={placeholder} + className={`w-full pr-4 pl-8 py-1 rounded border focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-150 ${ + isDarkMode + ? 'bg-gray-700 text-gray-100 placeholder-gray-400 border-gray-600' + : 'bg-white text-gray-900 placeholder-gray-400 border-gray-300' + }`} + /> + {localValue && ( + + )} +
+ + +
+ )} +
+ ); +}; + +// Main Component +const ImprovedTaskFilters: React.FC = ({ + position, + className = '' +}) => { + const { t } = useTranslation('task-list-filters'); + const dispatch = useAppDispatch(); + const { socket, connected } = useSocket(); + + // Get current state values for filter updates + const currentTaskAssignees = useAppSelector(state => state.taskReducer.taskAssignees); + const currentBoardAssignees = useAppSelector(state => state.boardReducer.taskAssignees); + const currentTaskLabels = useAppSelector(state => state.taskReducer.labels); + const currentBoardLabels = useAppSelector(state => state.boardReducer.labels); + + // Use the filter data loader hook + useFilterDataLoader(); + + // Local state for filter sections + const [filterSections, setFilterSections] = useState([]); + const [searchValue, setSearchValue] = useState(''); + const [showArchived, setShowArchived] = useState(false); + const [openDropdown, setOpenDropdown] = useState(null); + const [activeFiltersCount, setActiveFiltersCount] = useState(0); + + // Get real filter data + const filterSectionsData = useFilterData(); + + // Check if data is loaded - memoize this computation + const isDataLoaded = useMemo(() => { + return filterSectionsData.some(section => section.options.length > 0); + }, [filterSectionsData]); + + // Initialize filter sections from data - memoize this to prevent unnecessary updates + const memoizedFilterSections = useMemo(() => { + return filterSectionsData; + }, [filterSectionsData]); + + useEffect(() => { + setFilterSections(memoizedFilterSections); + }, [memoizedFilterSections]); + + // Redux selectors for theme and other state + const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark'); + const { projectId } = useAppSelector(state => state.projectReducer); + const { projectView } = useTabSearchParam(); + const { columns } = useAppSelector(state => state.taskReducer); + + // Theme-aware class names - memoize to prevent unnecessary re-renders + const themeClasses = useMemo(() => ({ + containerBg: isDarkMode ? 'bg-gray-800' : 'bg-white', + containerBorder: isDarkMode ? 'border-gray-700' : 'border-gray-200', + buttonBg: isDarkMode ? 'bg-gray-700 hover:bg-gray-600' : 'bg-white hover:bg-gray-50', + buttonBorder: isDarkMode ? 'border-gray-600' : 'border-gray-300', + buttonText: isDarkMode ? 'text-gray-200' : 'text-gray-700', + dropdownBg: isDarkMode ? 'bg-gray-800' : 'bg-white', + dropdownBorder: isDarkMode ? 'border-gray-700' : 'border-gray-200', + optionText: isDarkMode ? 'text-gray-200' : 'text-gray-700', + optionHover: isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-50', + secondaryText: isDarkMode ? 'text-gray-400' : 'text-gray-500', + dividerBorder: isDarkMode ? 'border-gray-700' : 'border-gray-200', + pillBg: isDarkMode ? 'bg-gray-700' : 'bg-gray-100', + pillText: isDarkMode ? 'text-gray-200' : 'text-gray-700', + pillActiveBg: isDarkMode ? 'bg-blue-600' : 'bg-blue-100', + pillActiveText: isDarkMode ? 'text-white' : 'text-blue-800', + searchBg: isDarkMode ? 'bg-gray-700' : 'bg-gray-50', + searchBorder: isDarkMode ? 'border-gray-600' : 'border-gray-300', + searchText: isDarkMode ? 'text-gray-200' : 'text-gray-900', + }), [isDarkMode]); + + // Calculate active filters count + useEffect(() => { + const count = filterSections.reduce((acc, section) => acc + section.selectedValues.length, 0); + setActiveFiltersCount(count + (searchValue ? 1 : 0)); + }, [filterSections, searchValue]); + + // Handlers + const handleDropdownToggle = useCallback((sectionId: string) => { + setOpenDropdown(current => current === sectionId ? null : sectionId); + }, []); + + const handleSelectionChange = useCallback((sectionId: string, values: string[]) => { + if (!projectId) return; + + // Prevent clearing all group by options + if (sectionId === 'groupBy' && values.length === 0) { + return; // Do nothing + } + + // Update local state first + setFilterSections(prev => prev.map(section => + section.id === sectionId + ? { ...section, selectedValues: values } + : section + )); + + // Use task management slices for groupBy + if (sectionId === 'groupBy' && values.length > 0) { + dispatch(setCurrentGrouping(values[0] as 'status' | 'priority' | 'phase')); + dispatch(fetchTasksV3(projectId)); + return; + } + + // Handle priorities + if (sectionId === 'priority') { + console.log('Priority selection changed:', { sectionId, values, projectId }); + dispatch(setSelectedPriorities(values)); + dispatch(fetchTasksV3(projectId)); + return; + } + + // Handle assignees (members) + if (sectionId === 'assignees') { + // Update selected property for each assignee + const updatedAssignees = currentTaskAssignees.map(member => ({ + ...member, + selected: values.includes(member.id || '') + })); + dispatch(setMembers(updatedAssignees)); + dispatch(fetchTasksV3(projectId)); + return; + } + + // Handle labels + if (sectionId === 'labels') { + // Update selected property for each label + const updatedLabels = currentTaskLabels.map(label => ({ + ...label, + selected: values.includes(label.id || '') + })); + dispatch(setLabels(updatedLabels)); + dispatch(fetchTasksV3(projectId)); + return; + } + }, [dispatch, projectId, currentTaskAssignees, currentTaskLabels]); + + const handleSearchChange = useCallback((value: string) => { + setSearchValue(value); + + // Log the search change for now + console.log('Search change:', value, { projectView, projectId }); + + // TODO: Implement proper search dispatch + }, [projectView, projectId]); + + const clearAllFilters = useCallback(() => { + // TODO: Implement clear all filters + console.log('Clear all filters'); + setSearchValue(''); + setShowArchived(false); + }, []); + + const toggleArchived = useCallback(() => { + setShowArchived(!showArchived); + // TODO: Implement proper archived toggle + console.log('Toggle archived:', !showArchived); + }, [showArchived]); + + // Show fields dropdown functionality + const handleColumnVisibilityChange = useCallback(async (col: ITaskListColumn) => { + if (!projectId) return; + console.log('Column visibility change:', col); + // TODO: Implement column visibility change + }, [projectId]); + + return ( +
+
+ {/* Left Section - Main Filters */} +
+ {/* Search */} + + + {/* Filter Dropdowns - Only render when data is loaded */} + {isDataLoaded ? ( + filterSectionsData.map((section) => ( + handleDropdownToggle(section.id)} + themeClasses={themeClasses} + isDarkMode={isDarkMode} + /> + )) + ) : ( + // Loading state +
+
+ Loading filters... +
+ )} +
+ + {/* Right Section - Additional Controls */} +
+ {/* Active Filters Indicator */} + {activeFiltersCount > 0 && ( +
+ + {activeFiltersCount} filter{activeFiltersCount !== 1 ? 's' : ''} active + + +
+ )} + + {/* Show Archived Toggle (for list view) */} + {position === 'list' && ( + + )} + + {/* Show Fields Button (for list view) */} + {position === 'list' && ( + + )} +
+
+ + {/* Active Filters Pills */} + {activeFiltersCount > 0 && ( +
+ {searchValue && ( +
+ + "{searchValue}" + +
+ )} + + {filterSectionsData + .filter(section => section.id !== 'groupBy') // <-- skip groupBy + .map((section) => + section.selectedValues.map((value) => { + const option = section.options.find(opt => opt.value === value); + if (!option) return null; + + return ( +
+ {option.color && ( +
+ )} + {option.label} + +
+ ); + }) + )} +
+ )} +
+ ); +}; + +export default React.memo(ImprovedTaskFilters); \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx index dff7eb74..5793389a 100644 --- a/worklenz-frontend/src/components/task-management/task-group.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -1,12 +1,11 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo, useCallback } from 'react'; import { useDroppable } from '@dnd-kit/core'; import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; import { useSelector } from 'react-redux'; import { Button, Typography } from 'antd'; import { PlusOutlined, RightOutlined, DownOutlined } from '@ant-design/icons'; -import { ITaskListGroup } from '@/types/tasks/taskList.types'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { IGroupBy, COLUMN_KEYS } from '@/features/tasks/tasks.slice'; +import { TaskGroup as TaskGroupType, Task } from '@/types/task-management.types'; +import { taskManagementSelectors } from '@/features/task-management/task-management.slice'; import { RootState } from '@/app/store'; import TaskRow from './task-row'; import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row'; @@ -14,9 +13,9 @@ import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-tabl const { Text } = Typography; interface TaskGroupProps { - group: ITaskListGroup; + group: TaskGroupType; projectId: string; - currentGrouping: IGroupBy; + currentGrouping: 'status' | 'priority' | 'phase'; selectedTaskIds: string[]; onAddTask?: (groupId: string) => void; onToggleCollapse?: (groupId: string) => void; @@ -24,7 +23,40 @@ interface TaskGroupProps { onToggleSubtasks?: (taskId: string) => void; } -const TaskGroup: React.FC = ({ +// Group color mapping - moved outside component for better performance +const GROUP_COLORS = { + status: { + todo: '#faad14', + doing: '#1890ff', + done: '#52c41a', + }, + priority: { + high: '#fa8c16', + medium: '#faad14', + low: '#52c41a', + }, + phase: '#722ed1', + default: '#d9d9d9', +} as const; + +// Column configurations for consistent layout +const FIXED_COLUMNS = [ + { key: 'drag', label: '', width: 40, fixed: true }, + { key: 'select', label: '', width: 40, fixed: true }, + { key: 'key', label: 'Key', width: 80, fixed: true }, + { key: 'task', label: 'Task', width: 475, fixed: true }, +]; + +const SCROLLABLE_COLUMNS = [ + { key: 'progress', label: 'Progress', width: 90 }, + { key: 'members', label: 'Members', width: 150 }, + { key: 'labels', label: 'Labels', width: 200 }, + { key: 'status', label: 'Status', width: 100 }, + { key: 'priority', label: 'Priority', width: 100 }, + { key: 'timeTracking', label: 'Time Tracking', width: 120 }, +]; + +const TaskGroup: React.FC = React.memo(({ group, projectId, currentGrouping, @@ -34,7 +66,7 @@ const TaskGroup: React.FC = ({ onSelectTask, onToggleSubtasks, }) => { - const [isCollapsed, setIsCollapsed] = useState(false); + const [isCollapsed, setIsCollapsed] = useState(group.collapsed || false); const { setNodeRef, isOver } = useDroppable({ id: group.id, @@ -44,197 +76,182 @@ const TaskGroup: React.FC = ({ }, }); - // Get column visibility from Redux store - const columns = useSelector((state: RootState) => state.taskReducer.columns); + // Get all tasks from the store + const allTasks = useSelector(taskManagementSelectors.selectAll); + + // Get theme from Redux store + const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark'); + + // Get tasks for this group using memoization for performance + const groupTasks = useMemo(() => { + return group.taskIds + .map(taskId => allTasks.find(task => task.id === taskId)) + .filter((task): task is Task => task !== undefined); + }, [group.taskIds, allTasks]); - // Helper function to check if a column is visible - const isColumnVisible = (columnKey: string) => { - const column = columns.find(col => col.key === columnKey); - return column ? column.pinned : true; // Default to visible if column not found - }; + // Calculate group statistics - memoized + const { completedTasks, totalTasks, completionRate } = useMemo(() => { + const completed = groupTasks.filter(task => task.progress === 100).length; + const total = groupTasks.length; + const rate = total > 0 ? Math.round((completed / total) * 100) : 0; + + return { + completedTasks: completed, + totalTasks: total, + completionRate: rate, + }; + }, [groupTasks]); - // Get task IDs for sortable context - const taskIds = group.tasks.map(task => task.id!); - - // Calculate group statistics - const completedTasks = group.tasks.filter( - task => task.status_category?.is_done || task.complete_ratio === 100 - ).length; - const totalTasks = group.tasks.length; - const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; - - // Get group color based on grouping type - const getGroupColor = () => { - if (group.color_code) return group.color_code; + // Get group color based on grouping type - memoized + const groupColor = useMemo(() => { + if (group.color) return group.color; // Fallback colors based on group value switch (currentGrouping) { case 'status': - return group.id === 'todo' ? '#faad14' : group.id === 'doing' ? '#1890ff' : '#52c41a'; + return GROUP_COLORS.status[group.groupValue as keyof typeof GROUP_COLORS.status] || GROUP_COLORS.default; case 'priority': - return group.id === 'critical' - ? '#ff4d4f' - : group.id === 'high' - ? '#fa8c16' - : group.id === 'medium' - ? '#faad14' - : '#52c41a'; + return GROUP_COLORS.priority[group.groupValue as keyof typeof GROUP_COLORS.priority] || GROUP_COLORS.default; case 'phase': - return '#722ed1'; + return GROUP_COLORS.phase; default: - return '#d9d9d9'; + return GROUP_COLORS.default; } - }; + }, [group.color, group.groupValue, currentGrouping]); - const handleToggleCollapse = () => { + // Memoized event handlers + const handleToggleCollapse = useCallback(() => { setIsCollapsed(!isCollapsed); onToggleCollapse?.(group.id); - }; + }, [isCollapsed, onToggleCollapse, group.id]); - const handleAddTask = () => { + const handleAddTask = useCallback(() => { onAddTask?.(group.id); - }; + }, [onAddTask, group.id]); + + // Memoized style object + const containerStyle = useMemo(() => ({ + backgroundColor: isOver + ? (isDarkMode ? '#1a2332' : '#f0f8ff') + : undefined, + }), [isOver, isDarkMode]); return (
- {/* Group Header Row */} -
-
-
-
-
-
- - {/* Column Headers */} - {!isCollapsed && totalTasks > 0 && ( -
-
-
-
-
-
- Key -
-
- Task +
+
sum + col.width, 0) + SCROLLABLE_COLUMNS.reduce((sum, col) => sum + col.width, 0) }}> + {/* Group Header Row */} +
+
+
+
-
- {isColumnVisible(COLUMN_KEYS.PROGRESS) && ( -
- Progress -
- )} - {isColumnVisible(COLUMN_KEYS.ASSIGNEES) && ( -
- Members -
- )} - {isColumnVisible(COLUMN_KEYS.LABELS) && ( -
- Labels -
- )} - {isColumnVisible(COLUMN_KEYS.STATUS) && ( -
- Status -
- )} - {isColumnVisible(COLUMN_KEYS.PRIORITY) && ( -
- Priority -
- )} - {isColumnVisible(COLUMN_KEYS.TIME_TRACKING) && ( -
- Time Tracking -
- )} -
-
- )} - {/* Tasks List */} - {!isCollapsed && ( -
- {group.tasks.length === 0 ? ( -
-
-
-
- No tasks in this group -
- -
+ {col.label && {col.label}} +
+ ))} +
+
+ {SCROLLABLE_COLUMNS.map(col => ( +
+ {col.label} +
+ ))}
- ) : ( - -
- {group.tasks.map((task, index) => ( - - ))} -
-
)} - {/* Add Task Row - Always show when not collapsed */} -
- -
-
- )} + {/* Tasks List */} + {!isCollapsed && ( +
+ {groupTasks.length === 0 ? ( +
+
+
+
+ No tasks in this group +
+ +
+
+
+
+ ) : ( + +
+ {groupTasks.map((task, index) => ( + + ))} +
+
+ )} + {/* Add Task Row - Always show when not collapsed */} +
+ +
+
+ )} +
+
); -}; +}, (prevProps, nextProps) => { + // Simplified comparison for better performance + return ( + prevProps.group.id === nextProps.group.id && + prevProps.group.taskIds.length === nextProps.group.taskIds.length && + prevProps.group.collapsed === nextProps.group.collapsed && + prevProps.selectedTaskIds.length === nextProps.selectedTaskIds.length && + prevProps.currentGrouping === nextProps.currentGrouping + ); +}); + +TaskGroup.displayName = 'TaskGroup'; export default TaskGroup; diff --git a/worklenz-frontend/src/components/task-management/task-list-board.tsx b/worklenz-frontend/src/components/task-management/task-list-board.tsx index 60367f0b..aa03f805 100644 --- a/worklenz-frontend/src/components/task-management/task-list-board.tsx +++ b/worklenz-frontend/src/components/task-management/task-list-board.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useMemo } from 'react'; +import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { DndContext, @@ -12,25 +12,41 @@ import { useSensor, useSensors, } from '@dnd-kit/core'; -import { - sortableKeyboardCoordinates, -} from '@dnd-kit/sortable'; +import { sortableKeyboardCoordinates } from '@dnd-kit/sortable'; import { Card, Spin, Empty } from 'antd'; import { RootState } from '@/app/store'; import { - IGroupBy, - setGroup, - fetchTaskGroups, + taskManagementSelectors, reorderTasks, -} from '@/features/tasks/tasks.slice'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import TaskGroup from './task-group'; + moveTaskToGroup, + optimisticTaskMove, + setLoading, + fetchTasks, + fetchTasksV3, + selectTaskGroupsV3, + selectCurrentGroupingV3, +} from '@/features/task-management/task-management.slice'; +import { + selectTaskGroups, + selectCurrentGrouping, + setCurrentGrouping, +} from '@/features/task-management/grouping.slice'; +import { + selectSelectedTaskIds, + toggleTaskSelection, + clearSelection, +} from '@/features/task-management/selection.slice'; +import { Task } from '@/types/task-management.types'; +import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; 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 the TaskListFilters component -const TaskListFilters = React.lazy(() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters')); +// Import the improved TaskListFilters component +const ImprovedTaskFilters = React.lazy( + () => import('./improved-task-filters') +); interface TaskListBoardProps { projectId: string; @@ -38,10 +54,34 @@ interface TaskListBoardProps { } interface DragState { - activeTask: IProjectTask | null; + activeTask: Task | null; activeGroupId: string | null; } +// Throttle utility for performance optimization +const throttle = void>(func: T, delay: number): T => { + let timeoutId: NodeJS.Timeout | null = null; + let lastExecTime = 0; + + return ((...args: any[]) => { + const currentTime = Date.now(); + + if (currentTime - lastExecTime > delay) { + func(...args); + lastExecTime = currentTime; + } else { + if (timeoutId) clearTimeout(timeoutId); + timeoutId = setTimeout( + () => { + func(...args); + lastExecTime = Date.now(); + }, + delay - (currentTime - lastExecTime) + ); + } + }) as T; +}; + const TaskListBoard: React.FC = ({ projectId, className = '' }) => { const dispatch = useDispatch(); const [dragState, setDragState] = useState({ @@ -49,24 +89,29 @@ const TaskListBoard: React.FC = ({ projectId, className = '' activeGroupId: null, }); - // Redux selectors - const { - taskGroups, - loadingGroups, - error, - groupBy, - search, - archived, - } = useSelector((state: RootState) => state.taskReducer); + // Refs for performance optimization + const dragOverTimeoutRef = useRef(null); + const containerRef = useRef(null); - // Selection state - const [selectedTaskIds, setSelectedTaskIds] = useState([]); + // Enable real-time socket updates for task changes + useTaskSocketHandlers(); - // Drag and Drop sensors + // Redux selectors using V3 API (pre-processed data, minimal loops) + const tasks = useSelector(taskManagementSelectors.selectAll); + const taskGroups = useSelector(selectTaskGroupsV3); // Pre-processed groups from backend + const currentGrouping = useSelector(selectCurrentGroupingV3); // Current grouping from backend + const selectedTaskIds = useSelector(selectSelectedTaskIds); + const loading = useSelector((state: RootState) => state.taskManagement.loading); + const error = useSelector((state: RootState) => state.taskManagement.error); + + // Get theme from Redux store + const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark'); + + // Drag and Drop sensors - optimized for better performance const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { - distance: 8, + distance: 3, // Reduced from 8 for more responsive dragging }, }), useSensor(KeyboardSensor, { @@ -77,227 +122,322 @@ const TaskListBoard: React.FC = ({ projectId, className = '' // Fetch task groups when component mounts or dependencies change useEffect(() => { if (projectId) { - dispatch(fetchTaskGroups(projectId)); + // Fetch real tasks from V3 API (minimal processing needed) + dispatch(fetchTasksV3(projectId)); } - }, [dispatch, projectId, groupBy, search, archived]); - - // Memoized calculations - const allTaskIds = useMemo(() => { - return taskGroups.flatMap(group => group.tasks.map(task => task.id!)); - }, [taskGroups]); - - const totalTasksCount = useMemo(() => { - return taskGroups.reduce((sum, group) => sum + group.tasks.length, 0); - }, [taskGroups]); + }, [dispatch, projectId, currentGrouping]); + // Memoized calculations - optimized + const allTaskIds = useMemo(() => tasks.map(task => task.id), [tasks]); + const totalTasksCount = useMemo(() => tasks.length, [tasks]); const hasSelection = selectedTaskIds.length > 0; - // Handlers - const handleGroupingChange = (newGroupBy: IGroupBy) => { - dispatch(setGroup(newGroupBy)); - }; + // Memoized handlers for better performance + const handleGroupingChange = useCallback( + (newGroupBy: 'status' | 'priority' | 'phase') => { + dispatch(setCurrentGrouping(newGroupBy)); + }, + [dispatch] + ); - const handleDragStart = (event: DragStartEvent) => { - const { active } = event; - const taskId = active.id as string; - - // Find the task and its group - let activeTask: IProjectTask | null = null; - let activeGroupId: string | null = null; + const handleDragStart = useCallback( + (event: DragStartEvent) => { + const { active } = event; + const taskId = active.id as string; - for (const group of taskGroups) { - const task = group.tasks.find(t => t.id === taskId); - if (task) { - activeTask = task; - activeGroupId = group.id; - break; - } - } + // Find the task and its group + const activeTask = tasks.find(t => t.id === taskId) || null; + let activeGroupId: string | null = null; - setDragState({ - activeTask, - activeGroupId, - }); - }; - - const handleDragOver = (event: DragOverEvent) => { - // Handle drag over logic if needed for visual feedback - }; - - const handleDragEnd = (event: DragEndEvent) => { - const { active, over } = event; - - setDragState({ - activeTask: null, - activeGroupId: null, - }); - - if (!over || !dragState.activeTask || !dragState.activeGroupId) { - return; - } - - const activeTaskId = active.id as string; - const overContainer = over.id as string; - - // Determine if dropping on a group or task - const overGroup = taskGroups.find(g => g.id === overContainer); - let targetGroupId = overContainer; - let targetIndex = -1; - - if (!overGroup) { - // Dropping on a task, find which group it belongs to - for (const group of taskGroups) { - const taskIndex = group.tasks.findIndex(t => t.id === overContainer); - if (taskIndex !== -1) { - targetGroupId = group.id; - targetIndex = taskIndex; - break; + if (activeTask) { + // Determine group ID based on current grouping + if (currentGrouping === 'status') { + activeGroupId = `status-${activeTask.status}`; + } else if (currentGrouping === 'priority') { + activeGroupId = `priority-${activeTask.priority}`; + } else if (currentGrouping === 'phase') { + activeGroupId = `phase-${activeTask.phase}`; } } - } - const sourceGroup = taskGroups.find(g => g.id === dragState.activeGroupId); - const targetGroup = taskGroups.find(g => g.id === targetGroupId); + setDragState({ + activeTask, + activeGroupId, + }); + }, + [tasks, currentGrouping] + ); - if (!sourceGroup || !targetGroup) return; + // Throttled drag over handler for better performance + const handleDragOver = useCallback( + throttle((event: DragOverEvent) => { + const { active, over } = event; - const sourceIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId); - if (sourceIndex === -1) return; + if (!over || !dragState.activeTask) return; - // Calculate new positions - const finalTargetIndex = targetIndex === -1 ? targetGroup.tasks.length : targetIndex; + const activeTaskId = active.id as string; + const overContainer = over.id as string; - // Create updated task arrays - const updatedSourceTasks = [...sourceGroup.tasks]; - const [movedTask] = updatedSourceTasks.splice(sourceIndex, 1); - - let updatedTargetTasks: IProjectTask[]; - if (sourceGroup.id === targetGroup.id) { - // Moving within the same group - updatedTargetTasks = updatedSourceTasks; - updatedTargetTasks.splice(finalTargetIndex, 0, movedTask); - } else { - // Moving between different groups - updatedTargetTasks = [...targetGroup.tasks]; - updatedTargetTasks.splice(finalTargetIndex, 0, movedTask); - } - - // Dispatch the reorder action - dispatch(reorderTasks({ - activeGroupId: sourceGroup.id, - overGroupId: targetGroup.id, - fromIndex: sourceIndex, - toIndex: finalTargetIndex, - task: movedTask, - updatedSourceTasks, - updatedTargetTasks, - })); - }; - - - - const handleSelectTask = (taskId: string, selected: boolean) => { - setSelectedTaskIds(prev => { - if (selected) { - return [...prev, taskId]; - } else { - return prev.filter(id => id !== taskId); + // Clear any existing timeout + if (dragOverTimeoutRef.current) { + clearTimeout(dragOverTimeoutRef.current); } - }); - }; - const handleToggleSubtasks = (taskId: string) => { + // Optimistic update with throttling + dragOverTimeoutRef.current = setTimeout(() => { + // Only update if we're hovering over a different container + const targetTask = tasks.find(t => t.id === overContainer); + let targetGroupId = overContainer; + + if (targetTask) { + if (currentGrouping === 'status') { + targetGroupId = `status-${targetTask.status}`; + } else if (currentGrouping === 'priority') { + targetGroupId = `priority-${targetTask.priority}`; + } else if (currentGrouping === 'phase') { + targetGroupId = `phase-${targetTask.phase}`; + } + } + + if (targetGroupId !== dragState.activeGroupId) { + // Perform optimistic update for visual feedback + const targetGroup = taskGroups.find(g => g.id === targetGroupId); + if (targetGroup) { + dispatch( + optimisticTaskMove({ + taskId: activeTaskId, + newGroupId: targetGroupId, + newIndex: targetGroup.taskIds.length, + }) + ); + } + } + }, 50); // 50ms throttle for drag over events + }, 50), + [dragState, tasks, taskGroups, currentGrouping, dispatch] + ); + + const handleDragEnd = useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + + // Clear any pending drag over timeouts + if (dragOverTimeoutRef.current) { + clearTimeout(dragOverTimeoutRef.current); + dragOverTimeoutRef.current = null; + } + + // Reset drag state immediately for better UX + const currentDragState = dragState; + setDragState({ + activeTask: null, + activeGroupId: null, + }); + + if (!over || !currentDragState.activeTask || !currentDragState.activeGroupId) { + return; + } + + const activeTaskId = active.id as string; + const overContainer = over.id as string; + + // Parse the group ID to get group type and value - optimized + const parseGroupId = (groupId: string) => { + const [groupType, ...groupValueParts] = groupId.split('-'); + return { + groupType: groupType as 'status' | 'priority' | 'phase', + groupValue: groupValueParts.join('-'), + }; + }; + + // Determine target group + let targetGroupId = overContainer; + let targetIndex = -1; + + // Check if dropping on a task or a group + const targetTask = tasks.find(t => t.id === overContainer); + if (targetTask) { + // Dropping on a task, determine its group + if (currentGrouping === 'status') { + targetGroupId = `status-${targetTask.status}`; + } else if (currentGrouping === 'priority') { + targetGroupId = `priority-${targetTask.priority}`; + } else if (currentGrouping === 'phase') { + targetGroupId = `phase-${targetTask.phase}`; + } + + // Find the index of the target task within its group + const targetGroup = taskGroups.find(g => g.id === targetGroupId); + if (targetGroup) { + targetIndex = targetGroup.taskIds.indexOf(targetTask.id); + } + } + + const sourceGroupInfo = parseGroupId(currentDragState.activeGroupId); + const targetGroupInfo = parseGroupId(targetGroupId); + + // If moving between different groups, update the task's group property + if (currentDragState.activeGroupId !== targetGroupId) { + dispatch( + moveTaskToGroup({ + taskId: activeTaskId, + groupType: targetGroupInfo.groupType, + groupValue: targetGroupInfo.groupValue, + }) + ); + } + + // Handle reordering within the same group or between groups + const sourceGroup = taskGroups.find(g => g.id === currentDragState.activeGroupId); + const targetGroup = taskGroups.find(g => g.id === targetGroupId); + + if (sourceGroup && targetGroup && targetIndex !== -1) { + const sourceIndex = sourceGroup.taskIds.indexOf(activeTaskId); + const finalTargetIndex = targetIndex === -1 ? targetGroup.taskIds.length : targetIndex; + + // Only reorder if actually moving to a different position + if (sourceGroup.id !== targetGroup.id || sourceIndex !== finalTargetIndex) { + // Calculate new order values - simplified + const allTasksInTargetGroup = targetGroup.taskIds.map( + id => tasks.find(t => t.id === id)! + ); + const newOrder = allTasksInTargetGroup.map((task, index) => { + if (index < finalTargetIndex) return task.order; + if (index === finalTargetIndex) return currentDragState.activeTask!.order; + return task.order + 1; + }); + + // Dispatch reorder action + dispatch( + reorderTasks({ + taskIds: [activeTaskId, ...allTasksInTargetGroup.map(t => t.id)], + newOrder: [currentDragState.activeTask!.order, ...newOrder], + }) + ); + } + } + }, + [dragState, tasks, taskGroups, currentGrouping, dispatch] + ); + + const handleSelectTask = useCallback( + (taskId: string, selected: boolean) => { + dispatch(toggleTaskSelection(taskId)); + }, + [dispatch] + ); + + const handleToggleSubtasks = useCallback((taskId: string) => { // Implementation for toggling subtasks console.log('Toggle subtasks for task:', taskId); - }; + }, []); + + // Memoized DragOverlay content for better performance + const dragOverlayContent = useMemo(() => { + if (!dragState.activeTask || !dragState.activeGroupId) return null; + + return ( + + ); + }, [dragState.activeTask, dragState.activeGroupId, projectId, currentGrouping]); + + // Cleanup effect + useEffect(() => { + return () => { + if (dragOverTimeoutRef.current) { + clearTimeout(dragOverTimeoutRef.current); + } + }; + }, []); if (error) { return ( - + ); } return ( -
- {/* Task Filters */} - + - Loading filters...
}> - - - + {/* Task Filters */} +
+ Loading filters...
}> + + +
- {/* Bulk Action Bar */} - {hasSelection && ( - setSelectedTaskIds([])} - /> - )} + {/* Virtualized Task Groups Container */} +
+ {loading ? ( + +
+ +
+
+ ) : taskGroups.length === 0 ? ( + + + + ) : ( +
+ {taskGroups.map((group, index) => { + // Calculate dynamic height for each group + const groupTasks = group.taskIds.length; + const baseHeight = 120; // Header + column headers + add task row + const taskRowsHeight = groupTasks * 40; // 40px per task row + const minGroupHeight = 300; // Minimum height for better visual appearance + const maxGroupHeight = 600; // Increased maximum height per group + const calculatedHeight = baseHeight + taskRowsHeight; + const groupHeight = Math.max( + minGroupHeight, + Math.min(calculatedHeight, maxGroupHeight) + ); - {/* Task Groups Container */} -
- {loadingGroups ? ( - -
- -
-
- ) : taskGroups.length === 0 ? ( - - - - ) : ( - -
- {taskGroups.map((group) => ( - - ))} + return ( + + ); + })}
+ )} +
- - {dragState.activeTask ? ( - - ) : null} - - - )} -
+ + {dragOverlayContent} + +
); }; -export default TaskListBoard; \ No newline at end of file +export default TaskListBoard; diff --git a/worklenz-frontend/src/components/task-management/task-row.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx index fbf392f8..2f278957 100644 --- a/worklenz-frontend/src/components/task-management/task-row.tsx +++ b/worklenz-frontend/src/components/task-management/task-row.tsx @@ -1,34 +1,51 @@ -import React from 'react'; +import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react'; import { useSortable } from '@dnd-kit/sortable'; import { CSS } from '@dnd-kit/utilities'; import { useSelector } from 'react-redux'; -import { Checkbox, Avatar, Tag, Progress, Typography, Space, Button, Tooltip } from 'antd'; +import { Input, Typography } from 'antd'; +import type { InputRef } from 'antd'; import { HolderOutlined, - EyeOutlined, MessageOutlined, PaperClipOutlined, ClockCircleOutlined, } from '@ant-design/icons'; -import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; -import { IGroupBy, COLUMN_KEYS } from '@/features/tasks/tasks.slice'; +import { Task } from '@/types/task-management.types'; import { RootState } from '@/app/store'; - -const { Text } = Typography; +import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tag, Tooltip } from '@/components'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; interface TaskRowProps { - task: IProjectTask; + task: Task; projectId: string; groupId: string; - currentGrouping: IGroupBy; + currentGrouping: 'status' | 'priority' | 'phase'; isSelected: boolean; isDragOverlay?: boolean; index?: number; onSelect?: (taskId: string, selected: boolean) => void; onToggleSubtasks?: (taskId: string) => void; + columns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>; + fixedColumns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>; + scrollableColumns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>; } -const TaskRow: React.FC = ({ +// Priority and status colors - moved outside component to avoid recreation +const PRIORITY_COLORS = { + critical: '#ff4d4f', + high: '#ff7a45', + medium: '#faad14', + low: '#52c41a', +} as const; + +const STATUS_COLORS = { + todo: '#f0f0f0', + doing: '#1890ff', + done: '#52c41a', +} as const; + +const TaskRow: React.FC = React.memo(({ task, projectId, groupId, @@ -38,7 +55,18 @@ const TaskRow: React.FC = ({ index, onSelect, onToggleSubtasks, + columns, + fixedColumns, + scrollableColumns, }) => { + const { socket, connected } = useSocket(); + + // Edit task name state + const [editTaskName, setEditTaskName] = useState(false); + const [taskName, setTaskName] = useState(task.title || ''); + const inputRef = useRef(null); + const wrapperRef = useRef(null); + const { attributes, listeners, @@ -47,7 +75,7 @@ const TaskRow: React.FC = ({ transition, isDragging, } = useSortable({ - id: task.id!, + id: task.id, data: { type: 'task', taskId: task.id, @@ -56,33 +84,135 @@ const TaskRow: React.FC = ({ disabled: isDragOverlay, }); - // Get column visibility from Redux store - const columns = useSelector((state: RootState) => state.taskReducer.columns); - - // Helper function to check if a column is visible - const isColumnVisible = (columnKey: string) => { - const column = columns.find(col => col.key === columnKey); - return column ? column.pinned : true; // Default to visible if column not found - }; + // Get theme from Redux store + const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark'); - const style = { + // Click outside detection for edit mode + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) { + handleTaskNameSave(); + } + }; + + if (editTaskName) { + document.addEventListener('mousedown', handleClickOutside); + inputRef.current?.focus(); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [editTaskName]); + + // Handle task name save + const handleTaskNameSave = useCallback(() => { + const newTaskName = inputRef.current?.input?.value; + if (newTaskName?.trim() !== '' && connected && newTaskName !== task.title) { + socket?.emit( + SocketEvents.TASK_NAME_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + name: newTaskName, + parent_task: null, // Assuming top-level tasks for now + }) + ); + } + setEditTaskName(false); + }, [connected, socket, task.id, task.title]); + + // Memoize style calculations - simplified + const style = useMemo(() => ({ transform: CSS.Transform.toString(transform), transition, opacity: isDragging ? 0.5 : 1, - }; + }), [transform, transition, isDragging]); - const handleSelectChange = (checked: boolean) => { - onSelect?.(task.id!, checked); - }; + // Memoize event handlers to prevent unnecessary re-renders + const handleSelectChange = useCallback((checked: boolean) => { + onSelect?.(task.id, checked); + }, [onSelect, task.id]); - const handleToggleSubtasks = () => { - onToggleSubtasks?.(task.id!); - }; + const handleToggleSubtasks = useCallback(() => { + onToggleSubtasks?.(task.id); + }, [onToggleSubtasks, task.id]); - // Format due date - const formatDueDate = (dateString?: string) => { - if (!dateString) return null; - const date = new Date(dateString); + // Memoize assignees for AvatarGroup to prevent unnecessary re-renders + const avatarGroupMembers = useMemo(() => { + return task.assignee_names || []; + }, [task.assignee_names]); + + // Simplified class name calculations + const containerClasses = useMemo(() => { + const baseClasses = 'border-b transition-all duration-300'; + const themeClasses = isDarkMode + ? 'border-gray-700 bg-gray-900 hover:bg-gray-800' + : 'border-gray-200 bg-white hover:bg-gray-50'; + const selectedClasses = isSelected + ? (isDarkMode ? 'bg-blue-900/20' : 'bg-blue-50') + : ''; + const overlayClasses = isDragOverlay + ? `rounded shadow-lg border-2 ${isDarkMode ? 'bg-gray-900 border-gray-600 shadow-2xl' : 'bg-white border-gray-300 shadow-2xl'}` + : ''; + return `${baseClasses} ${themeClasses} ${selectedClasses} ${overlayClasses}`; + }, [isDarkMode, isSelected, isDragOverlay]); + + const fixedColumnsClasses = useMemo(() => + `flex sticky left-0 z-10 border-r-2 shadow-sm ${isDarkMode ? 'bg-gray-900 border-gray-700' : 'bg-white border-gray-200'}`, + [isDarkMode] + ); + + const taskNameClasses = useMemo(() => { + const baseClasses = 'text-sm font-medium flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300 cursor-pointer'; + const themeClasses = isDarkMode ? 'text-gray-100 hover:text-blue-400' : 'text-gray-900 hover:text-blue-600'; + const completedClasses = task.progress === 100 + ? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}` + : ''; + + return `${baseClasses} ${themeClasses} ${completedClasses}`; + }, [isDarkMode, task.progress]); + + // Get colors - using constants for better performance + const getPriorityColor = useCallback((priority: string) => + PRIORITY_COLORS[priority as keyof typeof PRIORITY_COLORS] || '#d9d9d9', []); + + const getStatusColor = useCallback((status: string) => + STATUS_COLORS[status as keyof typeof STATUS_COLORS] || '#d9d9d9', []); + + // Create adapter for LabelsSelector - memoized + const taskAdapter = useMemo(() => ({ + id: task.id, + name: task.title, + parent_task_id: null, + all_labels: task.labels?.map(label => ({ + id: label.id, + name: label.name, + color_code: label.color + })) || [], + labels: task.labels?.map(label => ({ + id: label.id, + name: label.name, + color_code: label.color + })) || [], + } as any), [task.id, task.title, task.labels]); + + // Create adapter for AssigneeSelector - memoized + const taskAdapterForAssignee = useMemo(() => ({ + id: task.id, + name: task.title, + parent_task_id: null, + assignees: task.assignee_names?.map(member => ({ + team_member_id: member.team_member_id, + id: member.team_member_id, + project_member_id: member.team_member_id, + name: member.name, + })) || [], + } as any), [task.id, task.title, task.assignee_names]); + + // Memoize due date calculation + const dueDate = useMemo(() => { + if (!task.dueDate) return null; + const date = new Date(task.dueDate); const now = new Date(); const diffTime = date.getTime() - now.getTime(); const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); @@ -96,557 +226,238 @@ const TaskRow: React.FC = ({ } else { return { text: `Due ${date.toLocaleDateString()}`, color: 'default' }; } - }; - - const dueDate = formatDueDate(task.end_date); + }, [task.dueDate]); return ( - <> -
-
- {/* Fixed Columns */} -
- {/* Drag Handle */} -
-
- - {/* Selection Checkbox */} -
- handleSelectChange(e.target.checked)} - /> -
- - {/* Task Key */} -
- {task.project_id && task.task_key && ( - - {task.task_key} - - )} -
- - {/* Task Name */} -
-
-
- - {task.name} - - {task.sub_tasks_count && task.sub_tasks_count > 0 && ( +
+
+ {/* Fixed Columns */} +
sum + col.width, 0) || 0, + }} + > + {fixedColumns?.map(col => { + switch (col.key) { + case 'drag': + return ( +
+ ); + case 'select': + return ( +
+ +
+ ); + case 'key': + return ( +
+ - {task.show_sub_tasks ? '−' : '+'} {task.sub_tasks_count} - - )} -
-
-
-
- - {/* Scrollable Columns */} -
- {/* Progress */} - {isColumnVisible(COLUMN_KEYS.PROGRESS) && ( -
- {task.complete_ratio !== undefined && task.complete_ratio >= 0 && ( -
- {percent}%} - /> + {task.task_key} +
- )} -
- )} - - {/* Members */} - {isColumnVisible(COLUMN_KEYS.ASSIGNEES) && ( -
- {task.assignees && task.assignees.length > 0 && ( - - {task.assignees.map((assignee) => ( - - - {assignee.name?.charAt(0)?.toUpperCase()} - - - ))} - - )} -
- )} - - {/* Labels */} - {isColumnVisible(COLUMN_KEYS.LABELS) && ( -
- {task.labels && task.labels.length > 0 && ( -
- {task.labels.slice(0, 3).map((label) => ( - - {label.name} - - ))} - {task.labels.length > 3 && ( - - +{task.labels.length - 3} - - )} -
- )} -
- )} - - {/* Status */} - {isColumnVisible(COLUMN_KEYS.STATUS) && ( -
- {task.status_name && ( -
- {task.status_name} -
- )} -
- )} - - {/* Priority */} - {isColumnVisible(COLUMN_KEYS.PRIORITY) && ( -
- {task.priority_name && ( -
-
- {task.priority_name} -
- )} -
- )} - - {/* Time Tracking */} - {isColumnVisible(COLUMN_KEYS.TIME_TRACKING) && ( -
-
- {task.time_spent_string && ( -
- - {task.time_spent_string} + ); + case 'task': + return ( +
+
+
+
+ {!editTaskName ? ( + setEditTaskName(true)} + className={taskNameClasses} + style={{ cursor: 'pointer' }} + > + {task.title} + + ) : ( + ) => setTaskName(e.target.value)} + onPressEnter={handleTaskNameSave} + className={`${isDarkMode ? 'bg-gray-800 text-gray-100 border-gray-600' : 'bg-white text-gray-900 border-gray-300'}`} + style={{ + width: '100%', + padding: '2px 4px', + fontSize: '14px', + fontWeight: 500, + }} + /> + )} +
+
- )} - {/* Task Indicators */} -
- {task.comments_count && task.comments_count > 0 && ( -
- - {task.comments_count} -
- )} - {task.attachments_count && task.attachments_count > 0 && ( -
- - {task.attachments_count} -
+
+ ); + default: + return null; + } + })} +
+ {/* Scrollable Columns */} +
sum + col.width, 0) || 0 }}> + {scrollableColumns?.map(col => { + switch (col.key) { + case 'progress': + return ( +
+ {task.progress !== undefined && task.progress >= 0 && ( + )}
-
-
- )} -
+ ); + case 'members': + return ( +
+
+ {avatarGroupMembers.length > 0 && ( + + )} + +
+
+ ); + case 'labels': + return ( +
+
+ {task.labels?.map((label, index) => ( + label.end && label.names && label.name ? ( + + ) : ( + + ) + ))} + +
+
+ ); + case 'status': + return ( +
+ + {task.status} + +
+ ); + case 'priority': + return ( +
+
+
+ + {task.priority} + +
+
+ ); + case 'timeTracking': + return ( +
+
+ {task.timeTracking?.logged && task.timeTracking.logged > 0 && ( +
+ + + {typeof task.timeTracking.logged === 'number' + ? `${task.timeTracking.logged}h` + : task.timeTracking.logged + } + +
+ )} +
+
+ ); + default: + return null; + } + })}
- - {/* Subtasks */} - {task.show_sub_tasks && task.sub_tasks && task.sub_tasks.length > 0 && ( -
- {task.sub_tasks.map((subtask) => ( - - ))} -
- )} - - - +
); -}; +}, (prevProps, nextProps) => { + // Simplified comparison for better performance + return ( + prevProps.task.id === nextProps.task.id && + prevProps.task.title === nextProps.task.title && + prevProps.task.progress === nextProps.task.progress && + prevProps.task.status === nextProps.task.status && + prevProps.task.priority === nextProps.task.priority && + prevProps.task.labels?.length === nextProps.task.labels?.length && + prevProps.task.assignee_names?.length === nextProps.task.assignee_names?.length && + prevProps.isSelected === nextProps.isSelected && + prevProps.isDragOverlay === nextProps.isDragOverlay && + prevProps.groupId === nextProps.groupId + ); +}); + +TaskRow.displayName = 'TaskRow'; export default TaskRow; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/virtualized-task-group.tsx b/worklenz-frontend/src/components/task-management/virtualized-task-group.tsx new file mode 100644 index 00000000..70ae7c2c --- /dev/null +++ b/worklenz-frontend/src/components/task-management/virtualized-task-group.tsx @@ -0,0 +1,163 @@ +import React, { useMemo, useCallback } from 'react'; +import { FixedSizeList as List } from 'react-window'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { useSelector } from 'react-redux'; +import { taskManagementSelectors } from '@/features/task-management/task-management.slice'; +import { Task } from '@/types/task-management.types'; +import TaskRow from './task-row'; + +interface VirtualizedTaskGroupProps { + group: any; + projectId: string; + currentGrouping: 'status' | 'priority' | 'phase'; + selectedTaskIds: string[]; + onSelectTask: (taskId: string, selected: boolean) => void; + onToggleSubtasks: (taskId: string) => void; + height: number; + width: number; +} + +const VirtualizedTaskGroup: React.FC = React.memo(({ + group, + projectId, + currentGrouping, + selectedTaskIds, + onSelectTask, + onToggleSubtasks, + height, + width +}) => { + const allTasks = useSelector(taskManagementSelectors.selectAll); + + // Get tasks for this group using memoization for performance + const groupTasks = useMemo(() => { + return group.taskIds + .map((taskId: string) => allTasks.find((task: Task) => task.id === taskId)) + .filter((task: Task | undefined): task is Task => task !== undefined); + }, [group.taskIds, allTasks]); + + const TASK_ROW_HEIGHT = 40; + const GROUP_HEADER_HEIGHT = 40; + const COLUMN_HEADER_HEIGHT = 40; + const ADD_TASK_ROW_HEIGHT = 40; + + // Calculate total height for the group + const totalHeight = GROUP_HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + (groupTasks.length * TASK_ROW_HEIGHT) + ADD_TASK_ROW_HEIGHT; + + // Row renderer for virtualization + const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => { + // Header row + if (index === 0) { + return ( +
+
+
+
+ + {group.title} ({groupTasks.length}) + +
+
+
+
+ ); + } + + // Column headers row + if (index === 1) { + return ( +
+
+
+
+
+
+
+ Key +
+
+ Task +
+
+
+
+ Progress +
+
+ Members +
+
+ Labels +
+
+ Status +
+
+ Priority +
+
+ Time Tracking +
+
+
+
+
+ ); + } + + // Task rows + const taskIndex = index - 2; + if (taskIndex >= 0 && taskIndex < groupTasks.length) { + const task = groupTasks[taskIndex]; + return ( +
+ +
+ ); + } + + // Add task row (last row) + if (taskIndex === groupTasks.length) { + return ( +
+
+
+
+ + Add task +
+
+
+
+ ); + } + + return null; + }, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks]); + + return ( +
+ + + {Row} + + +
+ ); +}); + +export default VirtualizedTaskGroup; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx new file mode 100644 index 00000000..47579249 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/virtualized-task-list.tsx @@ -0,0 +1,416 @@ +import React, { useMemo, useCallback, useEffect, useRef } from 'react'; +import { FixedSizeList as List } from 'react-window'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { useSelector } from 'react-redux'; +import { taskManagementSelectors } from '@/features/task-management/task-management.slice'; +import { Task } from '@/types/task-management.types'; +import TaskRow from './task-row'; +import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row'; + +interface VirtualizedTaskListProps { + group: any; + projectId: string; + currentGrouping: 'status' | 'priority' | 'phase'; + selectedTaskIds: string[]; + onSelectTask: (taskId: string, selected: boolean) => void; + onToggleSubtasks: (taskId: string) => void; + height: number; + width: number; +} + +const VirtualizedTaskList: React.FC = React.memo(({ + group, + projectId, + currentGrouping, + selectedTaskIds, + onSelectTask, + onToggleSubtasks, + height, + width +}) => { + const allTasks = useSelector(taskManagementSelectors.selectAll); + + // Get tasks for this group using memoization for performance + const groupTasks = useMemo(() => { + return group.taskIds + .map((taskId: string) => allTasks.find((task: Task) => task.id === taskId)) + .filter((task: Task | undefined): task is Task => task !== undefined); + }, [group.taskIds, allTasks]); + + const TASK_ROW_HEIGHT = 40; + const HEADER_HEIGHT = 40; + const COLUMN_HEADER_HEIGHT = 40; + + // Calculate the actual height needed for the virtualized list + const actualContentHeight = HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + (groupTasks.length * TASK_ROW_HEIGHT); + const listHeight = Math.min(height - 40, actualContentHeight); + + // Calculate item count - only include actual content + const getItemCount = () => { + return groupTasks.length + 2; // +2 for header and column headers only + }; + + // Debug logging + useEffect(() => { + console.log('VirtualizedTaskList:', { + groupId: group.id, + groupTasks: groupTasks.length, + height, + listHeight, + itemCount: getItemCount(), + isVirtualized: groupTasks.length > 10, // Show if virtualization should be active + minHeight: 300, + maxHeight: 600 + }); + }, [group.id, groupTasks.length, height, listHeight]); + + const scrollContainerRef = useRef(null); + const headerScrollRef = useRef(null); + + // Synchronize header scroll with body scroll + useEffect(() => { + const handleScroll = () => { + if (headerScrollRef.current && scrollContainerRef.current) { + headerScrollRef.current.scrollLeft = scrollContainerRef.current.scrollLeft; + } + }; + const scrollDiv = scrollContainerRef.current; + if (scrollDiv) { + scrollDiv.addEventListener('scroll', handleScroll); + } + return () => { + if (scrollDiv) { + scrollDiv.removeEventListener('scroll', handleScroll); + } + }; + }, []); + + // Define columns array for alignment + const columns = [ + { key: 'drag', label: '', width: 40, fixed: true }, + { key: 'select', label: '', width: 40, fixed: true }, + { key: 'key', label: 'KEY', width: 80, fixed: true }, + { key: 'task', label: 'TASK', width: 475, fixed: true }, + { key: 'progress', label: 'PROGRESS', width: 90 }, + { key: 'members', label: 'MEMBERS', width: 150 }, + { key: 'labels', label: 'LABELS', width: 200 }, + { key: 'status', label: 'STATUS', width: 100 }, + { key: 'priority', label: 'PRIORITY', width: 100 }, + { key: 'timeTracking', label: 'TIME TRACKING', width: 120 }, + ]; + const fixedColumns = columns.filter(col => col.fixed); + const scrollableColumns = columns.filter(col => !col.fixed); + const fixedWidth = fixedColumns.reduce((sum, col) => sum + col.width, 0); + const scrollableWidth = scrollableColumns.reduce((sum, col) => sum + col.width, 0); + const totalTableWidth = fixedWidth + scrollableWidth; + + // Row renderer for virtualization (remove header/column header rows) + const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => { + const task = groupTasks[index]; + if (!task) return null; + return ( +
+ +
+ ); + }, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks]); + + return ( +
+ {/* Group Header */} +
+
+
+ + {group.title} ({groupTasks.length}) + +
+
+
+ {/* Column Headers (sync scroll) */} +
+
+
+ {fixedColumns.map(col => ( +
+ {col.label} +
+ ))} +
+
+ {scrollableColumns.map(col => ( +
+ {col.label} +
+ ))} +
+
+
+ {/* Scrollable List */} +
+ + + {Row} + + +
+ {/* Add Task Row - Always show at the bottom */} +
+ +
+ +
+ ); +}); + +export default VirtualizedTaskList; \ No newline at end of file diff --git a/worklenz-frontend/src/features/board/board-slice.ts b/worklenz-frontend/src/features/board/board-slice.ts index f62ecdab..fbdc98fc 100644 --- a/worklenz-frontend/src/features/board/board-slice.ts +++ b/worklenz-frontend/src/features/board/board-slice.ts @@ -76,6 +76,10 @@ interface BoardState { priorities: string[]; members: string[]; editableSectionId: string | null; + + allTasks: IProjectTask[]; + grouping: string; + totalTasks: number; } const initialState: BoardState = { @@ -98,6 +102,9 @@ const initialState: BoardState = { priorities: [], members: [], editableSectionId: null, + allTasks: [], + grouping: '', + totalTasks: 0, }; const deleteTaskFromGroup = ( @@ -186,7 +193,7 @@ export const fetchBoardTaskGroups = createAsyncThunk( priorities: boardReducer.priorities.join(' '), }; - const response = await tasksApiService.getTaskList(config); + const response = await tasksApiService.getTaskListV3(config); return response.body; } catch (error) { logger.error('Fetch Task Groups', error); @@ -803,7 +810,10 @@ const boardSlice = createSlice({ }) .addCase(fetchBoardTaskGroups.fulfilled, (state, action) => { state.loadingGroups = false; - state.taskGroups = action.payload; + state.taskGroups = action.payload && action.payload.groups ? action.payload.groups : []; + state.allTasks = action.payload && action.payload.allTasks ? action.payload.allTasks : []; + state.grouping = action.payload && action.payload.grouping ? action.payload.grouping : ''; + state.totalTasks = action.payload && action.payload.totalTasks ? action.payload.totalTasks : 0; }) .addCase(fetchBoardTaskGroups.rejected, (state, action) => { state.loadingGroups = false; diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index 6a3a972d..f015d0b0 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -1,10 +1,11 @@ -import { createSlice, createEntityAdapter, PayloadAction } from '@reduxjs/toolkit'; +import { createSlice, createEntityAdapter, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit'; import { Task, TaskManagementState } from '@/types/task-management.types'; import { RootState } from '@/app/store'; +import { tasksApiService, ITaskListConfigV2, ITaskListV3Response } from '@/api/tasks/tasks.api.service'; +import logger from '@/utils/errorLogger'; // Entity adapter for normalized state const tasksAdapter = createEntityAdapter({ - selectId: (task) => task.id, sortComparer: (a, b) => a.order - b.order, }); @@ -13,8 +14,198 @@ const initialState: TaskManagementState = { ids: [], loading: false, error: null, + groups: [], + grouping: null, + selectedPriorities: [], }; +// Async thunk to fetch tasks from API +export const fetchTasks = createAsyncThunk( + 'taskManagement/fetchTasks', + 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.getTaskList(config); + + // Helper function to safely convert time values + const convertTimeValue = (value: any): number => { + if (typeof value === 'number') return value; + if (typeof value === 'string') { + const parsed = parseFloat(value); + return isNaN(parsed) ? 0 : parsed; + } + if (typeof value === 'object' && value !== null) { + // Handle time objects like {hours: 2, minutes: 30} + if ('hours' in value || 'minutes' in value) { + const hours = Number(value.hours || 0); + const minutes = Number(value.minutes || 0); + return hours + (minutes / 60); + } + } + return 0; + }; + + // Create a mapping from status IDs to group names + const statusIdToNameMap: Record = {}; + const priorityIdToNameMap: Record = {}; + + response.body.forEach((group: any) => { + statusIdToNameMap[group.id] = group.name.toLowerCase(); + }); + + // For priority mapping, we need to get priority names from the tasks themselves + // Since the API doesn't provide priority names in the group structure + response.body.forEach((group: any) => { + group.tasks.forEach((task: any) => { + // Map priority value to name (this is an assumption based on common patterns) + if (task.priority_value !== undefined) { + switch (task.priority_value) { + case 0: priorityIdToNameMap[task.priority] = 'low'; break; + case 1: priorityIdToNameMap[task.priority] = 'medium'; break; + case 2: priorityIdToNameMap[task.priority] = 'high'; break; + case 3: priorityIdToNameMap[task.priority] = 'critical'; break; + default: priorityIdToNameMap[task.priority] = 'medium'; + } + } + }); + }); + + // Transform the API response to our Task type + const tasks: Task[] = response.body.flatMap((group: any) => + group.tasks.map((task: any) => ({ + id: task.id, + task_key: task.task_key || '', + title: task.name || '', + description: task.description || '', + status: statusIdToNameMap[task.status] || 'todo', + priority: priorityIdToNameMap[task.priority] || 'medium', + phase: task.phase_name || 'Development', + progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0, + assignees: task.assignees?.map((a: any) => a.team_member_id) || [], + 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, + })) + ); + + return tasks; + } catch (error) { + logger.error('Fetch Tasks', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to fetch tasks'); + } + } +); + +// 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; + + // Get selected labels from taskReducer + const selectedLabels = state.taskReducer.labels + ? state.taskReducer.labels.filter(l => l.selected).map(l => l.id).join(',') + : ''; + + // Get selected assignees from taskReducer + const selectedAssignees = state.taskReducer.taskAssignees + ? state.taskReducer.taskAssignees.filter(m => m.selected).map(m => m.id).join(',') + : ''; + + // Get selected priorities from taskManagement slice + const selectedPriorities = state.taskManagement.selectedPriorities + ? state.taskManagement.selectedPriorities.join(',') + : ''; + + console.log('fetchTasksV3 - selectedPriorities:', selectedPriorities); + + const config: ITaskListConfigV2 = { + id: projectId, + archived: false, + group: currentGrouping, + field: '', + order: '', + search: '', + statuses: '', + members: selectedAssignees, + projects: '', + isSubtasksInclude: false, + labels: selectedLabels, + priorities: selectedPriorities, + }; + + const response = await tasksApiService.getTaskListV3(config); + + // Minimal processing - tasks are already processed by backend + return { + tasks: response.body.allTasks, + groups: response.body.groups, + grouping: response.body.grouping, + totalTasks: response.body.totalTasks + }; + } catch (error) { + logger.error('Fetch Tasks V3', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to fetch tasks'); + } + } +); + +// Refresh task progress separately to avoid slowing down initial load +export const refreshTaskProgress = createAsyncThunk( + 'taskManagement/refreshTaskProgress', + async (projectId: string, { rejectWithValue }) => { + try { + const response = await tasksApiService.refreshTaskProgress(projectId); + return response.body; + } catch (error) { + logger.error('Refresh Task Progress', error); + if (error instanceof Error) { + return rejectWithValue(error.message); + } + return rejectWithValue('Failed to refresh task progress'); + } + } +); + const taskManagementSlice = createSlice({ name: 'taskManagement', initialState: tasksAdapter.getInitialState(initialState), @@ -61,13 +252,19 @@ const taskManagementSlice = createSlice({ tasksAdapter.removeMany(state, action.payload); }, - // Drag and drop operations + // Optimized drag and drop operations reorderTasks: (state, action: PayloadAction<{ taskIds: string[]; newOrder: number[] }>) => { const { taskIds, newOrder } = action.payload; + + // Batch update for better performance const updates = taskIds.map((id, index) => ({ id, - changes: { order: newOrder[index] }, + changes: { + order: newOrder[index], + updatedAt: new Date().toISOString(), + }, })); + tasksAdapter.updateMany(state, updates); }, @@ -89,6 +286,34 @@ const taskManagementSlice = createSlice({ tasksAdapter.updateOne(state, { id: taskId, changes }); }, + // Optimistic update for drag operations - reduces perceived lag + optimisticTaskMove: (state, action: PayloadAction<{ taskId: string; newGroupId: string; newIndex: number }>) => { + const { taskId, newGroupId, newIndex } = action.payload; + const task = state.entities[taskId]; + + if (task) { + // Parse group ID to determine new values + const [groupType, ...groupValueParts] = newGroupId.split('-'); + const groupValue = groupValueParts.join('-'); + + const changes: Partial = { + order: newIndex, + updatedAt: new Date().toISOString(), + }; + + // Update group-specific field + if (groupType === 'status') { + changes.status = groupValue as Task['status']; + } else if (groupType === 'priority') { + changes.priority = groupValue as Task['priority']; + } else if (groupType === 'phase') { + changes.phase = groupValue; + } + + tasksAdapter.updateOne(state, { id: taskId, changes }); + } + }, + // Loading states setLoading: (state, action: PayloadAction) => { state.loading = action.payload; @@ -98,6 +323,54 @@ const taskManagementSlice = createSlice({ state.error = action.payload; state.loading = false; }, + + // Filter actions + setSelectedPriorities: (state, action: PayloadAction) => { + state.selectedPriorities = action.payload; + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchTasks.pending, (state) => { + state.loading = true; + state.error = null; + }) + .addCase(fetchTasks.fulfilled, (state, action) => { + state.loading = false; + state.error = null; + tasksAdapter.setAll(state, action.payload); + }) + .addCase(fetchTasks.rejected, (state, action) => { + state.loading = false; + state.error = action.payload as string || 'Failed to fetch tasks'; + }) + .addCase(fetchTasksV3.pending, (state) => { + state.loading = true; + state.error = null; + }) + .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'; + }); }, }); @@ -110,16 +383,20 @@ export const { bulkDeleteTasks, reorderTasks, moveTaskToGroup, + optimisticTaskMove, setLoading, setError, + setSelectedPriorities, } = taskManagementSlice.actions; +export default taskManagementSlice.reducer; + // Selectors export const taskManagementSelectors = tasksAdapter.getSelectors( (state) => state.taskManagement ); -// Additional selectors +// Enhanced selectors for better performance export const selectTasksByStatus = (state: RootState, status: string) => taskManagementSelectors.selectAll(state).filter(task => task.status === status); @@ -132,4 +409,6 @@ export const selectTasksByPhase = (state: RootState, phase: string) => export const selectTasksLoading = (state: RootState) => state.taskManagement.loading; export const selectTasksError = (state: RootState) => state.taskManagement.error; -export default taskManagementSlice.reducer; \ No newline at end of file +// V3 API selectors - no processing needed, data is pre-processed by backend +export const selectTaskGroupsV3 = (state: RootState) => state.taskManagement.groups; +export const selectCurrentGroupingV3 = (state: RootState) => state.taskManagement.grouping; \ No newline at end of file diff --git a/worklenz-frontend/src/features/tasks/tasks.slice.ts b/worklenz-frontend/src/features/tasks/tasks.slice.ts index 49c85e28..7a6a34dc 100644 --- a/worklenz-frontend/src/features/tasks/tasks.slice.ts +++ b/worklenz-frontend/src/features/tasks/tasks.slice.ts @@ -80,6 +80,9 @@ interface ITaskState { convertToSubtaskDrawerOpen: boolean; customColumns: ITaskListColumn[]; customColumnValues: Record>; + allTasks: IProjectTask[]; + grouping: string; + totalTasks: number; } const initialState: ITaskState = { @@ -105,6 +108,9 @@ const initialState: ITaskState = { convertToSubtaskDrawerOpen: false, customColumns: [], customColumnValues: {}, + allTasks: [], + grouping: '', + totalTasks: 0, }; export const COLUMN_KEYS = { @@ -165,7 +171,7 @@ export const fetchTaskGroups = createAsyncThunk( priorities: taskReducer.priorities.join(' '), }; - const response = await tasksApiService.getTaskList(config); + const response = await tasksApiService.getTaskListV3(config); return response.body; } catch (error) { logger.error('Fetch Task Groups', error); @@ -234,9 +240,9 @@ export const fetchSubTasks = createAsyncThunk( parent_task: taskId, }; try { - const response = await tasksApiService.getTaskList(config); + const response = await tasksApiService.getTaskListV3(config); // Only expand if we actually fetched subtasks - if (response.body.length > 0) { + if (response.body && response.body.groups && response.body.groups.length > 0) { dispatch(toggleTaskRowExpansion(taskId)); } return response.body; @@ -1026,7 +1032,10 @@ const taskSlice = createSlice({ }) .addCase(fetchTaskGroups.fulfilled, (state, action) => { state.loadingGroups = false; - state.taskGroups = action.payload; + state.taskGroups = action.payload && action.payload.groups ? action.payload.groups : []; + state.allTasks = action.payload && action.payload.allTasks ? action.payload.allTasks : []; + state.grouping = action.payload && action.payload.grouping ? action.payload.grouping : ''; + state.totalTasks = action.payload && action.payload.totalTasks ? action.payload.totalTasks : 0; }) .addCase(fetchTaskGroups.rejected, (state, action) => { state.loadingGroups = false; @@ -1035,14 +1044,16 @@ const taskSlice = createSlice({ .addCase(fetchSubTasks.pending, state => { state.error = null; }) - .addCase(fetchSubTasks.fulfilled, (state, action: PayloadAction) => { - if (action.payload.length > 0) { - const taskId = action.payload[0].parent_task_id; + .addCase(fetchSubTasks.fulfilled, (state, action) => { + if (action.payload && action.payload.groups && action.payload.groups.length > 0) { + // Assuming subtasks are in the first group for this context + const subtasks = action.payload.groups[0].tasks; + const taskId = subtasks.length > 0 ? subtasks[0].parent_task_id : null; if (taskId) { for (const group of state.taskGroups) { const task = group.tasks.find(t => t.id === taskId); if (task) { - task.sub_tasks = action.payload; + task.sub_tasks = subtasks; task.show_sub_tasks = true; break; } diff --git a/worklenz-frontend/src/index.css b/worklenz-frontend/src/index.css index 3c1af53d..d6902d25 100644 --- a/worklenz-frontend/src/index.css +++ b/worklenz-frontend/src/index.css @@ -1,5 +1,6 @@ @import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"); @import url("./styles/customOverrides.css"); +@import url("./styles/task-management.css"); @tailwind base; @tailwind components; diff --git a/worklenz-frontend/src/pages/projects/project-view-1/taskList/ProjectViewTaskList.tsx b/worklenz-frontend/src/pages/projects/project-view-1/taskList/ProjectViewTaskList.tsx index a3ec005b..e58d7793 100644 --- a/worklenz-frontend/src/pages/projects/project-view-1/taskList/ProjectViewTaskList.tsx +++ b/worklenz-frontend/src/pages/projects/project-view-1/taskList/ProjectViewTaskList.tsx @@ -1,54 +1,49 @@ import { useEffect } from 'react'; import { Flex } from 'antd'; -import TaskListFilters from './taskListFilters/TaskListFilters'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; -import { fetchTaskGroups } from '@/features/tasks/tasks.slice'; -import { ITaskListConfigV2 } from '@/types/tasks/taskList.types'; -import TanStackTable from '../task-list/task-list-custom'; -import TaskListCustom from '../task-list/task-list-custom'; -import TaskListTableWrapper from '../task-list/task-list-table-wrapper/task-list-table-wrapper'; +import { fetchTasksV3 } from '@/features/task-management/task-management.slice'; +import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; +import TaskListBoard from '@/components/task-management/task-list-board'; const ProjectViewTaskList = () => { - // sample data from task reducer const dispatch = useAppDispatch(); - const { taskGroups, loadingGroups } = useAppSelector(state => state.taskReducer); - const { statusCategories } = useAppSelector(state => state.taskStatusReducer); const projectId = useAppSelector(state => state.projectReducer.projectId); + const { statusCategories } = useAppSelector(state => state.taskStatusReducer); useEffect(() => { if (projectId) { - const config: ITaskListConfigV2 = { - id: projectId, - field: 'id', - order: 'desc', - search: '', - statuses: '', - members: '', - projects: '', - isSubtasksInclude: true, - }; - dispatch(fetchTaskGroups(config)); + // Use the optimized V3 API for faster loading + dispatch(fetchTasksV3(projectId)); } if (!statusCategories.length) { dispatch(fetchStatusesCategories()); } }, [dispatch, projectId]); + // Cleanup effect - reset values when component is destroyed + useEffect(() => { + return () => { + // Clear any selected tasks when component unmounts + dispatch(deselectAll()); + }; + }, [dispatch]); + + if (!projectId) { + return ( + +
No project selected
+
+ ); + } + return ( - - - {taskGroups.map(group => ( - - ))} + ); }; diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/groupTables/TaskGroupList.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/groupTables/TaskGroupList.tsx index e9332c05..201bbff3 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/groupTables/TaskGroupList.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/groupTables/TaskGroupList.tsx @@ -31,7 +31,7 @@ import { SocketEvents } from '@/shared/socket-events'; import logger from '@/utils/errorLogger'; import TaskListTable from '../task-list-table/task-list-table'; import Collapsible from '@/components/collapsible/collapsible'; -import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar'; + import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer'; import { createPortal } from 'react-dom'; @@ -232,7 +232,7 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => { - {createPortal(, document.body, 'bulk-action-container')} + {createPortal( {}} />, document.body, diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx index 0eda5ec7..90e8979a 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-group-wrapper-optimized.tsx @@ -7,7 +7,7 @@ import { ITaskListGroup } from '@/types/tasks/taskList.types'; import { useAppSelector } from '@/hooks/useAppSelector'; import TaskListTableWrapper from './task-list-table/task-list-table-wrapper/task-list-table-wrapper'; -import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar'; + import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer'; import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; @@ -69,7 +69,7 @@ const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOpti /> ))} - {createPortal(, document.body, 'bulk-action-container')} + {createPortal( {}} />, diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx index 6a0e9374..349460b5 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-group-wrapper/task-group-wrapper.tsx @@ -61,7 +61,7 @@ import { import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice'; import TaskListTableWrapper from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper'; -import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar'; + import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer'; import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; @@ -686,7 +686,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => { /> ))} - {createPortal(, document.body, 'bulk-action-container')} + {createPortal( {}} />, diff --git a/worklenz-frontend/src/styles/task-management.css b/worklenz-frontend/src/styles/task-management.css index e1f797c8..82b18b33 100644 --- a/worklenz-frontend/src/styles/task-management.css +++ b/worklenz-frontend/src/styles/task-management.css @@ -213,389 +213,283 @@ outline-offset: 2px; } -/* Dark mode support */ -[data-theme="dark"] .task-list-board { +/* Dark mode support using class-based selectors */ +.dark .task-list-board { background-color: #141414; color: rgba(255, 255, 255, 0.85); } -@media (prefers-color-scheme: dark) { - .task-list-board { - background-color: #141414; - color: rgba(255, 255, 255, 0.85); - } - - /* Task Groups */ - .task-group { - background-color: #1f1f1f; - border-color: #303030; - } - - .task-group.drag-over { - border-color: #1890ff !important; - box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3); - background-color: rgba(24, 144, 255, 0.1); - } - - .task-group .group-header { - background: #262626; - border-bottom-color: #303030; - color: rgba(255, 255, 255, 0.85); - } - - .task-group .group-header:hover { - background: #2f2f2f; - } - - /* Task Rows */ - .task-row { - background-color: #1f1f1f; - color: rgba(255, 255, 255, 0.85); - border-color: #303030; - } - - .task-row:hover { - background-color: #262626 !important; - border-left-color: #595959; - } - - .task-row.selected { - background-color: rgba(24, 144, 255, 0.15) !important; - border-left-color: #1890ff; - } - - .task-row .drag-handle { - color: rgba(255, 255, 255, 0.45); - } - - .task-row .drag-handle:hover { - color: rgba(255, 255, 255, 0.85); - } - - /* Progress bars */ - .ant-progress-bg { - background-color: #303030; - } - - /* Text colors */ - .task-row .ant-typography { - color: rgba(255, 255, 255, 0.85); - } - - .task-row .text-gray-500 { - color: rgba(255, 255, 255, 0.45) !important; - } - - .task-row .text-gray-600 { - color: rgba(255, 255, 255, 0.65) !important; - } - - .task-row .text-gray-400 { - color: rgba(255, 255, 255, 0.45) !important; - } - - /* Completed task styling */ - .task-row .line-through { - color: rgba(255, 255, 255, 0.45); - } - - /* Bulk Action Bar */ - .bulk-action-bar { - background: rgba(24, 144, 255, 0.15); - border-color: rgba(24, 144, 255, 0.3); - color: rgba(255, 255, 255, 0.85); - } - - /* Cards and containers */ - .ant-card { - background-color: #1f1f1f; - border-color: #303030; - color: rgba(255, 255, 255, 0.85); - } - - .ant-card-head { - background-color: #262626; - border-bottom-color: #303030; - color: rgba(255, 255, 255, 0.85); - } - - .ant-card-body { - background-color: #1f1f1f; - color: rgba(255, 255, 255, 0.85); - } - - /* Buttons */ - .ant-btn { - border-color: #303030; - color: rgba(255, 255, 255, 0.85); - } - - .ant-btn:hover { - border-color: #595959; - color: rgba(255, 255, 255, 0.85); - } - - .ant-btn-primary { - background-color: #1890ff; - border-color: #1890ff; - } - - .ant-btn-primary:hover { - background-color: #40a9ff; - border-color: #40a9ff; - } - - /* Dropdowns and menus */ - .ant-dropdown-menu { - background-color: #1f1f1f; - border-color: #303030; - } - - .ant-dropdown-menu-item { - color: rgba(255, 255, 255, 0.85); - } - - .ant-dropdown-menu-item:hover { - background-color: #262626; - } - - /* Select components */ - .ant-select-selector { - background-color: #1f1f1f !important; - border-color: #303030 !important; - color: rgba(255, 255, 255, 0.85) !important; - } - - .ant-select-arrow { - color: rgba(255, 255, 255, 0.45); - } - - /* Checkboxes */ - .ant-checkbox-wrapper { - color: rgba(255, 255, 255, 0.85); - } - - .ant-checkbox-inner { - background-color: #1f1f1f; - border-color: #303030; - } - - .ant-checkbox-checked .ant-checkbox-inner { - background-color: #1890ff; - border-color: #1890ff; - } - - /* Tags and labels */ - .ant-tag { - background-color: #262626; - border-color: #303030; - color: rgba(255, 255, 255, 0.85); - } - - /* Avatars */ - .ant-avatar { - background-color: #595959; - color: rgba(255, 255, 255, 0.85); - } - - /* Tooltips */ - .ant-tooltip-inner { - background-color: #262626; - color: rgba(255, 255, 255, 0.85); - } - - .ant-tooltip-arrow-content { - background-color: #262626; - } - - /* Popconfirm */ - .ant-popover-inner { - background-color: #1f1f1f; - color: rgba(255, 255, 255, 0.85); - } - - .ant-popover-arrow-content { - background-color: #1f1f1f; - } - - /* Subtasks */ - .task-subtasks { - border-left-color: #303030; - } - - .task-subtasks .task-row { - background-color: #141414; - } - - .task-subtasks .task-row:hover { - background-color: #1f1f1f !important; - } - - /* Scrollbars */ - .task-groups-container::-webkit-scrollbar-track { - background: #141414; - } - - .task-groups-container::-webkit-scrollbar-thumb { - background: #595959; - } - - .task-groups-container::-webkit-scrollbar-thumb:hover { - background: #777777; - } - - /* Loading states */ - .ant-spin-dot-item { - background-color: #1890ff; - } - - /* Empty states */ - .ant-empty { - color: rgba(255, 255, 255, 0.45); - } - - .ant-empty-description { - color: rgba(255, 255, 255, 0.45); - } - - /* Focus styles for dark mode */ - .task-row:focus-within { - outline-color: #40a9ff; - } - - .drag-handle:focus { - outline-color: #40a9ff; - } - - /* Border colors */ - .border-gray-100 { - border-color: #303030 !important; - } - - .border-gray-200 { - border-color: #404040 !important; - } - - .border-gray-300 { - border-color: #595959 !important; - } - - /* Background utilities */ - .bg-gray-50 { - background-color: #141414 !important; - } - - .bg-gray-100 { - background-color: #1f1f1f !important; - } - - .bg-white { - background-color: #1f1f1f !important; - } - - /* Due date colors in dark mode */ - .text-red-500 { - color: #ff7875 !important; - } - - .text-orange-500 { - color: #ffa940 !important; - } - - /* Group progress bar in dark mode */ - .task-group .group-header .bg-gray-200 { - background-color: #303030 !important; - } -} - -/* Specific dark mode styles using data-theme attribute */ -[data-theme="dark"] .task-group { +.dark .task-group { background-color: #1f1f1f; border-color: #303030; } -[data-theme="dark"] .task-group.drag-over { +.dark .task-group.drag-over { border-color: #1890ff !important; box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3); background-color: rgba(24, 144, 255, 0.1); } -[data-theme="dark"] .task-group .group-header { +.dark .task-group .group-header { background: #262626; border-bottom-color: #303030; color: rgba(255, 255, 255, 0.85); } -[data-theme="dark"] .task-group .group-header:hover { +.dark .task-group .group-header:hover { background: #2f2f2f; } -[data-theme="dark"] .task-row { +.dark .task-row { background-color: #1f1f1f; color: rgba(255, 255, 255, 0.85); border-color: #303030; } -[data-theme="dark"] .task-row:hover { +.dark .task-row:hover { background-color: #262626 !important; border-left-color: #595959; } -[data-theme="dark"] .task-row.selected { +.dark .task-row.selected { background-color: rgba(24, 144, 255, 0.15) !important; border-left-color: #1890ff; } -[data-theme="dark"] .task-row .drag-handle { +.dark .task-row .drag-handle { color: rgba(255, 255, 255, 0.45); } -[data-theme="dark"] .task-row .drag-handle:hover { +.dark .task-row .drag-handle:hover { color: rgba(255, 255, 255, 0.85); } -[data-theme="dark"] .bulk-action-bar { +.dark .bulk-action-bar { background: rgba(24, 144, 255, 0.15); border-color: rgba(24, 144, 255, 0.3); color: rgba(255, 255, 255, 0.85); } -[data-theme="dark"] .task-row .ant-typography { +.dark .task-row .ant-typography { color: rgba(255, 255, 255, 0.85); } -[data-theme="dark"] .task-row .text-gray-500 { +.dark .task-row .text-gray-500 { color: rgba(255, 255, 255, 0.45) !important; } -[data-theme="dark"] .task-row .text-gray-600 { +.dark .task-row .text-gray-600 { color: rgba(255, 255, 255, 0.65) !important; } -[data-theme="dark"] .task-row .text-gray-400 { +.dark .task-row .text-gray-400 { color: rgba(255, 255, 255, 0.45) !important; } -[data-theme="dark"] .task-row .line-through { +.dark .task-row .line-through { color: rgba(255, 255, 255, 0.45); } -[data-theme="dark"] .task-subtasks { +.dark .ant-card { + background-color: #1f1f1f; + border-color: #303030; +} + +.dark .ant-card-head { + background-color: #262626; + border-bottom-color: #303030; +} + +.dark .ant-card-body { + background-color: #1f1f1f; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-btn { + background-color: #262626; + border-color: #404040; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-btn:hover { + background-color: #2f2f2f; + border-color: #505050; +} + +.dark .ant-btn-primary { + background-color: #1890ff; + border-color: #1890ff; +} + +.dark .ant-btn-primary:hover { + background-color: #40a9ff; + border-color: #40a9ff; +} + +.dark .ant-dropdown-menu { + background-color: #1f1f1f; + border-color: #303030; +} + +.dark .ant-dropdown-menu-item { + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-dropdown-menu-item:hover { + background-color: #262626; +} + +.dark .ant-select-selector { + background-color: #262626 !important; + border-color: #404040 !important; + color: rgba(255, 255, 255, 0.85) !important; +} + +.dark .ant-select-arrow { + color: rgba(255, 255, 255, 0.45); +} + +.dark .ant-checkbox-wrapper { + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-checkbox-inner { + background-color: #262626; + border-color: #404040; +} + +.dark .ant-checkbox-checked .ant-checkbox-inner { + background-color: #1890ff; + border-color: #1890ff; +} + +.dark .ant-tag { + background-color: #262626; + border-color: #404040; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-avatar { + background-color: #404040; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-tooltip-inner { + background-color: #1f1f1f; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-tooltip-arrow-content { + background-color: #1f1f1f; +} + +.dark .ant-popover-inner { + background-color: #1f1f1f; + color: rgba(255, 255, 255, 0.85); +} + +.dark .ant-popover-arrow-content { + background-color: #1f1f1f; +} + +.dark .task-subtasks { border-left-color: #303030; } -[data-theme="dark"] .task-subtasks .task-row { +.dark .task-subtasks .task-row { background-color: #141414; } -[data-theme="dark"] .task-subtasks .task-row:hover { +.dark .task-subtasks .task-row:hover { + background-color: #1a1a1a; +} + +.dark .task-groups-container::-webkit-scrollbar-track { + background-color: #262626; +} + +.dark .task-groups-container::-webkit-scrollbar-thumb { + background-color: #404040; +} + +.dark .task-groups-container::-webkit-scrollbar-thumb:hover { + background-color: #505050; +} + +.dark .ant-spin-dot-item { + background-color: #1890ff; +} + +.dark .ant-empty { + color: rgba(255, 255, 255, 0.45); +} + +.dark .ant-empty-description { + color: rgba(255, 255, 255, 0.45); +} + +.dark .task-row:focus-within { + outline-color: #1890ff; +} + +.dark .drag-handle:focus { + outline-color: #1890ff; +} + +.dark .border-gray-100 { + border-color: #262626 !important; +} + +.dark .border-gray-200 { + border-color: #303030 !important; +} + +.dark .border-gray-300 { + border-color: #404040 !important; +} + +.dark .bg-gray-50 { + background-color: #141414 !important; +} + +.dark .bg-gray-100 { + background-color: #1a1a1a !important; +} + +.dark .bg-white { background-color: #1f1f1f !important; } -[data-theme="dark"] .text-red-500 { +.dark .text-red-500 { color: #ff7875 !important; } -[data-theme="dark"] .text-orange-500 { +.dark .text-orange-500 { color: #ffa940 !important; +} + +.dark .task-group .group-header .bg-gray-200 { + background-color: #262626 !important; +} + +/* System preference fallback */ +@media (prefers-color-scheme: dark) { + .task-list-board:not(.light) { + color: rgba(255, 255, 255, 0.85); + } + + .task-group:not(.light) { + background-color: #1f1f1f; + } + + .task-row:not(.light) { + background-color: #1f1f1f; + color: rgba(255, 255, 255, 0.85); + border-color: #303030; + } + + .task-row:not(.light):hover { + background-color: #262626 !important; + border-left-color: #595959; + } } \ No newline at end of file diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts index dc048c52..bccb3b0c 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -1,5 +1,8 @@ +import { InlineMember } from './teamMembers/inlineMember.types'; + export interface Task { id: string; + task_key: string; title: string; description?: string; status: 'todo' | 'doing' | 'done'; @@ -7,7 +10,8 @@ export interface Task { phase: string; // Custom phases like 'planning', 'development', 'testing', 'deployment' progress: number; // 0-100 assignees: string[]; - labels: string[]; + assignee_names?: InlineMember[]; + labels: Label[]; dueDate?: string; timeTracking: { estimated?: number; @@ -56,6 +60,8 @@ export interface Label { id: string; name: string; color: string; + end?: boolean; + names?: string[]; } // Redux State Interfaces @@ -64,6 +70,9 @@ export interface TaskManagementState { ids: string[]; loading: boolean; error: string | null; + groups: TaskGroup[]; // Pre-processed groups from V3 API + grouping: string | null; // Current grouping from V3 API + selectedPriorities: string[]; // Selected priority filters } export interface TaskGroupsState { diff --git a/worklenz-frontend/vite.config.ts b/worklenz-frontend/vite.config.ts index 425e3d35..a0e74df1 100644 --- a/worklenz-frontend/vite.config.ts +++ b/worklenz-frontend/vite.config.ts @@ -29,7 +29,7 @@ export default defineConfig(({ command, mode }) => { // **Development Server** server: { - port: 3000, + port: 5173, open: true, hmr: { overlay: false, @@ -108,9 +108,6 @@ export default defineConfig(({ command, mode }) => { // **Preserve modules to avoid context issues** preserveEntrySignatures: 'strict', - - // **Ensure proper module interop** - interop: 'auto', }, // **Experimental features for better performance**