feat(task-management): enhance task filtering and UI components for improved usability
- Updated AssigneeSelector and LabelsSelector components to include text color adjustments for better visibility in dark mode. - Introduced ImprovedTaskFilters component for a more efficient task filtering experience, integrating Redux state management for selected priorities and labels. - Refactored task management slice to support new filtering capabilities, including selected priorities and improved task fetching logic. - Enhanced TaskGroup and TaskRow components to accommodate new filtering features and improve overall layout consistency.
This commit is contained in:
@@ -98,8 +98,8 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
|
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
|
||||||
transition-colors duration-200
|
transition-colors duration-200
|
||||||
${isDarkMode
|
${isDarkMode
|
||||||
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800'
|
? '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'
|
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
@@ -117,7 +117,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
|||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="p-2 border-b border-gray-200 dark:border-gray-600">
|
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
||||||
<input
|
<input
|
||||||
ref={searchInputRef}
|
ref={searchInputRef}
|
||||||
type="text"
|
type="text"
|
||||||
|
|||||||
@@ -152,11 +152,11 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({
|
|||||||
transition-colors duration-200
|
transition-colors duration-200
|
||||||
${isOpen
|
${isOpen
|
||||||
? isDarkMode
|
? isDarkMode
|
||||||
? 'border-blue-500 bg-blue-900/20'
|
? 'border-blue-500 bg-blue-900/20 text-blue-400'
|
||||||
: 'border-blue-500 bg-blue-50'
|
: 'border-blue-500 bg-blue-50 text-blue-600'
|
||||||
: isDarkMode
|
: isDarkMode
|
||||||
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800'
|
? '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'
|
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className={`relative ${className}`} ref={dropdownRef}>
|
||||||
|
{/* Trigger Button */}
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md
|
||||||
|
border transition-all duration-200 ease-in-out
|
||||||
|
${selectedCount > 0
|
||||||
|
? (isDarkMode ? 'bg-blue-600 text-white border-blue-500' : 'bg-blue-50 text-blue-800 border-blue-300 font-semibold')
|
||||||
|
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
|
||||||
|
}
|
||||||
|
hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1
|
||||||
|
${themeClasses.containerBg === 'bg-gray-800' ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'}
|
||||||
|
`}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<IconComponent className="w-3.5 h-3.5" />
|
||||||
|
<span>{section.label}</span>
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<span className="inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-blue-500 rounded-full">
|
||||||
|
{selectedCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<DownOutlined
|
||||||
|
className={`w-3.5 h-3.5 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Panel */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className={`absolute top-full left-0 z-50 mt-1 w-64 ${themeClasses.dropdownBg} rounded-md shadow-lg border ${themeClasses.dropdownBorder}`}>
|
||||||
|
{/* Search Input */}
|
||||||
|
{section.searchable && (
|
||||||
|
<div className={`p-2 border-b ${themeClasses.dividerBorder}`}>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<SearchOutlined className="absolute left-2.5 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => 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'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Options List */}
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{filteredOptions.length === 0 ? (
|
||||||
|
<div className={`p-2 text-xs text-center ${themeClasses.secondaryText}`}>
|
||||||
|
No options found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-0.5">
|
||||||
|
{filteredOptions.map((option) => {
|
||||||
|
const isSelected = section.selectedValues.includes(option.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
onClick={() => handleOptionToggle(option.value)}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded
|
||||||
|
transition-colors duration-150 text-left
|
||||||
|
${isSelected
|
||||||
|
? (isDarkMode ? 'bg-blue-600 text-white' : 'bg-blue-50 text-blue-800 font-semibold')
|
||||||
|
: `${themeClasses.optionText} ${themeClasses.optionHover}`
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Checkbox/Radio indicator */}
|
||||||
|
<div className={`
|
||||||
|
flex items-center justify-center w-3.5 h-3.5 border rounded
|
||||||
|
${isSelected
|
||||||
|
? 'bg-blue-500 border-blue-500 text-white'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
{isSelected && <CheckOutlined className="w-2.5 h-2.5" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color indicator */}
|
||||||
|
{option.color && (
|
||||||
|
<div
|
||||||
|
className="w-2.5 h-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: option.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
{option.avatar && (
|
||||||
|
<div className="w-5 h-5 bg-gray-300 rounded-full flex items-center justify-center text-xs font-medium text-gray-700 dark:bg-gray-600 dark:text-gray-300">
|
||||||
|
<img
|
||||||
|
src={option.avatar}
|
||||||
|
alt={option.label}
|
||||||
|
className="w-5 h-5 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Label and Count */}
|
||||||
|
<div className="flex-1 flex items-center justify-between">
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
{option.count !== undefined && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{option.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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<HTMLInputElement>(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 (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
{!isExpanded ? (
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md border transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 ${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText} ${
|
||||||
|
themeClasses.containerBg === 'bg-gray-800' ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SearchOutlined className="w-3.5 h-3.5" />
|
||||||
|
<span>Search</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<SearchOutlined className="absolute left-2.5 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={localValue}
|
||||||
|
onChange={(e) => 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 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
className={`absolute right-1.5 top-1/2 transform -translate-y-1/2 ${themeClasses.secondaryText} hover:${themeClasses.optionText} transition-colors duration-150`}
|
||||||
|
>
|
||||||
|
<CloseOutlined className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-2.5 py-1.5 text-xs font-medium text-white bg-blue-500 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded(false)}
|
||||||
|
className={`px-2.5 py-1.5 text-xs font-medium transition-colors duration-200 ${themeClasses.secondaryText} hover:${themeClasses.optionText}`}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main Component
|
||||||
|
const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||||
|
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<FilterSection[]>([]);
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [openDropdown, setOpenDropdown] = useState<string | null>(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 (
|
||||||
|
<div className={`${themeClasses.containerBg} border ${themeClasses.containerBorder} rounded-md p-3 shadow-sm ${className}`}>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Left Section - Main Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Search */}
|
||||||
|
<SearchFilter
|
||||||
|
value={searchValue}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
placeholder="Search tasks..."
|
||||||
|
themeClasses={themeClasses}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filter Dropdowns - Only render when data is loaded */}
|
||||||
|
{isDataLoaded ? (
|
||||||
|
filterSectionsData.map((section) => (
|
||||||
|
<FilterDropdown
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
isOpen={openDropdown === section.id}
|
||||||
|
onToggle={() => handleDropdownToggle(section.id)}
|
||||||
|
themeClasses={themeClasses}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
// Loading state
|
||||||
|
<div className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${themeClasses.secondaryText}`}>
|
||||||
|
<div className="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-blue-500"></div>
|
||||||
|
<span>Loading filters...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Section - Additional Controls */}
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
{/* Active Filters Indicator */}
|
||||||
|
{activeFiltersCount > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={`text-xs ${themeClasses.secondaryText}`}>
|
||||||
|
{activeFiltersCount} filter{activeFiltersCount !== 1 ? 's' : ''} active
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={clearAllFilters}
|
||||||
|
className={`text-xs text-blue-600 hover:text-blue-700 font-medium transition-colors duration-150 ${
|
||||||
|
isDarkMode ? 'text-blue-400 hover:text-blue-300' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show Archived Toggle (for list view) */}
|
||||||
|
{position === 'list' && (
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showArchived}
|
||||||
|
onChange={toggleArchived}
|
||||||
|
className={`w-3.5 h-3.5 text-blue-600 rounded focus:ring-blue-500 transition-colors duration-150 ${
|
||||||
|
isDarkMode
|
||||||
|
? 'border-gray-600 bg-gray-700 focus:ring-offset-gray-800'
|
||||||
|
: 'border-gray-300 bg-white focus:ring-offset-white'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className={`text-xs ${themeClasses.optionText}`}>
|
||||||
|
Show archived
|
||||||
|
</span>
|
||||||
|
<InboxOutlined className={`w-3.5 h-3.5 ${themeClasses.secondaryText}`} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show Fields Button (for list view) */}
|
||||||
|
{position === 'list' && (
|
||||||
|
<button className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md border transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 ${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText} ${
|
||||||
|
isDarkMode ? 'focus:ring-offset-gray-800' : 'focus:ring-offset-white'
|
||||||
|
}`}>
|
||||||
|
<EyeOutlined className="w-3.5 h-3.5" />
|
||||||
|
<span>Fields</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Filters Pills */}
|
||||||
|
{activeFiltersCount > 0 && (
|
||||||
|
<div className={`flex flex-wrap items-center gap-1.5 mt-2 pt-2 border-t ${themeClasses.dividerBorder}`}>
|
||||||
|
{searchValue && (
|
||||||
|
<div className={`inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-medium rounded-full ${themeClasses.pillActiveBg} ${themeClasses.pillActiveText}`}>
|
||||||
|
<SearchOutlined className="w-2.5 h-2.5" />
|
||||||
|
<span>"{searchValue}"</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchValue('')}
|
||||||
|
className={`ml-1 rounded-full p-0.5 transition-colors duration-150 ${
|
||||||
|
isDarkMode ? 'hover:bg-blue-800' : 'hover:bg-blue-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CloseOutlined className="w-2.5 h-2.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 (
|
||||||
|
<div
|
||||||
|
key={`${section.id}-${value}`}
|
||||||
|
className={`inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-medium rounded-full ${themeClasses.pillBg} ${themeClasses.pillText}`}
|
||||||
|
>
|
||||||
|
{option.color && (
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: option.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newValues = section.selectedValues.filter(v => v !== value);
|
||||||
|
handleSelectionChange(section.id, newValues);
|
||||||
|
}}
|
||||||
|
className={`ml-1 rounded-full p-0.5 transition-colors duration-150 ${
|
||||||
|
isDarkMode ? 'hover:bg-gray-600' : 'hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CloseOutlined className="w-2.5 h-2.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(ImprovedTaskFilters);
|
||||||
@@ -39,6 +39,23 @@ const GROUP_COLORS = {
|
|||||||
default: '#d9d9d9',
|
default: '#d9d9d9',
|
||||||
} as const;
|
} 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<TaskGroupProps> = React.memo(({
|
const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
||||||
group,
|
group,
|
||||||
projectId,
|
projectId,
|
||||||
@@ -121,130 +138,120 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
className={`task-group`}
|
||||||
className={`task-group ${isOver ? 'drag-over' : ''}`}
|
style={{ ...containerStyle, overflowX: 'unset' }}
|
||||||
style={containerStyle}
|
|
||||||
>
|
>
|
||||||
{/* Group Header Row */}
|
<div className="task-group-scroll-wrapper" style={{ overflowX: 'auto', width: '100%' }}>
|
||||||
<div className="task-group-header">
|
<div style={{ minWidth: FIXED_COLUMNS.reduce((sum, col) => sum + col.width, 0) + SCROLLABLE_COLUMNS.reduce((sum, col) => sum + col.width, 0) }}>
|
||||||
<div className="task-group-header-row">
|
{/* Group Header Row */}
|
||||||
<div
|
<div className="task-group-header">
|
||||||
className="task-group-header-content"
|
<div className="task-group-header-row">
|
||||||
style={{ backgroundColor: groupColor }}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
icon={isCollapsed ? <RightOutlined /> : <DownOutlined />}
|
|
||||||
onClick={handleToggleCollapse}
|
|
||||||
className="task-group-header-button"
|
|
||||||
/>
|
|
||||||
<Text strong className="task-group-header-text">
|
|
||||||
{group.title} ({totalTasks})
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Column Headers */}
|
|
||||||
{!isCollapsed && totalTasks > 0 && (
|
|
||||||
<div
|
|
||||||
className="task-group-column-headers"
|
|
||||||
style={{ borderLeft: `4px solid ${groupColor}` }}
|
|
||||||
>
|
|
||||||
<div className="task-group-column-headers-row">
|
|
||||||
<div className="task-table-fixed-columns">
|
|
||||||
<div
|
<div
|
||||||
className="task-table-cell task-table-header-cell"
|
className="task-group-header-content"
|
||||||
style={{ width: '40px' }}
|
style={{ backgroundColor: groupColor }}
|
||||||
></div>
|
>
|
||||||
<div
|
<Button
|
||||||
className="task-table-cell task-table-header-cell"
|
type="text"
|
||||||
style={{ width: '40px' }}
|
size="small"
|
||||||
></div>
|
icon={isCollapsed ? <RightOutlined /> : <DownOutlined />}
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '80px' }}>
|
onClick={handleToggleCollapse}
|
||||||
<Text className="column-header-text">Key</Text>
|
className="task-group-header-button"
|
||||||
</div>
|
/>
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '475px' }}>
|
<Text strong className="task-group-header-text">
|
||||||
<Text className="column-header-text">Task</Text>
|
{group.title} ({totalTasks})
|
||||||
</div>
|
</Text>
|
||||||
</div>
|
|
||||||
<div className="task-table-scrollable-columns">
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '90px' }}>
|
|
||||||
<Text className="column-header-text">Progress</Text>
|
|
||||||
</div>
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '150px' }}>
|
|
||||||
<Text className="column-header-text">Members</Text>
|
|
||||||
</div>
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '200px' }}>
|
|
||||||
<Text className="column-header-text">Labels</Text>
|
|
||||||
</div>
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
|
||||||
<Text className="column-header-text">Status</Text>
|
|
||||||
</div>
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
|
||||||
<Text className="column-header-text">Priority</Text>
|
|
||||||
</div>
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '120px' }}>
|
|
||||||
<Text className="column-header-text">Time Tracking</Text>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tasks List */}
|
{/* Column Headers */}
|
||||||
{!isCollapsed && (
|
{!isCollapsed && totalTasks > 0 && (
|
||||||
<div
|
<div
|
||||||
className="task-group-body"
|
className="task-group-column-headers"
|
||||||
style={{ borderLeft: `4px solid ${groupColor}` }}
|
style={{ borderLeft: `4px solid ${groupColor}` }}
|
||||||
>
|
>
|
||||||
{groupTasks.length === 0 ? (
|
<div className="task-group-column-headers-row">
|
||||||
<div className="task-group-empty">
|
<div className="task-table-fixed-columns">
|
||||||
<div className="task-table-fixed-columns">
|
{FIXED_COLUMNS.map(col => (
|
||||||
<div style={{ width: '380px', padding: '20px 12px' }}>
|
<div
|
||||||
<div className="text-center text-gray-500">
|
key={col.key}
|
||||||
<Text type="secondary">No tasks in this group</Text>
|
className="task-table-cell task-table-header-cell"
|
||||||
<br />
|
style={{ width: col.width }}
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={handleAddTask}
|
|
||||||
className="mt-2"
|
|
||||||
>
|
>
|
||||||
Add first task
|
{col.label && <Text className="column-header-text">{col.label}</Text>}
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="task-table-scrollable-columns">
|
||||||
|
{SCROLLABLE_COLUMNS.map(col => (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
className="task-table-cell task-table-header-cell"
|
||||||
|
style={{ width: col.width }}
|
||||||
|
>
|
||||||
|
<Text className="column-header-text">{col.label}</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<SortableContext items={group.taskIds} strategy={verticalListSortingStrategy}>
|
|
||||||
<div className="task-group-tasks">
|
|
||||||
{groupTasks.map((task, index) => (
|
|
||||||
<TaskRow
|
|
||||||
key={task.id}
|
|
||||||
task={task}
|
|
||||||
projectId={projectId}
|
|
||||||
groupId={group.id}
|
|
||||||
currentGrouping={currentGrouping}
|
|
||||||
isSelected={selectedTaskIds.includes(task.id)}
|
|
||||||
index={index}
|
|
||||||
onSelect={onSelectTask}
|
|
||||||
onToggleSubtasks={onToggleSubtasks}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Task Row - Always show when not collapsed */}
|
{/* Tasks List */}
|
||||||
<div className="task-group-add-task">
|
{!isCollapsed && (
|
||||||
<AddTaskListRow groupId={group.id} />
|
<div
|
||||||
</div>
|
className="task-group-body"
|
||||||
</div>
|
style={{ borderLeft: `4px solid ${groupColor}` }}
|
||||||
)}
|
>
|
||||||
|
{groupTasks.length === 0 ? (
|
||||||
|
<div className="task-group-empty">
|
||||||
|
<div className="task-table-fixed-columns">
|
||||||
|
<div style={{ width: '380px', padding: '20px 12px' }}>
|
||||||
|
<div className="text-center text-gray-500">
|
||||||
|
<Text type="secondary">No tasks in this group</Text>
|
||||||
|
<br />
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleAddTask}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
Add first task
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SortableContext items={group.taskIds} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="task-group-tasks">
|
||||||
|
{groupTasks.map((task, index) => (
|
||||||
|
<TaskRow
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
projectId={projectId}
|
||||||
|
groupId={group.id}
|
||||||
|
currentGrouping={currentGrouping}
|
||||||
|
isSelected={selectedTaskIds.includes(task.id)}
|
||||||
|
index={index}
|
||||||
|
onSelect={onSelectTask}
|
||||||
|
onToggleSubtasks={onToggleSubtasks}
|
||||||
|
fixedColumns={FIXED_COLUMNS}
|
||||||
|
scrollableColumns={SCROLLABLE_COLUMNS}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Task Row - Always show when not collapsed */}
|
||||||
|
<div className="task-group-add-task">
|
||||||
|
<AddTaskListRow groupId={group.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<style>{`
|
<style>{`
|
||||||
.task-group {
|
.task-group {
|
||||||
border: 1px solid var(--task-border-primary, #e8e8e8);
|
border: 1px solid var(--task-border-primary, #e8e8e8);
|
||||||
@@ -252,7 +259,6 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
background: var(--task-bg-primary, white);
|
background: var(--task-bg-primary, white);
|
||||||
box-shadow: 0 1px 3px var(--task-shadow, rgba(0, 0, 0, 0.1));
|
box-shadow: 0 1px 3px var(--task-shadow, rgba(0, 0, 0, 0.1));
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -268,14 +274,14 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.task-group-header-row {
|
.task-group-header-row {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-group-header-content {
|
.task-group-header-content {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 6px 6px 0 0;
|
border-radius: 6px 6px 0 0;
|
||||||
@@ -332,7 +338,6 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
|||||||
max-height: 40px;
|
max-height: 40px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 1200px; /* Ensure minimum width for all columns */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-table-header-cell {
|
.task-table-header-cell {
|
||||||
@@ -397,11 +402,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
|||||||
.task-table-fixed-columns {
|
.task-table-fixed-columns {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--task-bg-secondary, #f5f5f5);
|
background: var(--task-bg-secondary, #f5f5f5);
|
||||||
position: sticky;
|
|
||||||
left: 0;
|
|
||||||
z-index: 11;
|
|
||||||
border-right: 2px solid var(--task-border-primary, #e8e8e8);
|
border-right: 2px solid var(--task-border-primary, #e8e8e8);
|
||||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
|
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ import {
|
|||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import {
|
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { Card, Spin, Empty } from 'antd';
|
import { Card, Spin, Empty } from 'antd';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import {
|
import {
|
||||||
@@ -26,27 +24,29 @@ import {
|
|||||||
fetchTasks,
|
fetchTasks,
|
||||||
fetchTasksV3,
|
fetchTasksV3,
|
||||||
selectTaskGroupsV3,
|
selectTaskGroupsV3,
|
||||||
selectCurrentGroupingV3
|
selectCurrentGroupingV3,
|
||||||
} from '@/features/task-management/task-management.slice';
|
} from '@/features/task-management/task-management.slice';
|
||||||
import {
|
import {
|
||||||
selectTaskGroups,
|
selectTaskGroups,
|
||||||
selectCurrentGrouping,
|
selectCurrentGrouping,
|
||||||
setCurrentGrouping
|
setCurrentGrouping,
|
||||||
} from '@/features/task-management/grouping.slice';
|
} from '@/features/task-management/grouping.slice';
|
||||||
import {
|
import {
|
||||||
selectSelectedTaskIds,
|
selectSelectedTaskIds,
|
||||||
toggleTaskSelection,
|
toggleTaskSelection,
|
||||||
clearSelection
|
clearSelection,
|
||||||
} from '@/features/task-management/selection.slice';
|
} from '@/features/task-management/selection.slice';
|
||||||
import { Task } from '@/types/task-management.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
import TaskRow from './task-row';
|
import TaskRow from './task-row';
|
||||||
import BulkActionBar from './bulk-action-bar';
|
// import BulkActionBar from './bulk-action-bar';
|
||||||
import VirtualizedTaskList from './virtualized-task-list';
|
import VirtualizedTaskList from './virtualized-task-list';
|
||||||
import { AppDispatch } from '@/app/store';
|
import { AppDispatch } from '@/app/store';
|
||||||
|
|
||||||
// Import the TaskListFilters component
|
// Import the improved TaskListFilters component
|
||||||
const TaskListFilters = React.lazy(() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters'));
|
const ImprovedTaskFilters = React.lazy(
|
||||||
|
() => import('./improved-task-filters')
|
||||||
|
);
|
||||||
|
|
||||||
interface TaskListBoardProps {
|
interface TaskListBoardProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -71,10 +71,13 @@ const throttle = <T extends (...args: any[]) => void>(func: T, delay: number): T
|
|||||||
lastExecTime = currentTime;
|
lastExecTime = currentTime;
|
||||||
} else {
|
} else {
|
||||||
if (timeoutId) clearTimeout(timeoutId);
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
timeoutId = setTimeout(() => {
|
timeoutId = setTimeout(
|
||||||
func(...args);
|
() => {
|
||||||
lastExecTime = Date.now();
|
func(...args);
|
||||||
}, delay - (currentTime - lastExecTime));
|
lastExecTime = Date.now();
|
||||||
|
},
|
||||||
|
delay - (currentTime - lastExecTime)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}) as T;
|
}) as T;
|
||||||
};
|
};
|
||||||
@@ -130,56 +133,131 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const hasSelection = selectedTaskIds.length > 0;
|
const hasSelection = selectedTaskIds.length > 0;
|
||||||
|
|
||||||
// Memoized handlers for better performance
|
// Memoized handlers for better performance
|
||||||
const handleGroupingChange = useCallback((newGroupBy: 'status' | 'priority' | 'phase') => {
|
const handleGroupingChange = useCallback(
|
||||||
dispatch(setCurrentGrouping(newGroupBy));
|
(newGroupBy: 'status' | 'priority' | 'phase') => {
|
||||||
}, [dispatch]);
|
dispatch(setCurrentGrouping(newGroupBy));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
const handleDragStart = useCallback(
|
||||||
const { active } = event;
|
(event: DragStartEvent) => {
|
||||||
const taskId = active.id as string;
|
const { active } = event;
|
||||||
|
const taskId = active.id as string;
|
||||||
|
|
||||||
// Find the task and its group
|
// Find the task and its group
|
||||||
const activeTask = tasks.find(t => t.id === taskId) || null;
|
const activeTask = tasks.find(t => t.id === taskId) || null;
|
||||||
let activeGroupId: string | null = null;
|
let activeGroupId: string | null = null;
|
||||||
|
|
||||||
if (activeTask) {
|
if (activeTask) {
|
||||||
// Determine group ID based on current grouping
|
// Determine group ID based on current grouping
|
||||||
if (currentGrouping === 'status') {
|
if (currentGrouping === 'status') {
|
||||||
activeGroupId = `status-${activeTask.status}`;
|
activeGroupId = `status-${activeTask.status}`;
|
||||||
} else if (currentGrouping === 'priority') {
|
} else if (currentGrouping === 'priority') {
|
||||||
activeGroupId = `priority-${activeTask.priority}`;
|
activeGroupId = `priority-${activeTask.priority}`;
|
||||||
} else if (currentGrouping === 'phase') {
|
} else if (currentGrouping === 'phase') {
|
||||||
activeGroupId = `phase-${activeTask.phase}`;
|
activeGroupId = `phase-${activeTask.phase}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
setDragState({
|
setDragState({
|
||||||
activeTask,
|
activeTask,
|
||||||
activeGroupId,
|
activeGroupId,
|
||||||
});
|
});
|
||||||
}, [tasks, currentGrouping]);
|
},
|
||||||
|
[tasks, currentGrouping]
|
||||||
|
);
|
||||||
|
|
||||||
// Throttled drag over handler for better performance
|
// Throttled drag over handler for better performance
|
||||||
const handleDragOver = useCallback(throttle((event: DragOverEvent) => {
|
const handleDragOver = useCallback(
|
||||||
const { active, over } = event;
|
throttle((event: DragOverEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
if (!over || !dragState.activeTask) return;
|
if (!over || !dragState.activeTask) return;
|
||||||
|
|
||||||
const activeTaskId = active.id as string;
|
const activeTaskId = active.id as string;
|
||||||
const overContainer = over.id as string;
|
const overContainer = over.id as string;
|
||||||
|
|
||||||
// Clear any existing timeout
|
// Clear any existing timeout
|
||||||
if (dragOverTimeoutRef.current) {
|
if (dragOverTimeoutRef.current) {
|
||||||
clearTimeout(dragOverTimeoutRef.current);
|
clearTimeout(dragOverTimeoutRef.current);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Optimistic update with throttling
|
// Optimistic update with throttling
|
||||||
dragOverTimeoutRef.current = setTimeout(() => {
|
dragOverTimeoutRef.current = setTimeout(() => {
|
||||||
// Only update if we're hovering over a different container
|
// Only update if we're hovering over a different container
|
||||||
const targetTask = tasks.find(t => t.id === overContainer);
|
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 targetGroupId = overContainer;
|
||||||
|
let targetIndex = -1;
|
||||||
|
|
||||||
|
// Check if dropping on a task or a group
|
||||||
|
const targetTask = tasks.find(t => t.id === overContainer);
|
||||||
if (targetTask) {
|
if (targetTask) {
|
||||||
|
// Dropping on a task, determine its group
|
||||||
if (currentGrouping === 'status') {
|
if (currentGrouping === 'status') {
|
||||||
targetGroupId = `status-${targetTask.status}`;
|
targetGroupId = `status-${targetTask.status}`;
|
||||||
} else if (currentGrouping === 'priority') {
|
} else if (currentGrouping === 'priority') {
|
||||||
@@ -187,119 +265,67 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
} else if (currentGrouping === 'phase') {
|
} else if (currentGrouping === 'phase') {
|
||||||
targetGroupId = `phase-${targetTask.phase}`;
|
targetGroupId = `phase-${targetTask.phase}`;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
if (targetGroupId !== dragState.activeGroupId) {
|
// Find the index of the target task within its group
|
||||||
// Perform optimistic update for visual feedback
|
|
||||||
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
||||||
if (targetGroup) {
|
if (targetGroup) {
|
||||||
dispatch(optimisticTaskMove({
|
targetIndex = targetGroup.taskIds.indexOf(targetTask.id);
|
||||||
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 sourceGroupInfo = parseGroupId(currentDragState.activeGroupId);
|
||||||
const { active, over } = event;
|
const targetGroupInfo = parseGroupId(targetGroupId);
|
||||||
|
|
||||||
// Clear any pending drag over timeouts
|
// If moving between different groups, update the task's group property
|
||||||
if (dragOverTimeoutRef.current) {
|
if (currentDragState.activeGroupId !== targetGroupId) {
|
||||||
clearTimeout(dragOverTimeoutRef.current);
|
dispatch(
|
||||||
dragOverTimeoutRef.current = null;
|
moveTaskToGroup({
|
||||||
}
|
taskId: activeTaskId,
|
||||||
|
groupType: targetGroupInfo.groupType,
|
||||||
// Reset drag state immediately for better UX
|
groupValue: targetGroupInfo.groupValue,
|
||||||
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
|
// 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);
|
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
||||||
if (targetGroup) {
|
|
||||||
targetIndex = targetGroup.taskIds.indexOf(targetTask.id);
|
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 sourceGroupInfo = parseGroupId(currentDragState.activeGroupId);
|
const handleSelectTask = useCallback(
|
||||||
const targetGroupInfo = parseGroupId(targetGroupId);
|
(taskId: string, selected: boolean) => {
|
||||||
|
dispatch(toggleTaskSelection(taskId));
|
||||||
// If moving between different groups, update the task's group property
|
},
|
||||||
if (currentDragState.activeGroupId !== targetGroupId) {
|
[dispatch]
|
||||||
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) => {
|
const handleToggleSubtasks = useCallback((taskId: string) => {
|
||||||
// Implementation for toggling subtasks
|
// Implementation for toggling subtasks
|
||||||
@@ -334,10 +360,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<Empty
|
<Empty description={`Error loading tasks: ${error}`} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
description={`Error loading tasks: ${error}`}
|
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -352,26 +375,11 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
{/* Task Filters */}
|
{/* Task Filters */}
|
||||||
<Card
|
<div className="mb-4">
|
||||||
size="small"
|
|
||||||
className="mb-4"
|
|
||||||
styles={{ body: { padding: '12px 16px' } }}
|
|
||||||
>
|
|
||||||
<React.Suspense fallback={<div>Loading filters...</div>}>
|
<React.Suspense fallback={<div>Loading filters...</div>}>
|
||||||
<TaskListFilters position="list" />
|
<ImprovedTaskFilters position="list" />
|
||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
</Card>
|
</div>
|
||||||
|
|
||||||
{/* Bulk Action Bar */}
|
|
||||||
{hasSelection && (
|
|
||||||
<BulkActionBar
|
|
||||||
selectedTaskIds={selectedTaskIds}
|
|
||||||
totalSelected={selectedTaskIds.length}
|
|
||||||
currentGrouping={currentGrouping as any}
|
|
||||||
projectId={projectId}
|
|
||||||
onClearSelection={() => dispatch(clearSelection())}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Virtualized Task Groups Container */}
|
{/* Virtualized Task Groups Container */}
|
||||||
<div className="task-groups-container">
|
<div className="task-groups-container">
|
||||||
@@ -383,10 +391,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
</Card>
|
</Card>
|
||||||
) : taskGroups.length === 0 ? (
|
) : taskGroups.length === 0 ? (
|
||||||
<Card>
|
<Card>
|
||||||
<Empty
|
<Empty description="No tasks found" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
description="No tasks found"
|
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
<div className="virtualized-task-groups">
|
<div className="virtualized-task-groups">
|
||||||
@@ -398,14 +403,19 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const minGroupHeight = 300; // Minimum height for better visual appearance
|
const minGroupHeight = 300; // Minimum height for better visual appearance
|
||||||
const maxGroupHeight = 600; // Increased maximum height per group
|
const maxGroupHeight = 600; // Increased maximum height per group
|
||||||
const calculatedHeight = baseHeight + taskRowsHeight;
|
const calculatedHeight = baseHeight + taskRowsHeight;
|
||||||
const groupHeight = Math.max(minGroupHeight, Math.min(calculatedHeight, maxGroupHeight));
|
const groupHeight = Math.max(
|
||||||
|
minGroupHeight,
|
||||||
|
Math.min(calculatedHeight, maxGroupHeight)
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<VirtualizedTaskList
|
<VirtualizedTaskList
|
||||||
key={group.id}
|
key={group.id}
|
||||||
group={group}
|
group={group}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
currentGrouping={(currentGrouping as 'status' | 'priority' | 'phase') || 'status'}
|
currentGrouping={
|
||||||
|
(currentGrouping as 'status' | 'priority' | 'phase') || 'status'
|
||||||
|
}
|
||||||
selectedTaskIds={selectedTaskIds}
|
selectedTaskIds={selectedTaskIds}
|
||||||
onSelectTask={handleSelectTask}
|
onSelectTask={handleSelectTask}
|
||||||
onToggleSubtasks={handleToggleSubtasks}
|
onToggleSubtasks={handleToggleSubtasks}
|
||||||
|
|||||||
@@ -196,6 +196,19 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
})) || [],
|
})) || [],
|
||||||
} as any), [task.id, task.title, task.labels]);
|
} 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
|
// Memoize due date calculation
|
||||||
const dueDate = useMemo(() => {
|
const dueDate = useMemo(() => {
|
||||||
if (!task.dueDate) return null;
|
if (!task.dueDate) return null;
|
||||||
@@ -221,15 +234,12 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
style={style}
|
style={style}
|
||||||
className={containerClasses}
|
className={containerClasses}
|
||||||
>
|
>
|
||||||
<div className="flex h-10 max-h-10 overflow-visible relative min-w-[1200px]">
|
<div className="flex h-10 max-h-10 overflow-visible relative">
|
||||||
{/* Fixed Columns */}
|
{/* Fixed Columns */}
|
||||||
<div
|
<div
|
||||||
className="fixed-columns-row"
|
className="fixed-columns-row"
|
||||||
style={{
|
style={{
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
position: 'sticky',
|
|
||||||
left: 0,
|
|
||||||
zIndex: 2,
|
|
||||||
background: isDarkMode ? '#1a1a1a' : '#fff',
|
background: isDarkMode ? '#1a1a1a' : '#fff',
|
||||||
width: fixedColumns?.reduce((sum, col) => sum + col.width, 0) || 0,
|
width: fixedColumns?.reduce((sum, col) => sum + col.width, 0) || 0,
|
||||||
}}
|
}}
|
||||||
@@ -345,22 +355,11 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<button
|
<AssigneeSelector
|
||||||
className={`
|
task={taskAdapterForAssignee}
|
||||||
w-6 h-6 rounded-full border border-dashed flex items-center justify-center
|
groupId={groupId}
|
||||||
transition-colors duration-200
|
isDarkMode={isDarkMode}
|
||||||
${isDarkMode
|
/>
|
||||||
? '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'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
onClick={() => {
|
|
||||||
// TODO: Implement assignee selector functionality
|
|
||||||
console.log('Add assignee clicked for task:', task.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span className="text-xs">+</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ interface BoardState {
|
|||||||
priorities: string[];
|
priorities: string[];
|
||||||
members: string[];
|
members: string[];
|
||||||
editableSectionId: string | null;
|
editableSectionId: string | null;
|
||||||
|
|
||||||
|
allTasks: IProjectTask[];
|
||||||
|
grouping: string;
|
||||||
|
totalTasks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: BoardState = {
|
const initialState: BoardState = {
|
||||||
@@ -98,6 +102,9 @@ const initialState: BoardState = {
|
|||||||
priorities: [],
|
priorities: [],
|
||||||
members: [],
|
members: [],
|
||||||
editableSectionId: null,
|
editableSectionId: null,
|
||||||
|
allTasks: [],
|
||||||
|
grouping: '',
|
||||||
|
totalTasks: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteTaskFromGroup = (
|
const deleteTaskFromGroup = (
|
||||||
@@ -186,7 +193,7 @@ export const fetchBoardTaskGroups = createAsyncThunk(
|
|||||||
priorities: boardReducer.priorities.join(' '),
|
priorities: boardReducer.priorities.join(' '),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await tasksApiService.getTaskList(config);
|
const response = await tasksApiService.getTaskListV3(config);
|
||||||
return response.body;
|
return response.body;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Fetch Task Groups', error);
|
logger.error('Fetch Task Groups', error);
|
||||||
@@ -803,7 +810,10 @@ const boardSlice = createSlice({
|
|||||||
})
|
})
|
||||||
.addCase(fetchBoardTaskGroups.fulfilled, (state, action) => {
|
.addCase(fetchBoardTaskGroups.fulfilled, (state, action) => {
|
||||||
state.loadingGroups = false;
|
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) => {
|
.addCase(fetchBoardTaskGroups.rejected, (state, action) => {
|
||||||
state.loadingGroups = false;
|
state.loadingGroups = false;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const initialState: TaskManagementState = {
|
|||||||
error: null,
|
error: null,
|
||||||
groups: [],
|
groups: [],
|
||||||
grouping: null,
|
grouping: null,
|
||||||
|
selectedPriorities: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Async thunk to fetch tasks from API
|
// Async thunk to fetch tasks from API
|
||||||
@@ -137,6 +138,23 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
const state = getState() as RootState;
|
const state = getState() as RootState;
|
||||||
const currentGrouping = state.grouping.currentGrouping;
|
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 = {
|
const config: ITaskListConfigV2 = {
|
||||||
id: projectId,
|
id: projectId,
|
||||||
archived: false,
|
archived: false,
|
||||||
@@ -145,11 +163,11 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
order: '',
|
order: '',
|
||||||
search: '',
|
search: '',
|
||||||
statuses: '',
|
statuses: '',
|
||||||
members: '',
|
members: selectedAssignees,
|
||||||
projects: '',
|
projects: '',
|
||||||
isSubtasksInclude: false,
|
isSubtasksInclude: false,
|
||||||
labels: '',
|
labels: selectedLabels,
|
||||||
priorities: '',
|
priorities: selectedPriorities,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await tasksApiService.getTaskListV3(config);
|
const response = await tasksApiService.getTaskListV3(config);
|
||||||
@@ -305,6 +323,11 @@ const taskManagementSlice = createSlice({
|
|||||||
state.error = action.payload;
|
state.error = action.payload;
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Filter actions
|
||||||
|
setSelectedPriorities: (state, action: PayloadAction<string[]>) => {
|
||||||
|
state.selectedPriorities = action.payload;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: (builder) => {
|
extraReducers: (builder) => {
|
||||||
builder
|
builder
|
||||||
@@ -363,6 +386,7 @@ export const {
|
|||||||
optimisticTaskMove,
|
optimisticTaskMove,
|
||||||
setLoading,
|
setLoading,
|
||||||
setError,
|
setError,
|
||||||
|
setSelectedPriorities,
|
||||||
} = taskManagementSlice.actions;
|
} = taskManagementSlice.actions;
|
||||||
|
|
||||||
export default taskManagementSlice.reducer;
|
export default taskManagementSlice.reducer;
|
||||||
|
|||||||
@@ -80,6 +80,9 @@ interface ITaskState {
|
|||||||
convertToSubtaskDrawerOpen: boolean;
|
convertToSubtaskDrawerOpen: boolean;
|
||||||
customColumns: ITaskListColumn[];
|
customColumns: ITaskListColumn[];
|
||||||
customColumnValues: Record<string, Record<string, any>>;
|
customColumnValues: Record<string, Record<string, any>>;
|
||||||
|
allTasks: IProjectTask[];
|
||||||
|
grouping: string;
|
||||||
|
totalTasks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ITaskState = {
|
const initialState: ITaskState = {
|
||||||
@@ -105,6 +108,9 @@ const initialState: ITaskState = {
|
|||||||
convertToSubtaskDrawerOpen: false,
|
convertToSubtaskDrawerOpen: false,
|
||||||
customColumns: [],
|
customColumns: [],
|
||||||
customColumnValues: {},
|
customColumnValues: {},
|
||||||
|
allTasks: [],
|
||||||
|
grouping: '',
|
||||||
|
totalTasks: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const COLUMN_KEYS = {
|
export const COLUMN_KEYS = {
|
||||||
@@ -165,7 +171,7 @@ export const fetchTaskGroups = createAsyncThunk(
|
|||||||
priorities: taskReducer.priorities.join(' '),
|
priorities: taskReducer.priorities.join(' '),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await tasksApiService.getTaskList(config);
|
const response = await tasksApiService.getTaskListV3(config);
|
||||||
return response.body;
|
return response.body;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Fetch Task Groups', error);
|
logger.error('Fetch Task Groups', error);
|
||||||
@@ -234,9 +240,9 @@ export const fetchSubTasks = createAsyncThunk(
|
|||||||
parent_task: taskId,
|
parent_task: taskId,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const response = await tasksApiService.getTaskList(config);
|
const response = await tasksApiService.getTaskListV3(config);
|
||||||
// Only expand if we actually fetched subtasks
|
// 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));
|
dispatch(toggleTaskRowExpansion(taskId));
|
||||||
}
|
}
|
||||||
return response.body;
|
return response.body;
|
||||||
@@ -1026,7 +1032,10 @@ const taskSlice = createSlice({
|
|||||||
})
|
})
|
||||||
.addCase(fetchTaskGroups.fulfilled, (state, action) => {
|
.addCase(fetchTaskGroups.fulfilled, (state, action) => {
|
||||||
state.loadingGroups = false;
|
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) => {
|
.addCase(fetchTaskGroups.rejected, (state, action) => {
|
||||||
state.loadingGroups = false;
|
state.loadingGroups = false;
|
||||||
@@ -1035,14 +1044,16 @@ const taskSlice = createSlice({
|
|||||||
.addCase(fetchSubTasks.pending, state => {
|
.addCase(fetchSubTasks.pending, state => {
|
||||||
state.error = null;
|
state.error = null;
|
||||||
})
|
})
|
||||||
.addCase(fetchSubTasks.fulfilled, (state, action: PayloadAction<IProjectTask[]>) => {
|
.addCase(fetchSubTasks.fulfilled, (state, action) => {
|
||||||
if (action.payload.length > 0) {
|
if (action.payload && action.payload.groups && action.payload.groups.length > 0) {
|
||||||
const taskId = action.payload[0].parent_task_id;
|
// 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) {
|
if (taskId) {
|
||||||
for (const group of state.taskGroups) {
|
for (const group of state.taskGroups) {
|
||||||
const task = group.tasks.find(t => t.id === taskId);
|
const task = group.tasks.find(t => t.id === taskId);
|
||||||
if (task) {
|
if (task) {
|
||||||
task.sub_tasks = action.payload;
|
task.sub_tasks = subtasks;
|
||||||
task.show_sub_tasks = true;
|
task.show_sub_tasks = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { SocketEvents } from '@/shared/socket-events';
|
|||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import TaskListTable from '../task-list-table/task-list-table';
|
import TaskListTable from '../task-list-table/task-list-table';
|
||||||
import Collapsible from '@/components/collapsible/collapsible';
|
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 TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
|
|
||||||
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
|
|
||||||
{createPortal(
|
{createPortal(
|
||||||
<TaskTemplateDrawer showDrawer={false} selectedTemplateId={''} onClose={() => {}} />,
|
<TaskTemplateDrawer showDrawer={false} selectedTemplateId={''} onClose={() => {}} />,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
|||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
|
||||||
import TaskListTableWrapper from './task-list-table/task-list-table-wrapper/task-list-table-wrapper';
|
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 TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||||
|
|
||||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
@@ -69,7 +69,7 @@ const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOpti
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
|
|
||||||
|
|
||||||
{createPortal(
|
{createPortal(
|
||||||
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ import {
|
|||||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
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 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 TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||||
|
|
||||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
@@ -686,7 +686,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
|
|
||||||
|
|
||||||
{createPortal(
|
{createPortal(
|
||||||
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
||||||
|
|||||||
@@ -72,6 +72,7 @@ export interface TaskManagementState {
|
|||||||
error: string | null;
|
error: string | null;
|
||||||
groups: TaskGroup[]; // Pre-processed groups from V3 API
|
groups: TaskGroup[]; // Pre-processed groups from V3 API
|
||||||
grouping: string | null; // Current grouping from V3 API
|
grouping: string | null; // Current grouping from V3 API
|
||||||
|
selectedPriorities: string[]; // Selected priority filters
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskGroupsState {
|
export interface TaskGroupsState {
|
||||||
|
|||||||
Reference in New Issue
Block a user