diff --git a/worklenz-frontend/src/components/AssigneeSelector.tsx b/worklenz-frontend/src/components/AssigneeSelector.tsx new file mode 100644 index 00000000..d4936f40 --- /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..2e987948 --- /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/task-group.tsx b/worklenz-frontend/src/components/task-management/task-group.tsx index dff7eb74..d534beb5 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 } 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; @@ -34,7 +33,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,41 +43,37 @@ const TaskGroup: React.FC = ({ }, }); - // 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 task IDs for sortable context - const taskIds = group.tasks.map(task => task.id!); + // Get all tasks from the store + const allTasks = useSelector(taskManagementSelectors.selectAll); + + // 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]); // 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 completedTasks = useMemo(() => { + return groupTasks.filter(task => task.progress === 100).length; + }, [groupTasks]); + + const totalTasks = groupTasks.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; + 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.groupValue === 'todo' ? '#faad14' : + group.groupValue === 'doing' ? '#1890ff' : '#52c41a'; case 'priority': - return group.id === 'critical' - ? '#ff4d4f' - : group.id === 'high' - ? '#fa8c16' - : group.id === 'medium' - ? '#faad14' - : '#52c41a'; + return group.groupValue === 'critical' ? '#ff4d4f' : + group.groupValue === 'high' ? '#fa8c16' : + group.groupValue === 'medium' ? '#faad14' : '#52c41a'; case 'phase': return '#722ed1'; default: @@ -118,7 +113,7 @@ const TaskGroup: React.FC = ({ className="task-group-header-button" /> - {group.name} ({totalTasks}) + {group.title} ({totalTasks})
@@ -148,36 +143,24 @@ const TaskGroup: React.FC = ({
- {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 -
- )} +
+ Progress +
+
+ Members +
+
+ Labels +
+
+ Status +
+
+ Priority +
+
+ Time Tracking +
@@ -189,7 +172,7 @@ const TaskGroup: React.FC = ({ className="task-group-body" style={{ borderLeft: `4px solid ${getGroupColor()}` }} > - {group.tasks.length === 0 ? ( + {groupTasks.length === 0 ? (
@@ -209,16 +192,16 @@ const TaskGroup: React.FC = ({
) : ( - +
- {group.tasks.map((task, index) => ( + {groupTasks.map((task, index) => ( = ({ projectId, className = '' activeGroupId: null, }); - // Redux selectors - const { - taskGroups, - loadingGroups, - error, - groupBy, - search, - archived, - } = useSelector((state: RootState) => state.taskReducer); + // Enable real-time socket updates for task changes + useTaskSocketHandlers(); - // Selection state - const [selectedTaskIds, setSelectedTaskIds] = useState([]); + // Redux selectors using new task management slices + const tasks = useSelector(taskManagementSelectors.selectAll); + const taskGroups = useSelector(selectTaskGroups); + const currentGrouping = useSelector(selectCurrentGrouping); + const selectedTaskIds = useSelector(selectSelectedTaskIds); + const loading = useSelector((state: RootState) => state.taskManagement.loading); + const error = useSelector((state: RootState) => state.taskManagement.error); // Drag and Drop sensors const sensors = useSensors( @@ -77,24 +87,25 @@ 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 API + dispatch(fetchTasks(projectId)); } - }, [dispatch, projectId, groupBy, search, archived]); + }, [dispatch, projectId, currentGrouping]); // Memoized calculations const allTaskIds = useMemo(() => { - return taskGroups.flatMap(group => group.tasks.map(task => task.id!)); - }, [taskGroups]); + return tasks.map(task => task.id); + }, [tasks]); const totalTasksCount = useMemo(() => { - return taskGroups.reduce((sum, group) => sum + group.tasks.length, 0); - }, [taskGroups]); + return tasks.length; + }, [tasks]); const hasSelection = selectedTaskIds.length > 0; // Handlers - const handleGroupingChange = (newGroupBy: IGroupBy) => { - dispatch(setGroup(newGroupBy)); + const handleGroupingChange = (newGroupBy: typeof currentGrouping) => { + dispatch(setCurrentGrouping(newGroupBy)); }; const handleDragStart = (event: DragStartEvent) => { @@ -102,15 +113,17 @@ const TaskListBoard: React.FC = ({ projectId, className = '' const taskId = active.id as string; // Find the task and its group - let activeTask: IProjectTask | null = null; + const activeTask = tasks.find(t => t.id === taskId) || null; let activeGroupId: string | null = null; - for (const group of taskGroups) { - const task = group.tasks.find(t => t.id === taskId); - if (task) { - activeTask = task; - activeGroupId = group.id; - 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}`; } } @@ -139,71 +152,76 @@ const TaskListBoard: React.FC = ({ projectId, className = '' 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); + // Parse the group ID to get group type and value + 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; - 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; - } + // 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(dragState.activeGroupId); + const targetGroupInfo = parseGroupId(targetGroupId); + + // If moving between different groups, update the task's group property + if (dragState.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 === dragState.activeGroupId); const targetGroup = taskGroups.find(g => g.id === targetGroupId); - if (!sourceGroup || !targetGroup) return; + if (sourceGroup && targetGroup) { + const sourceIndex = sourceGroup.taskIds.indexOf(activeTaskId); + const finalTargetIndex = targetIndex === -1 ? targetGroup.taskIds.length : targetIndex; - const sourceIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId); - if (sourceIndex === -1) return; + // Calculate new order values + 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 dragState.activeTask!.order; + return task.order + 1; + }); - // Calculate new positions - const finalTargetIndex = targetIndex === -1 ? targetGroup.tasks.length : targetIndex; - - // 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 reorder action + dispatch(reorderTasks({ + taskIds: [activeTaskId, ...allTasksInTargetGroup.map(t => t.id)], + newOrder: [dragState.activeTask!.order, ...newOrder] + })); } - - // 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); - } - }); + dispatch(toggleTaskSelection(taskId)); }; const handleToggleSubtasks = (taskId: string) => { @@ -240,15 +258,15 @@ const TaskListBoard: React.FC = ({ projectId, className = '' setSelectedTaskIds([])} + onClearSelection={() => dispatch(clearSelection())} /> )} {/* Task Groups Container */}
- {loadingGroups ? ( + {loading ? (
@@ -275,7 +293,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' key={group.id} group={group} projectId={projectId} - currentGrouping={groupBy} + currentGrouping={currentGrouping} selectedTaskIds={selectedTaskIds} onSelectTask={handleSelectTask} onToggleSubtasks={handleToggleSubtasks} @@ -289,7 +307,7 @@ const TaskListBoard: React.FC = ({ projectId, className = '' task={dragState.activeTask} projectId={projectId} groupId={dragState.activeGroupId!} - currentGrouping={groupBy} + currentGrouping={currentGrouping} isSelected={false} isDragOverlay /> diff --git a/worklenz-frontend/src/components/task-management/task-row.tsx b/worklenz-frontend/src/components/task-management/task-row.tsx index fbf392f8..f02d3120 100644 --- a/worklenz-frontend/src/components/task-management/task-row.tsx +++ b/worklenz-frontend/src/components/task-management/task-row.tsx @@ -1,26 +1,22 @@ -import React from 'react'; +import React, { useMemo, useCallback } 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 { 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'; interface TaskRowProps { - task: IProjectTask; + task: Task; projectId: string; groupId: string; - currentGrouping: IGroupBy; + currentGrouping: 'status' | 'priority' | 'phase'; isSelected: boolean; isDragOverlay?: boolean; index?: number; @@ -28,7 +24,7 @@ interface TaskRowProps { onToggleSubtasks?: (taskId: string) => void; } -const TaskRow: React.FC = ({ +const TaskRow: React.FC = React.memo(({ task, projectId, groupId, @@ -47,7 +43,7 @@ const TaskRow: React.FC = ({ transition, isDragging, } = useSortable({ - id: task.id!, + id: task.id, data: { type: 'task', taskId: task.id, @@ -56,33 +52,32 @@ const TaskRow: React.FC = ({ disabled: isDragOverlay, }); - // Get column visibility from Redux store - const columns = useSelector((state: RootState) => state.taskReducer.columns); + // Get theme from Redux store + const themeMode = useSelector((state: RootState) => state.themeReducer?.mode || 'light'); - // 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 - }; + // Memoize derived values for performance + const isDarkMode = useMemo(() => themeMode === 'dark', [themeMode]); - const style = { + // Memoize style calculations + 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); + // Format due date - memoized for performance + 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 +91,286 @@ const TaskRow: React.FC = ({ } else { return { text: `Due ${date.toLocaleDateString()}`, color: 'default' }; } + }, [task.dueDate]); + + // Memoize assignees for AvatarGroup to prevent unnecessary re-renders + const avatarGroupMembers = useMemo(() => { + return task.assignees?.map(assigneeId => ({ + id: assigneeId, + team_member_id: assigneeId, + name: assigneeId // TODO: Map to actual user names + })) || []; + }, [task.assignees]); + + // Memoize class names for better performance + const containerClassName = useMemo(() => ` + border-b transition-all duration-300 + ${isDarkMode + ? `border-gray-700 bg-gray-900 hover:bg-gray-800 ${isSelected ? 'bg-blue-900/20' : ''}` + : `border-gray-200 bg-white hover:bg-gray-50 ${isSelected ? 'bg-blue-50' : ''}` + } + ${isSelected ? 'border-l-4 border-l-blue-500' : ''} + ${isDragOverlay + ? `rounded shadow-lg ${isDarkMode ? 'bg-gray-900 border border-gray-600' : 'bg-white border border-gray-300'}` + : '' + } + `, [isDarkMode, isSelected, isDragOverlay]); + + const fixedColumnsClassName = 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 taskNameClassName = useMemo(() => ` + text-sm font-medium flex-1 + overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300 + ${isDarkMode ? 'text-gray-100' : 'text-gray-900'} + ${task.progress === 100 + ? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}` + : '' + } + `, [isDarkMode, task.progress]); + + // Get priority color + const getPriorityColor = (priority: string) => { + const colors = { + critical: '#ff4d4f', + high: '#ff7a45', + medium: '#faad14', + low: '#52c41a', + }; + return colors[priority as keyof typeof colors] || '#d9d9d9'; }; - const dueDate = formatDueDate(task.end_date); + // Get status color + const getStatusColor = (status: string) => { + const colors = { + todo: '#f0f0f0', + doing: '#1890ff', + done: '#52c41a', + }; + return colors[status as keyof typeof colors] || '#d9d9d9'; + }; + + // Create adapter for LabelsSelector to work with new Task type + const taskAdapter = useMemo(() => { + // Convert new Task type to IProjectTask for compatibility + return { + id: task.id, + name: task.title, + parent_task_id: null, // TODO: Add parent task support + 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; // Type assertion for compatibility + }, [task.id, task.title, task.labels]); return ( <>
-
+
{/* Fixed Columns */} -
+
{/* Drag Handle */} -
+
{/* Selection Checkbox */} -
+
handleSelectChange(e.target.checked)} + onChange={handleSelectChange} + isDarkMode={isDarkMode} />
{/* Task Key */} -
- {task.project_id && task.task_key && ( - - {task.task_key} - - )} +
+ + {task.task_key} +
{/* Task Name */} -
-
-
- - {task.name} - - {task.sub_tasks_count && task.sub_tasks_count > 0 && ( - - )} +
+
+
+ + {task.title} +
{/* Scrollable Columns */} -
+
{/* Progress */} - {isColumnVisible(COLUMN_KEYS.PROGRESS) && ( -
- {task.complete_ratio !== undefined && task.complete_ratio >= 0 && ( -
- {percent}%} - /> -
- )} -
- )} +
+ {task.progress !== undefined && task.progress >= 0 && ( + + )} +
{/* Members */} - {isColumnVisible(COLUMN_KEYS.ASSIGNEES) && ( -
- {task.assignees && task.assignees.length > 0 && ( - - {task.assignees.map((assignee) => ( - - - {assignee.name?.charAt(0)?.toUpperCase()} - - - ))} - +
+
+ {avatarGroupMembers.length > 0 && ( + )} +
- )} +
{/* 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} - - )} -
- )} +
+
+ {task.labels?.map((label, index) => ( + label.end && label.names && label.name ? ( + + ) : ( + + ) + ))} +
- )} +
{/* Status */} - {isColumnVisible(COLUMN_KEYS.STATUS) && ( -
- {task.status_name && ( -
- {task.status_name} -
- )} -
- )} +
+ + {task.status} + +
{/* Priority */} - {isColumnVisible(COLUMN_KEYS.PRIORITY) && ( -
- {task.priority_name && ( -
-
- {task.priority_name} +
+
+
+ + {task.priority} + +
+
+ + {/* Time Tracking */} +
+
+ {task.timeTracking?.logged && task.timeTracking.logged > 0 && ( +
+ + + {typeof task.timeTracking.logged === 'number' + ? `${task.timeTracking.logged}h` + : task.timeTracking.logged + } +
)}
- )} - - {/* Time Tracking */} - {isColumnVisible(COLUMN_KEYS.TIME_TRACKING) && ( -
-
- {task.time_spent_string && ( -
- - {task.time_spent_string} -
- )} - {/* Task Indicators */} -
- {task.comments_count && task.comments_count > 0 && ( -
- - {task.comments_count} -
- )} - {task.attachments_count && task.attachments_count > 0 && ( -
- - {task.attachments_count} -
- )} -
-
-
- )} +
- - {/* Subtasks */} - {task.show_sub_tasks && task.sub_tasks && task.sub_tasks.length > 0 && ( -
- {task.sub_tasks.map((subtask) => ( - - ))} -
- )} - - ); -}; +}, (prevProps, nextProps) => { + // Custom comparison function for React.memo + // Only re-render if these specific props change + const labelsEqual = prevProps.task.labels.length === nextProps.task.labels.length && + prevProps.task.labels.every((label, index) => + label.id === nextProps.task.labels[index]?.id && + label.name === nextProps.task.labels[index]?.name && + label.color === nextProps.task.labels[index]?.color && + label.end === nextProps.task.labels[index]?.end && + JSON.stringify(label.names) === JSON.stringify(nextProps.task.labels[index]?.names) + ); + + return ( + prevProps.task.id === nextProps.task.id && + prevProps.task.assignees === nextProps.task.assignees && + prevProps.task.title === nextProps.task.title && + prevProps.task.progress === nextProps.task.progress && + prevProps.task.status === nextProps.task.status && + prevProps.task.priority === nextProps.task.priority && + labelsEqual && + 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/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index 6a3a972d..3c5f6298 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 } 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, }); @@ -15,6 +16,91 @@ const initialState: TaskManagementState = { error: null, }; +// 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; + }; + + // 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: task.status_name?.toLowerCase() || 'todo', + priority: task.priority_name?.toLowerCase() || '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) || [], + 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'); + } + } +); + const taskManagementSlice = createSlice({ name: 'taskManagement', initialState: tasksAdapter.getInitialState(initialState), @@ -99,6 +185,22 @@ const taskManagementSlice = createSlice({ state.loading = false; }, }, + 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; + }); + }, }); export const { diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts index dc048c52..e8204805 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -1,5 +1,6 @@ export interface Task { id: string; + task_key: string; title: string; description?: string; status: 'todo' | 'doing' | 'done'; @@ -7,7 +8,7 @@ export interface Task { phase: string; // Custom phases like 'planning', 'development', 'testing', 'deployment' progress: number; // 0-100 assignees: string[]; - labels: string[]; + labels: Label[]; dueDate?: string; timeTracking: { estimated?: number; @@ -56,6 +57,8 @@ export interface Label { id: string; name: string; color: string; + end?: boolean; + names?: string[]; } // Redux State Interfaces 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**