diff --git a/worklenz-frontend/src/components/AssigneeSelector.tsx b/worklenz-frontend/src/components/AssigneeSelector.tsx index d4936f40..02ac057c 100644 --- a/worklenz-frontend/src/components/AssigneeSelector.tsx +++ b/worklenz-frontend/src/components/AssigneeSelector.tsx @@ -98,8 +98,8 @@ const AssigneeSelector: React.FC = ({ w-5 h-5 rounded-full border border-dashed flex items-center justify-center transition-colors duration-200 ${isDarkMode - ? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800' - : 'border-gray-300 hover:border-gray-400 hover:bg-gray-100' + ? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400' + : 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600' } `} > @@ -117,7 +117,7 @@ const AssigneeSelector: React.FC = ({ `} > {/* Header */} -
+
= ({ transition-colors duration-200 ${isOpen ? isDarkMode - ? 'border-blue-500 bg-blue-900/20' - : 'border-blue-500 bg-blue-50' + ? 'border-blue-500 bg-blue-900/20 text-blue-400' + : 'border-blue-500 bg-blue-50 text-blue-600' : isDarkMode - ? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800' - : 'border-gray-300 hover:border-gray-400 hover:bg-gray-100' + ? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400' + : 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600' } `} > 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 335ebbef..5793389a 100644 --- a/worklenz-frontend/src/components/task-management/task-group.tsx +++ b/worklenz-frontend/src/components/task-management/task-group.tsx @@ -39,6 +39,23 @@ const GROUP_COLORS = { 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, @@ -121,130 +138,120 @@ const TaskGroup: React.FC = React.memo(({ return (
- {/* Group Header Row */} -
-
-
-
-
-
- - {/* Column Headers */} - {!isCollapsed && totalTasks > 0 && ( -
-
-
-
-
-
- Key -
-
- Task -
-
-
-
- Progress -
-
- Members -
-
- Labels -
-
- Status -
-
- Priority -
-
- Time Tracking +
+
sum + col.width, 0) + SCROLLABLE_COLUMNS.reduce((sum, col) => sum + col.width, 0) }}> + {/* Group Header Row */} +
+
+
+
-
- )} - {/* Tasks List */} - {!isCollapsed && ( -
- {groupTasks.length === 0 ? ( -
-
-
-
- No tasks in this group -
- -
+ {col.label && {col.label}} +
+ ))} +
+
+ {SCROLLABLE_COLUMNS.map(col => ( +
+ {col.label} +
+ ))}
- ) : ( - -
- {groupTasks.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 */} +
+ +
+
+ )} +
+