expand sub tasks
This commit is contained in:
@@ -37,4 +37,4 @@
|
||||
/* Hide drag handle during drag */
|
||||
[data-dnd-dragging="true"] .drag-handle-optimized {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,24 @@ import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
|
||||
import { toggleField } from '@/features/task-management/taskListFields.slice';
|
||||
|
||||
// Import Redux actions
|
||||
import { fetchTasksV3, setSearch as setTaskManagementSearch } from '@/features/task-management/task-management.slice';
|
||||
import { setCurrentGrouping, selectCurrentGrouping } from '@/features/task-management/grouping.slice';
|
||||
import {
|
||||
fetchTasksV3,
|
||||
setSearch as setTaskManagementSearch,
|
||||
} 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, setSearch, setPriorities } from '@/features/tasks/tasks.slice';
|
||||
import {
|
||||
fetchLabelsByProject,
|
||||
fetchTaskAssignees,
|
||||
setMembers,
|
||||
setLabels,
|
||||
setSearch,
|
||||
setPriorities,
|
||||
} 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';
|
||||
@@ -213,7 +226,9 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => {
|
||||
icon: TeamOutlined,
|
||||
multiSelect: true,
|
||||
searchable: true,
|
||||
selectedValues: currentAssignees.filter((m: any) => m.selected && m.id).map((m: any) => m.id || ''),
|
||||
selectedValues: currentAssignees
|
||||
.filter((m: any) => m.selected && m.id)
|
||||
.map((m: any) => m.id || ''),
|
||||
options: filterData.kanbanTaskAssignees.map((assignee: any) => ({
|
||||
id: assignee.id || '',
|
||||
label: assignee.name || '',
|
||||
@@ -228,7 +243,9 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => {
|
||||
icon: TagOutlined,
|
||||
multiSelect: true,
|
||||
searchable: true,
|
||||
selectedValues: currentLabels.filter((l: any) => l.selected && l.id).map((l: any) => l.id || ''),
|
||||
selectedValues: currentLabels
|
||||
.filter((l: any) => l.selected && l.id)
|
||||
.map((l: any) => l.id || ''),
|
||||
options: filterData.kanbanLabels.map((label: any) => ({
|
||||
id: label.id || '',
|
||||
label: label.name || '',
|
||||
@@ -247,15 +264,22 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => {
|
||||
options: [
|
||||
{ id: 'status', label: t('statusText'), value: 'status' },
|
||||
{ id: 'priority', label: t('priorityText'), value: 'priority' },
|
||||
{ id: 'phase', label: (kanbanProject as any)?.phase_label || t('phaseText'), value: 'phase' },
|
||||
{
|
||||
id: 'phase',
|
||||
label: (kanbanProject as any)?.phase_label || t('phaseText'),
|
||||
value: 'phase',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
} else {
|
||||
// Use task management/board state
|
||||
const currentPriorities = currentProjectView === 'list' ? filterData.taskPriorities : filterData.boardPriorities;
|
||||
const currentLabels = currentProjectView === 'list' ? filterData.taskLabels : filterData.boardLabels;
|
||||
const currentAssignees = currentProjectView === 'list' ? filterData.taskAssignees : filterData.boardAssignees;
|
||||
const currentPriorities =
|
||||
currentProjectView === 'list' ? filterData.taskPriorities : filterData.boardPriorities;
|
||||
const currentLabels =
|
||||
currentProjectView === 'list' ? filterData.taskLabels : filterData.boardLabels;
|
||||
const currentAssignees =
|
||||
currentProjectView === 'list' ? filterData.taskAssignees : filterData.boardAssignees;
|
||||
const groupByValue = currentGrouping || 'status';
|
||||
return [
|
||||
{
|
||||
@@ -277,7 +301,9 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => {
|
||||
icon: TeamOutlined,
|
||||
multiSelect: true,
|
||||
searchable: true,
|
||||
selectedValues: currentAssignees.filter((m: any) => m.selected && m.id).map((m: any) => m.id || ''),
|
||||
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 || '',
|
||||
@@ -292,7 +318,9 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => {
|
||||
icon: TagOutlined,
|
||||
multiSelect: true,
|
||||
searchable: true,
|
||||
selectedValues: currentLabels.filter((l: any) => l.selected && l.id).map((l: any) => l.id || ''),
|
||||
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 || '',
|
||||
@@ -311,13 +339,16 @@ const useFilterData = (position: 'board' | 'list'): FilterSection[] => {
|
||||
options: [
|
||||
{ id: 'status', label: t('statusText'), value: 'status' },
|
||||
{ id: 'priority', label: t('priorityText'), value: 'priority' },
|
||||
{ id: 'phase', label: filterData.project?.phase_label || t('phaseText'), value: 'phase' },
|
||||
{
|
||||
id: 'phase',
|
||||
label: filterData.project?.phase_label || t('phaseText'),
|
||||
value: 'phase',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
}, [isBoard, kanbanState, kanbanProject, filterData, currentProjectView, t, currentGrouping]);
|
||||
|
||||
};
|
||||
|
||||
// Filter Dropdown Component
|
||||
@@ -329,7 +360,15 @@ const FilterDropdown: React.FC<{
|
||||
themeClasses: any;
|
||||
isDarkMode: boolean;
|
||||
className?: string;
|
||||
}> = ({ section, onSelectionChange, isOpen, onToggle, themeClasses, isDarkMode, className = '' }) => {
|
||||
}> = ({
|
||||
section,
|
||||
onSelectionChange,
|
||||
isOpen,
|
||||
onToggle,
|
||||
themeClasses,
|
||||
isDarkMode,
|
||||
className = '',
|
||||
}) => {
|
||||
// Add permission checks for groupBy section
|
||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||
const isProjectManager = useIsProjectManager();
|
||||
@@ -345,9 +384,7 @@ const FilterDropdown: React.FC<{
|
||||
}
|
||||
|
||||
const searchLower = searchTerm.toLowerCase();
|
||||
return section.options.filter(option =>
|
||||
option.label.toLowerCase().includes(searchLower)
|
||||
);
|
||||
return section.options.filter(option => option.label.toLowerCase().includes(searchLower));
|
||||
}, [searchTerm, section.options, section.searchable]);
|
||||
|
||||
// Update filtered options when memo changes
|
||||
@@ -373,17 +410,20 @@ const FilterDropdown: React.FC<{
|
||||
}
|
||||
}, [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 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, []);
|
||||
@@ -400,9 +440,12 @@ const FilterDropdown: React.FC<{
|
||||
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}`
|
||||
${
|
||||
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-2
|
||||
${isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'}
|
||||
@@ -432,7 +475,9 @@ const FilterDropdown: React.FC<{
|
||||
|
||||
{/* Dropdown Panel */}
|
||||
{isOpen && (
|
||||
<div className={`absolute top-full left-0 z-50 mt-1 w-64 ${themeClasses.dropdownBg} rounded-md shadow-sm border ${themeClasses.dropdownBorder}`}>
|
||||
<div
|
||||
className={`absolute top-full left-0 z-50 mt-1 w-64 ${themeClasses.dropdownBg} rounded-md shadow-sm border ${themeClasses.dropdownBorder}`}
|
||||
>
|
||||
{/* Search Input */}
|
||||
{section.searchable && (
|
||||
<div className={`p-2 border-b ${themeClasses.dividerBorder}`}>
|
||||
@@ -442,10 +487,11 @@ const FilterDropdown: React.FC<{
|
||||
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'
|
||||
}`}
|
||||
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>
|
||||
@@ -459,7 +505,7 @@ const FilterDropdown: React.FC<{
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-0.5">
|
||||
{filteredOptions.map((option) => {
|
||||
{filteredOptions.map(option => {
|
||||
const isSelected = section.selectedValues.includes(option.value);
|
||||
|
||||
return (
|
||||
@@ -469,20 +515,26 @@ const FilterDropdown: React.FC<{
|
||||
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}`
|
||||
${
|
||||
isSelected
|
||||
? isDarkMode
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-blue-50 text-blue-800 font-semibold'
|
||||
: `${themeClasses.optionText} ${themeClasses.optionHover}`
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Checkbox/Radio indicator */}
|
||||
<div className={`
|
||||
<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
|
||||
? '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>
|
||||
|
||||
@@ -545,10 +597,13 @@ const SearchFilter: React.FC<{
|
||||
}
|
||||
}, [isExpanded]);
|
||||
|
||||
const handleSubmit = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onChange(localValue);
|
||||
}, [localValue, onChange]);
|
||||
const handleSubmit = useCallback(
|
||||
(e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
onChange(localValue);
|
||||
},
|
||||
[localValue, onChange]
|
||||
);
|
||||
|
||||
const handleClear = useCallback(() => {
|
||||
setLocalValue('');
|
||||
@@ -564,8 +619,11 @@ const SearchFilter: React.FC<{
|
||||
{!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-2 ${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText} ${themeClasses.containerBg === 'bg-gray-800' ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'
|
||||
}`}
|
||||
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-2 ${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>
|
||||
@@ -578,12 +636,13 @@ const SearchFilter: React.FC<{
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={localValue}
|
||||
onChange={(e) => setLocalValue(e.target.value)}
|
||||
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'
|
||||
}`}
|
||||
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
|
||||
@@ -616,7 +675,10 @@ const SearchFilter: React.FC<{
|
||||
|
||||
const LOCAL_STORAGE_KEY = 'worklenz.taskManagement.fields';
|
||||
|
||||
const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({ themeClasses, isDarkMode }) => {
|
||||
const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
||||
themeClasses,
|
||||
isDarkMode,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const fieldsRaw = useSelector((state: RootState) => state.taskManagementFields);
|
||||
const fields = Array.isArray(fieldsRaw) ? fieldsRaw : [];
|
||||
@@ -626,11 +688,13 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Debounced save to localStorage using enhanced debounce
|
||||
const debouncedSaveFields = useMemo(() =>
|
||||
createDebouncedFunction((fieldsToSave: typeof fields) => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(fieldsToSave));
|
||||
}, 300),
|
||||
[]);
|
||||
const debouncedSaveFields = useMemo(
|
||||
() =>
|
||||
createDebouncedFunction((fieldsToSave: typeof fields) => {
|
||||
localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(fieldsToSave));
|
||||
}, 300),
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
debouncedSaveFields(fields);
|
||||
@@ -650,7 +714,10 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
||||
return () => document.removeEventListener('mousedown', handleClick);
|
||||
}, [open]);
|
||||
|
||||
const visibleCount = useMemo(() => sortedFields.filter(field => field.visible).length, [sortedFields]);
|
||||
const visibleCount = useMemo(
|
||||
() => sortedFields.filter(field => field.visible).length,
|
||||
[sortedFields]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={dropdownRef}>
|
||||
@@ -660,9 +727,12 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
||||
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
|
||||
${visibleCount > 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}`
|
||||
${
|
||||
visibleCount > 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-2
|
||||
${isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'}
|
||||
@@ -684,7 +754,9 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
||||
|
||||
{/* Dropdown Panel - matching FilterDropdown style */}
|
||||
{open && (
|
||||
<div className={`absolute top-full left-0 z-50 mt-1 w-64 ${themeClasses.dropdownBg} rounded-md shadow-sm border ${themeClasses.dropdownBorder}`}>
|
||||
<div
|
||||
className={`absolute top-full left-0 z-50 mt-1 w-64 ${themeClasses.dropdownBg} rounded-md shadow-sm border ${themeClasses.dropdownBorder}`}
|
||||
>
|
||||
{/* Options List */}
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{sortedFields.length === 0 ? (
|
||||
@@ -693,7 +765,7 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-0.5">
|
||||
{sortedFields.map((field) => {
|
||||
{sortedFields.map(field => {
|
||||
const isSelected = field.visible;
|
||||
|
||||
return (
|
||||
@@ -703,20 +775,26 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
||||
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}`
|
||||
${
|
||||
isSelected
|
||||
? isDarkMode
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-blue-50 text-blue-800 font-semibold'
|
||||
: `${themeClasses.optionText} ${themeClasses.optionHover}`
|
||||
}
|
||||
`}
|
||||
>
|
||||
{/* Checkbox indicator - matching FilterDropdown style */}
|
||||
<div className={`
|
||||
<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
|
||||
? '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>
|
||||
|
||||
@@ -737,10 +815,7 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
||||
};
|
||||
|
||||
// Main Component
|
||||
const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
position,
|
||||
className = ''
|
||||
}) => {
|
||||
const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, className = '' }) => {
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
@@ -763,8 +838,12 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
const [clearingFilters, setClearingFilters] = useState(false);
|
||||
|
||||
// Refs for debounced functions
|
||||
const debouncedFilterChangeRef = useRef<((projectId: string) => void) & { cancel: () => void } | null>(null);
|
||||
const debouncedSearchChangeRef = useRef<((projectId: string, value: string) => void) & { cancel: () => void } | null>(null);
|
||||
const debouncedFilterChangeRef = useRef<
|
||||
(((projectId: string) => void) & { cancel: () => void }) | null
|
||||
>(null);
|
||||
const debouncedSearchChangeRef = useRef<
|
||||
(((projectId: string, value: string) => void) & { cancel: () => void }) | null
|
||||
>(null);
|
||||
|
||||
// Get real filter data
|
||||
const filterSectionsData = useFilterData(position);
|
||||
@@ -794,26 +873,29 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
|
||||
// Theme-aware class names - memoize to prevent unnecessary re-renders
|
||||
// Using task list row colors for consistency: --task-bg-primary: #1f1f1f, --task-bg-secondary: #141414
|
||||
const themeClasses = useMemo(() => ({
|
||||
containerBg: isDarkMode ? 'bg-[#1f1f1f]' : 'bg-white',
|
||||
containerBorder: isDarkMode ? 'border-[#303030]' : 'border-gray-200',
|
||||
buttonBg: isDarkMode ? 'bg-[#141414] hover:bg-[#262626]' : 'bg-white hover:bg-gray-50',
|
||||
buttonBorder: isDarkMode ? 'border-[#303030]' : 'border-gray-300',
|
||||
buttonText: isDarkMode ? 'text-[#d9d9d9]' : 'text-gray-700',
|
||||
dropdownBg: isDarkMode ? 'bg-[#1f1f1f]' : 'bg-white',
|
||||
dropdownBorder: isDarkMode ? 'border-[#303030]' : 'border-gray-200',
|
||||
optionText: isDarkMode ? 'text-[#d9d9d9]' : 'text-gray-700',
|
||||
optionHover: isDarkMode ? 'hover:bg-[#262626]' : 'hover:bg-gray-50',
|
||||
secondaryText: isDarkMode ? 'text-[#8c8c8c]' : 'text-gray-500',
|
||||
dividerBorder: isDarkMode ? 'border-[#404040]' : 'border-gray-200',
|
||||
pillBg: isDarkMode ? 'bg-[#141414]' : 'bg-gray-100',
|
||||
pillText: isDarkMode ? 'text-[#d9d9d9]' : 'text-gray-700',
|
||||
pillActiveBg: isDarkMode ? 'bg-blue-600' : 'bg-blue-100',
|
||||
pillActiveText: isDarkMode ? 'text-white' : 'text-blue-800',
|
||||
searchBg: isDarkMode ? 'bg-[#141414]' : 'bg-gray-50',
|
||||
searchBorder: isDarkMode ? 'border-[#303030]' : 'border-gray-300',
|
||||
searchText: isDarkMode ? 'text-[#d9d9d9]' : 'text-gray-900',
|
||||
}), [isDarkMode]);
|
||||
const themeClasses = useMemo(
|
||||
() => ({
|
||||
containerBg: isDarkMode ? 'bg-[#1f1f1f]' : 'bg-white',
|
||||
containerBorder: isDarkMode ? 'border-[#303030]' : 'border-gray-200',
|
||||
buttonBg: isDarkMode ? 'bg-[#141414] hover:bg-[#262626]' : 'bg-white hover:bg-gray-50',
|
||||
buttonBorder: isDarkMode ? 'border-[#303030]' : 'border-gray-300',
|
||||
buttonText: isDarkMode ? 'text-[#d9d9d9]' : 'text-gray-700',
|
||||
dropdownBg: isDarkMode ? 'bg-[#1f1f1f]' : 'bg-white',
|
||||
dropdownBorder: isDarkMode ? 'border-[#303030]' : 'border-gray-200',
|
||||
optionText: isDarkMode ? 'text-[#d9d9d9]' : 'text-gray-700',
|
||||
optionHover: isDarkMode ? 'hover:bg-[#262626]' : 'hover:bg-gray-50',
|
||||
secondaryText: isDarkMode ? 'text-[#8c8c8c]' : 'text-gray-500',
|
||||
dividerBorder: isDarkMode ? 'border-[#404040]' : 'border-gray-200',
|
||||
pillBg: isDarkMode ? 'bg-[#141414]' : 'bg-gray-100',
|
||||
pillText: isDarkMode ? 'text-[#d9d9d9]' : 'text-gray-700',
|
||||
pillActiveBg: isDarkMode ? 'bg-blue-600' : 'bg-blue-100',
|
||||
pillActiveText: isDarkMode ? 'text-white' : 'text-blue-800',
|
||||
searchBg: isDarkMode ? 'bg-[#141414]' : 'bg-gray-50',
|
||||
searchBorder: isDarkMode ? 'border-[#303030]' : 'border-gray-300',
|
||||
searchText: isDarkMode ? 'text-[#d9d9d9]' : 'text-gray-900',
|
||||
}),
|
||||
[isDarkMode]
|
||||
);
|
||||
|
||||
// Initialize debounced functions
|
||||
useEffect(() => {
|
||||
@@ -823,17 +905,20 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
}, FILTER_DEBOUNCE_DELAY);
|
||||
|
||||
// Debounced search change function
|
||||
debouncedSearchChangeRef.current = createDebouncedFunction((projectId: string, value: string) => {
|
||||
// Dispatch search action based on current view
|
||||
if (projectView === 'list') {
|
||||
dispatch(setSearch(value));
|
||||
} else {
|
||||
dispatch(setKanbanSearch(value));
|
||||
}
|
||||
debouncedSearchChangeRef.current = createDebouncedFunction(
|
||||
(projectId: string, value: string) => {
|
||||
// Dispatch search action based on current view
|
||||
if (projectView === 'list') {
|
||||
dispatch(setSearch(value));
|
||||
} else {
|
||||
dispatch(setKanbanSearch(value));
|
||||
}
|
||||
|
||||
// Trigger task refetch with new search value
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
}, SEARCH_DEBOUNCE_DELAY);
|
||||
// Trigger task refetch with new search value
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
},
|
||||
SEARCH_DEBOUNCE_DELAY
|
||||
);
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
@@ -844,8 +929,9 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
|
||||
// Calculate active filters count - memoized to prevent unnecessary recalculations
|
||||
const calculatedActiveFiltersCount = useMemo(() => {
|
||||
const count = filterSections.reduce((acc, section) =>
|
||||
section.id === 'groupBy' ? acc : acc + section.selectedValues.length, 0
|
||||
const count = filterSections.reduce(
|
||||
(acc, section) => (section.id === 'groupBy' ? acc : acc + section.selectedValues.length),
|
||||
0
|
||||
);
|
||||
return count + (searchValue ? 1 : 0);
|
||||
}, [filterSections, searchValue]);
|
||||
@@ -858,117 +944,123 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
|
||||
// Handlers
|
||||
const handleDropdownToggle = useCallback((sectionId: string) => {
|
||||
setOpenDropdown(current => current === sectionId ? null : sectionId);
|
||||
setOpenDropdown(current => (current === sectionId ? null : sectionId));
|
||||
}, []);
|
||||
|
||||
const handleSelectionChange = useCallback((sectionId: string, values: string[]) => {
|
||||
if (!projectId) return;
|
||||
if (position === 'board') {
|
||||
// Enhanced Kanban logic
|
||||
if (sectionId === 'groupBy' && values.length > 0) {
|
||||
dispatch(setKanbanGroupBy(values[0] as any));
|
||||
dispatch(fetchEnhancedKanbanGroups(projectId));
|
||||
return;
|
||||
const handleSelectionChange = useCallback(
|
||||
(sectionId: string, values: string[]) => {
|
||||
if (!projectId) return;
|
||||
if (position === 'board') {
|
||||
// Enhanced Kanban logic
|
||||
if (sectionId === 'groupBy' && values.length > 0) {
|
||||
dispatch(setKanbanGroupBy(values[0] as any));
|
||||
dispatch(fetchEnhancedKanbanGroups(projectId));
|
||||
return;
|
||||
}
|
||||
if (sectionId === 'priority') {
|
||||
dispatch(setKanbanPriorities(values));
|
||||
dispatch(fetchEnhancedKanbanGroups(projectId));
|
||||
return;
|
||||
}
|
||||
if (sectionId === 'assignees') {
|
||||
// Update individual assignee selections using the new action
|
||||
const currentAssignees = kanbanState.taskAssignees || [];
|
||||
const currentSelectedIds = currentAssignees
|
||||
.filter((m: any) => m.selected)
|
||||
.map((m: any) => m.id);
|
||||
|
||||
// First, clear all selections
|
||||
currentAssignees.forEach((assignee: any) => {
|
||||
if (assignee.selected) {
|
||||
dispatch(setTaskAssigneeSelection({ id: assignee.id, selected: false }));
|
||||
}
|
||||
});
|
||||
|
||||
// Then set the new selections
|
||||
values.forEach(id => {
|
||||
dispatch(setTaskAssigneeSelection({ id, selected: true }));
|
||||
});
|
||||
|
||||
dispatch(fetchEnhancedKanbanGroups(projectId));
|
||||
return;
|
||||
}
|
||||
if (sectionId === 'labels') {
|
||||
// Update individual label selections using the new action
|
||||
const currentLabels = kanbanState.labels || [];
|
||||
const currentSelectedIds = currentLabels
|
||||
.filter((l: any) => l.selected)
|
||||
.map((l: any) => l.id);
|
||||
|
||||
// First, clear all selections
|
||||
currentLabels.forEach((label: any) => {
|
||||
if (label.selected) {
|
||||
dispatch(setLabelSelection({ id: label.id, selected: false }));
|
||||
}
|
||||
});
|
||||
|
||||
// Then set the new selections
|
||||
values.forEach(id => {
|
||||
dispatch(setLabelSelection({ id, selected: true }));
|
||||
});
|
||||
|
||||
dispatch(fetchEnhancedKanbanGroups(projectId));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// ... existing list logic ...
|
||||
if (sectionId === 'groupBy' && values.length > 0) {
|
||||
dispatch(setCurrentGrouping(values[0] as 'status' | 'priority' | 'phase'));
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
return;
|
||||
}
|
||||
if (sectionId === 'priority') {
|
||||
dispatch(setPriorities(values));
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
return;
|
||||
}
|
||||
if (sectionId === 'assignees') {
|
||||
const updatedAssignees = currentTaskAssignees.map(member => ({
|
||||
...member,
|
||||
selected: values.includes(member.id || ''),
|
||||
}));
|
||||
dispatch(setMembers(updatedAssignees));
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
return;
|
||||
}
|
||||
if (sectionId === 'labels') {
|
||||
const updatedLabels = currentTaskLabels.map(label => ({
|
||||
...label,
|
||||
selected: values.includes(label.id || ''),
|
||||
}));
|
||||
dispatch(setLabels(updatedLabels));
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (sectionId === 'priority') {
|
||||
dispatch(setKanbanPriorities(values));
|
||||
dispatch(fetchEnhancedKanbanGroups(projectId));
|
||||
return;
|
||||
},
|
||||
[dispatch, projectId, position, currentTaskAssignees, currentTaskLabels, kanbanState]
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback(
|
||||
(value: string) => {
|
||||
setSearchValue(value);
|
||||
|
||||
if (!projectId) return;
|
||||
|
||||
if (position === 'board') {
|
||||
dispatch(setKanbanSearch(value));
|
||||
if (projectId) {
|
||||
dispatch(fetchEnhancedKanbanGroups(projectId));
|
||||
}
|
||||
} else {
|
||||
// Use debounced search
|
||||
if (projectId) {
|
||||
debouncedSearchChangeRef.current?.(projectId, value);
|
||||
}
|
||||
}
|
||||
if (sectionId === 'assignees') {
|
||||
// Update individual assignee selections using the new action
|
||||
const currentAssignees = kanbanState.taskAssignees || [];
|
||||
const currentSelectedIds = currentAssignees.filter((m: any) => m.selected).map((m: any) => m.id);
|
||||
|
||||
// First, clear all selections
|
||||
currentAssignees.forEach((assignee: any) => {
|
||||
if (assignee.selected) {
|
||||
dispatch(setTaskAssigneeSelection({ id: assignee.id, selected: false }));
|
||||
}
|
||||
});
|
||||
|
||||
// Then set the new selections
|
||||
values.forEach(id => {
|
||||
dispatch(setTaskAssigneeSelection({ id, selected: true }));
|
||||
});
|
||||
|
||||
dispatch(fetchEnhancedKanbanGroups(projectId));
|
||||
return;
|
||||
}
|
||||
if (sectionId === 'labels') {
|
||||
// Update individual label selections using the new action
|
||||
const currentLabels = kanbanState.labels || [];
|
||||
const currentSelectedIds = currentLabels.filter((l: any) => l.selected).map((l: any) => l.id);
|
||||
|
||||
// First, clear all selections
|
||||
currentLabels.forEach((label: any) => {
|
||||
if (label.selected) {
|
||||
dispatch(setLabelSelection({ id: label.id, selected: false }));
|
||||
}
|
||||
});
|
||||
|
||||
// Then set the new selections
|
||||
values.forEach(id => {
|
||||
dispatch(setLabelSelection({ id, selected: true }));
|
||||
});
|
||||
|
||||
dispatch(fetchEnhancedKanbanGroups(projectId));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
// ... existing list logic ...
|
||||
if (sectionId === 'groupBy' && values.length > 0) {
|
||||
dispatch(setCurrentGrouping(values[0] as 'status' | 'priority' | 'phase'));
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
return;
|
||||
}
|
||||
if (sectionId === 'priority') {
|
||||
dispatch(setPriorities(values));
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
return;
|
||||
}
|
||||
if (sectionId === 'assignees') {
|
||||
const updatedAssignees = currentTaskAssignees.map(member => ({
|
||||
...member,
|
||||
selected: values.includes(member.id || '')
|
||||
}));
|
||||
dispatch(setMembers(updatedAssignees));
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
return;
|
||||
}
|
||||
if (sectionId === 'labels') {
|
||||
const updatedLabels = currentTaskLabels.map(label => ({
|
||||
...label,
|
||||
selected: values.includes(label.id || '')
|
||||
}));
|
||||
dispatch(setLabels(updatedLabels));
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
return;
|
||||
}
|
||||
|
||||
}
|
||||
}, [dispatch, projectId, position, currentTaskAssignees, currentTaskLabels, kanbanState]);
|
||||
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
|
||||
|
||||
if (!projectId) return;
|
||||
|
||||
if (position === 'board') {
|
||||
dispatch(setKanbanSearch(value));
|
||||
if (projectId) {
|
||||
dispatch(fetchEnhancedKanbanGroups(projectId));
|
||||
}
|
||||
} else {
|
||||
// Use debounced search
|
||||
if (projectId) {
|
||||
debouncedSearchChangeRef.current?.(projectId, value);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}, [dispatch, projectId, position]);
|
||||
},
|
||||
[dispatch, projectId, position]
|
||||
);
|
||||
|
||||
const clearAllFilters = useCallback(async () => {
|
||||
if (!projectId || clearingFilters) return;
|
||||
@@ -988,10 +1080,12 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
setShowArchived(false);
|
||||
|
||||
// Update local filter sections state immediately
|
||||
setFilterSections(prev => prev.map(section => ({
|
||||
...section,
|
||||
selectedValues: section.id === 'groupBy' ? section.selectedValues : [] // Keep groupBy, clear others
|
||||
})));
|
||||
setFilterSections(prev =>
|
||||
prev.map(section => ({
|
||||
...section,
|
||||
selectedValues: section.id === 'groupBy' ? section.selectedValues : [], // Keep groupBy, clear others
|
||||
}))
|
||||
);
|
||||
};
|
||||
|
||||
// Execute all local state updates in a batch
|
||||
@@ -1009,14 +1103,14 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
// Clear label filters
|
||||
const clearedLabels = currentTaskLabels.map(label => ({
|
||||
...label,
|
||||
selected: false
|
||||
selected: false,
|
||||
}));
|
||||
dispatch(setLabels(clearedLabels));
|
||||
|
||||
// Clear assignee filters
|
||||
const clearedAssignees = currentTaskAssignees.map(member => ({
|
||||
...member,
|
||||
selected: false
|
||||
selected: false,
|
||||
}));
|
||||
dispatch(setMembers(clearedAssignees));
|
||||
|
||||
@@ -1055,7 +1149,9 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
}, [dispatch, projectId, position, showArchived]);
|
||||
|
||||
return (
|
||||
<div className={`${themeClasses.containerBg} border ${themeClasses.containerBorder} rounded-md p-3 shadow-sm ${className}`}>
|
||||
<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">
|
||||
@@ -1069,7 +1165,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
|
||||
{/* Filter Dropdowns - Only render when data is loaded */}
|
||||
{isDataLoaded ? (
|
||||
filterSectionsData.map((section) => (
|
||||
filterSectionsData.map(section => (
|
||||
<FilterDropdown
|
||||
key={section.id}
|
||||
section={section}
|
||||
@@ -1082,7 +1178,9 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
))
|
||||
) : (
|
||||
// Loading state
|
||||
<div className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${themeClasses.secondaryText}`}>
|
||||
<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>
|
||||
@@ -1100,12 +1198,13 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
<button
|
||||
onClick={clearAllFilters}
|
||||
disabled={clearingFilters}
|
||||
className={`text-xs font-medium transition-colors duration-150 ${clearingFilters
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: isDarkMode
|
||||
? 'text-blue-400 hover:text-blue-300'
|
||||
: 'text-blue-600 hover:text-blue-700'
|
||||
}`}
|
||||
className={`text-xs font-medium transition-colors duration-150 ${
|
||||
clearingFilters
|
||||
? 'text-gray-400 cursor-not-allowed'
|
||||
: isDarkMode
|
||||
? 'text-blue-400 hover:text-blue-300'
|
||||
: 'text-blue-600 hover:text-blue-700'
|
||||
}`}
|
||||
>
|
||||
{clearingFilters ? 'Clearing...' : 'Clear all'}
|
||||
</button>
|
||||
@@ -1120,28 +1219,32 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
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-[#303030] bg-[#141414] focus:ring-offset-gray-800'
|
||||
isDarkMode
|
||||
? 'border-[#303030] bg-[#141414] focus:ring-offset-gray-800'
|
||||
: 'border-gray-300 bg-white focus:ring-offset-white'
|
||||
}`}
|
||||
/>
|
||||
<span className={`text-xs ${themeClasses.optionText}`}>
|
||||
Show archived
|
||||
</span>
|
||||
<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' && <FieldsDropdown themeClasses={themeClasses} isDarkMode={isDarkMode} />}
|
||||
{position === 'list' && (
|
||||
<FieldsDropdown themeClasses={themeClasses} isDarkMode={isDarkMode} />
|
||||
)}
|
||||
</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}`}>
|
||||
<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}`}>
|
||||
<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
|
||||
@@ -1163,8 +1266,9 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`ml-1 rounded-full p-0.5 transition-colors duration-150 ${isDarkMode ? 'hover:bg-blue-800' : 'hover:bg-blue-200'
|
||||
}`}
|
||||
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>
|
||||
@@ -1173,44 +1277,52 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
|
||||
{filterSectionsData
|
||||
.filter(section => section.id !== 'groupBy') // <-- skip groupBy
|
||||
.flatMap((section) =>
|
||||
section.selectedValues.map((value) => {
|
||||
const option = section.options.find(opt => opt.value === value);
|
||||
if (!option) return null;
|
||||
.flatMap(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={() => {
|
||||
// Update local state immediately for UI feedback
|
||||
setFilterSections(prev => prev.map(s =>
|
||||
s.id === section.id
|
||||
? { ...s, selectedValues: s.selectedValues.filter(v => v !== value) }
|
||||
: s
|
||||
));
|
||||
|
||||
// Use debounced API call
|
||||
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'
|
||||
}`}
|
||||
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}`}
|
||||
>
|
||||
<CloseOutlined className="w-2.5 h-2.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}).filter(Boolean)
|
||||
{option.color && (
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: option.color }}
|
||||
/>
|
||||
)}
|
||||
<span>{option.label}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Update local state immediately for UI feedback
|
||||
setFilterSections(prev =>
|
||||
prev.map(s =>
|
||||
s.id === section.id
|
||||
? {
|
||||
...s,
|
||||
selectedValues: s.selectedValues.filter(v => v !== value),
|
||||
}
|
||||
: s
|
||||
)
|
||||
);
|
||||
|
||||
// Use debounced API call
|
||||
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>
|
||||
);
|
||||
})
|
||||
.filter(Boolean)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1218,4 +1330,4 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(ImprovedTaskFilters);
|
||||
export default React.memo(ImprovedTaskFilters);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { PlusOutlined } from '@ant-design/icons';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
|
||||
// Lazy load the existing AssigneeSelector component only when needed (Asana-style)
|
||||
const LazyAssigneeSelector = React.lazy(() =>
|
||||
const LazyAssigneeSelector = React.lazy(() =>
|
||||
import('@/components/AssigneeSelector').then(module => ({ default: module.default }))
|
||||
);
|
||||
|
||||
@@ -19,9 +19,10 @@ const LoadingPlaceholder: React.FC<{ isDarkMode: boolean }> = ({ isDarkMode }) =
|
||||
className={`
|
||||
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
|
||||
transition-colors duration-200 animate-pulse
|
||||
${isDarkMode
|
||||
? 'border-gray-600 bg-gray-800 text-gray-400'
|
||||
: 'border-gray-300 bg-gray-100 text-gray-600'
|
||||
${
|
||||
isDarkMode
|
||||
? 'border-gray-600 bg-gray-800 text-gray-400'
|
||||
: 'border-gray-300 bg-gray-100 text-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
@@ -29,21 +30,24 @@ const LoadingPlaceholder: React.FC<{ isDarkMode: boolean }> = ({ isDarkMode }) =
|
||||
</div>
|
||||
);
|
||||
|
||||
const LazyAssigneeSelectorWrapper: React.FC<LazyAssigneeSelectorProps> = ({
|
||||
task,
|
||||
groupId = null,
|
||||
isDarkMode = false
|
||||
const LazyAssigneeSelectorWrapper: React.FC<LazyAssigneeSelectorProps> = ({
|
||||
task,
|
||||
groupId = null,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
||||
const [showComponent, setShowComponent] = useState(false);
|
||||
|
||||
const handleInteraction = useCallback((e: React.MouseEvent) => {
|
||||
// Don't prevent the event from bubbling, just mark as loaded
|
||||
if (!hasLoadedOnce) {
|
||||
setHasLoadedOnce(true);
|
||||
setShowComponent(true);
|
||||
}
|
||||
}, [hasLoadedOnce]);
|
||||
const handleInteraction = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Don't prevent the event from bubbling, just mark as loaded
|
||||
if (!hasLoadedOnce) {
|
||||
setHasLoadedOnce(true);
|
||||
setShowComponent(true);
|
||||
}
|
||||
},
|
||||
[hasLoadedOnce]
|
||||
);
|
||||
|
||||
// If not loaded yet, show a simple placeholder button
|
||||
if (!hasLoadedOnce) {
|
||||
@@ -54,9 +58,10 @@ const LazyAssigneeSelectorWrapper: React.FC<LazyAssigneeSelectorProps> = ({
|
||||
className={`
|
||||
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 text-gray-400'
|
||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
||||
${
|
||||
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'
|
||||
}
|
||||
`}
|
||||
title="Add assignee"
|
||||
@@ -69,13 +74,9 @@ const LazyAssigneeSelectorWrapper: React.FC<LazyAssigneeSelectorProps> = ({
|
||||
// Once loaded, show the full component
|
||||
return (
|
||||
<Suspense fallback={<LoadingPlaceholder isDarkMode={isDarkMode} />}>
|
||||
<LazyAssigneeSelector
|
||||
task={task}
|
||||
groupId={groupId}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
<LazyAssigneeSelector task={task} groupId={groupId} isDarkMode={isDarkMode} />
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default LazyAssigneeSelectorWrapper;
|
||||
export default LazyAssigneeSelectorWrapper;
|
||||
|
||||
@@ -3,14 +3,14 @@
|
||||
/* GPU acceleration for smooth animations */
|
||||
will-change: transform, opacity;
|
||||
transform: translateZ(0);
|
||||
|
||||
|
||||
/* Smooth backdrop blur with fallback */
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
|
||||
|
||||
/* Prevent layout shifts */
|
||||
contain: layout style paint;
|
||||
|
||||
|
||||
/* Optimize for animations */
|
||||
animation-fill-mode: both;
|
||||
animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -57,16 +57,16 @@
|
||||
/* GPU acceleration */
|
||||
will-change: transform, background-color;
|
||||
transform: translateZ(0);
|
||||
|
||||
|
||||
/* Smooth hover transitions */
|
||||
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
|
||||
/* Prevent text selection */
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
|
||||
|
||||
/* Optimize for touch */
|
||||
touch-action: manipulation;
|
||||
}
|
||||
@@ -143,14 +143,14 @@
|
||||
padding: 10px 16px;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
|
||||
.bulk-action-button {
|
||||
/* Smaller buttons on mobile */
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
|
||||
/* Hide some actions on very small screens */
|
||||
.bulk-action-secondary {
|
||||
display: none;
|
||||
@@ -164,7 +164,7 @@
|
||||
padding: 8px 12px;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
|
||||
/* Show only essential actions */
|
||||
.bulk-action-tertiary {
|
||||
display: none;
|
||||
@@ -179,7 +179,7 @@
|
||||
backdrop-filter: none;
|
||||
-webkit-backdrop-filter: none;
|
||||
}
|
||||
|
||||
|
||||
.bulk-action-button {
|
||||
border: 1px solid currentColor;
|
||||
}
|
||||
@@ -194,7 +194,7 @@
|
||||
animation: none !important;
|
||||
will-change: auto !important;
|
||||
}
|
||||
|
||||
|
||||
.bulk-action-button:hover {
|
||||
transform: none;
|
||||
}
|
||||
@@ -237,7 +237,10 @@
|
||||
|
||||
/* Smooth color transitions for theme switching */
|
||||
.bulk-action-theme-transition {
|
||||
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
||||
transition:
|
||||
background-color 0.3s ease,
|
||||
color 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Optimize for 60fps animations */
|
||||
@@ -250,4 +253,4 @@
|
||||
.bulk-action-stable-layout {
|
||||
contain: layout;
|
||||
transform: translateZ(0);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -17,22 +17,22 @@ const PerformanceAnalysis: React.FC<PerformanceAnalysisProps> = ({ projectId })
|
||||
// Start monitoring
|
||||
const startMonitoring = useCallback(() => {
|
||||
setIsMonitoring(true);
|
||||
|
||||
|
||||
// Start all monitoring
|
||||
const stopFrameRate = performanceMonitor.startFrameRateMonitoring();
|
||||
const stopLongTasks = performanceMonitor.startLongTaskMonitoring();
|
||||
const stopLayoutThrashing = performanceMonitor.startLayoutThrashingMonitoring();
|
||||
|
||||
|
||||
// Set up periodic memory monitoring
|
||||
const memoryInterval = setInterval(() => {
|
||||
performanceMonitor.monitorMemory();
|
||||
}, 1000);
|
||||
|
||||
|
||||
// Set up periodic metrics update
|
||||
const metricsInterval = setInterval(() => {
|
||||
setMetrics(performanceMonitor.getMetrics());
|
||||
}, 2000);
|
||||
|
||||
|
||||
const cleanup = () => {
|
||||
stopFrameRate();
|
||||
stopLongTasks();
|
||||
@@ -40,7 +40,7 @@ const PerformanceAnalysis: React.FC<PerformanceAnalysisProps> = ({ projectId })
|
||||
clearInterval(memoryInterval);
|
||||
clearInterval(metricsInterval);
|
||||
};
|
||||
|
||||
|
||||
setStopMonitoring(() => cleanup);
|
||||
}, []);
|
||||
|
||||
@@ -51,7 +51,7 @@ const PerformanceAnalysis: React.FC<PerformanceAnalysisProps> = ({ projectId })
|
||||
setStopMonitoring(null);
|
||||
}
|
||||
setIsMonitoring(false);
|
||||
|
||||
|
||||
// Generate final report
|
||||
const finalReport = performanceMonitor.generateReport();
|
||||
setReport(finalReport);
|
||||
@@ -129,8 +129,12 @@ const PerformanceAnalysis: React.FC<PerformanceAnalysisProps> = ({ projectId })
|
||||
dataIndex: 'average',
|
||||
key: 'average',
|
||||
render: (text: string, record: any) => {
|
||||
const color = record.status === 'error' ? '#ff4d4f' :
|
||||
record.status === 'warning' ? '#faad14' : '#52c41a';
|
||||
const color =
|
||||
record.status === 'error'
|
||||
? '#ff4d4f'
|
||||
: record.status === 'warning'
|
||||
? '#faad14'
|
||||
: '#52c41a';
|
||||
return <Text style={{ color, fontWeight: 500 }}>{text}</Text>;
|
||||
},
|
||||
},
|
||||
@@ -154,18 +158,16 @@ const PerformanceAnalysis: React.FC<PerformanceAnalysisProps> = ({ projectId })
|
||||
dataIndex: 'status',
|
||||
key: 'status',
|
||||
render: (status: string) => {
|
||||
const color = status === 'error' ? '#ff4d4f' :
|
||||
status === 'warning' ? '#faad14' : '#52c41a';
|
||||
const text = status === 'error' ? 'Poor' :
|
||||
status === 'warning' ? 'Fair' : 'Good';
|
||||
const color = status === 'error' ? '#ff4d4f' : status === 'warning' ? '#faad14' : '#52c41a';
|
||||
const text = status === 'error' ? 'Poor' : status === 'warning' ? 'Fair' : 'Good';
|
||||
return <Text style={{ color, fontWeight: 500 }}>{text}</Text>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title="Performance Analysis"
|
||||
<Card
|
||||
title="Performance Analysis"
|
||||
style={{ marginBottom: 16 }}
|
||||
extra={
|
||||
<Space>
|
||||
@@ -179,9 +181,7 @@ const PerformanceAnalysis: React.FC<PerformanceAnalysisProps> = ({ projectId })
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={clearMetrics}>Clear</Button>
|
||||
{report && (
|
||||
<Button onClick={downloadReport}>Download Report</Button>
|
||||
)}
|
||||
{report && <Button onClick={downloadReport}>Download Report</Button>}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
@@ -205,53 +205,90 @@ const PerformanceAnalysis: React.FC<PerformanceAnalysisProps> = ({ projectId })
|
||||
size="small"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
|
||||
|
||||
<Divider />
|
||||
|
||||
|
||||
<Title level={5}>Key Performance Indicators</Title>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))', gap: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
|
||||
gap: 16,
|
||||
}}
|
||||
>
|
||||
{metrics.fps && (
|
||||
<Card size="small">
|
||||
<Text>Frame Rate</Text>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold', color: getMetricStatus('fps', metrics.fps.average) === 'error' ? '#ff4d4f' : '#52c41a' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color:
|
||||
getMetricStatus('fps', metrics.fps.average) === 'error'
|
||||
? '#ff4d4f'
|
||||
: '#52c41a',
|
||||
}}
|
||||
>
|
||||
{metrics.fps.average.toFixed(1)} FPS
|
||||
</div>
|
||||
<Progress
|
||||
percent={Math.min((metrics.fps.average / 60) * 100, 100)}
|
||||
size="small"
|
||||
status={getMetricStatus('fps', metrics.fps.average) === 'error' ? 'exception' : 'active'}
|
||||
<Progress
|
||||
percent={Math.min((metrics.fps.average / 60) * 100, 100)}
|
||||
size="small"
|
||||
status={
|
||||
getMetricStatus('fps', metrics.fps.average) === 'error' ? 'exception' : 'active'
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
{metrics['memory-used'] && metrics['memory-limit'] && (
|
||||
<Card size="small">
|
||||
<Text>Memory Usage</Text>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold' }}>
|
||||
{((metrics['memory-used'].average / metrics['memory-limit'].average) * 100).toFixed(1)}%
|
||||
{(
|
||||
(metrics['memory-used'].average / metrics['memory-limit'].average) *
|
||||
100
|
||||
).toFixed(1)}
|
||||
%
|
||||
</div>
|
||||
<Progress
|
||||
percent={(metrics['memory-used'].average / metrics['memory-limit'].average) * 100}
|
||||
<Progress
|
||||
percent={(metrics['memory-used'].average / metrics['memory-limit'].average) * 100}
|
||||
size="small"
|
||||
status={(metrics['memory-used'].average / metrics['memory-limit'].average) * 100 > 80 ? 'exception' : 'active'}
|
||||
status={
|
||||
(metrics['memory-used'].average / metrics['memory-limit'].average) * 100 > 80
|
||||
? 'exception'
|
||||
: 'active'
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
{metrics['layout-thrashing-count'] && (
|
||||
<Card size="small">
|
||||
<Text>Layout Thrashing</Text>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold', color: metrics['layout-thrashing-count'].count > 10 ? '#ff4d4f' : '#52c41a' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: metrics['layout-thrashing-count'].count > 10 ? '#ff4d4f' : '#52c41a',
|
||||
}}
|
||||
>
|
||||
{metrics['layout-thrashing-count'].count}
|
||||
</div>
|
||||
<Text type="secondary">Detected instances</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
|
||||
{metrics['long-task-duration'] && (
|
||||
<Card size="small">
|
||||
<Text>Long Tasks</Text>
|
||||
<div style={{ fontSize: '24px', fontWeight: 'bold', color: metrics['long-task-duration'].count > 0 ? '#ff4d4f' : '#52c41a' }}>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '24px',
|
||||
fontWeight: 'bold',
|
||||
color: metrics['long-task-duration'].count > 0 ? '#ff4d4f' : '#52c41a',
|
||||
}}
|
||||
>
|
||||
{metrics['long-task-duration'].count}
|
||||
</div>
|
||||
<Text type="secondary">Tasks > 50ms</Text>
|
||||
@@ -265,14 +302,16 @@ const PerformanceAnalysis: React.FC<PerformanceAnalysisProps> = ({ projectId })
|
||||
<>
|
||||
<Divider />
|
||||
<Title level={5}>Performance Report</Title>
|
||||
<pre style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: 16,
|
||||
borderRadius: 4,
|
||||
fontSize: '12px',
|
||||
maxHeight: '300px',
|
||||
overflow: 'auto'
|
||||
}}>
|
||||
<pre
|
||||
style={{
|
||||
backgroundColor: '#f5f5f5',
|
||||
padding: 16,
|
||||
borderRadius: 4,
|
||||
fontSize: '12px',
|
||||
maxHeight: '300px',
|
||||
overflow: 'auto',
|
||||
}}
|
||||
>
|
||||
{report}
|
||||
</pre>
|
||||
</>
|
||||
@@ -281,4 +320,4 @@ const PerformanceAnalysis: React.FC<PerformanceAnalysisProps> = ({ projectId })
|
||||
);
|
||||
};
|
||||
|
||||
export default PerformanceAnalysis;
|
||||
export default PerformanceAnalysis;
|
||||
|
||||
@@ -2,13 +2,13 @@ import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
Button,
|
||||
import {
|
||||
Button,
|
||||
Typography,
|
||||
taskManagementAntdConfig,
|
||||
PlusOutlined,
|
||||
RightOutlined,
|
||||
DownOutlined
|
||||
PlusOutlined,
|
||||
RightOutlined,
|
||||
DownOutlined,
|
||||
} from '@/shared/antd-imports';
|
||||
import { TaskGroup as TaskGroupType, Task } from '@/types/task-management.types';
|
||||
import { taskManagementSelectors } from '@/features/task-management/task-management.slice';
|
||||
@@ -47,309 +47,314 @@ const GROUP_COLORS = {
|
||||
default: '#d9d9d9',
|
||||
} as const;
|
||||
|
||||
const TaskGroup: React.FC<TaskGroupProps> = React.memo(
|
||||
({
|
||||
group,
|
||||
projectId,
|
||||
currentGrouping,
|
||||
selectedTaskIds,
|
||||
onAddTask,
|
||||
onToggleCollapse,
|
||||
onSelectTask,
|
||||
onToggleSubtasks,
|
||||
}) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(group.collapsed || false);
|
||||
|
||||
|
||||
const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
||||
group,
|
||||
projectId,
|
||||
currentGrouping,
|
||||
selectedTaskIds,
|
||||
onAddTask,
|
||||
onToggleCollapse,
|
||||
onSelectTask,
|
||||
onToggleSubtasks,
|
||||
}) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(group.collapsed || false);
|
||||
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: group.id,
|
||||
data: {
|
||||
type: 'group',
|
||||
groupId: group.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Get all tasks from the store
|
||||
const allTasks = useSelector(taskManagementSelectors.selectAll);
|
||||
|
||||
// Get theme from Redux store
|
||||
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
||||
|
||||
// Get field visibility from taskListFields slice
|
||||
const taskListFields = useSelector((state: RootState) => state.taskManagementFields) as TaskListField[];
|
||||
|
||||
|
||||
|
||||
// Define all possible columns
|
||||
const allFixedColumns = [
|
||||
{ key: 'drag', label: '', width: 40, alwaysVisible: true },
|
||||
{ key: 'select', label: '', width: 40, alwaysVisible: true },
|
||||
{ key: 'key', label: 'KEY', width: 80, fieldKey: 'KEY' },
|
||||
{ key: 'task', label: 'TASK', width: 220, alwaysVisible: true },
|
||||
];
|
||||
|
||||
const allScrollableColumns = [
|
||||
{ key: 'description', label: 'Description', width: 200, fieldKey: 'DESCRIPTION' },
|
||||
{ key: 'progress', label: 'Progress', width: 90, fieldKey: 'PROGRESS' },
|
||||
{ key: 'status', label: 'Status', width: 100, fieldKey: 'STATUS' },
|
||||
{ key: 'members', label: 'Members', width: 150, fieldKey: 'ASSIGNEES' },
|
||||
{ key: 'labels', label: 'Labels', width: 200, fieldKey: 'LABELS' },
|
||||
{ key: 'phase', label: 'Phase', width: 100, fieldKey: 'PHASE' },
|
||||
{ key: 'priority', label: 'Priority', width: 100, fieldKey: 'PRIORITY' },
|
||||
{ key: 'timeTracking', label: 'Time Tracking', width: 120, fieldKey: 'TIME_TRACKING' },
|
||||
{ key: 'estimation', label: 'Estimation', width: 100, fieldKey: 'ESTIMATION' },
|
||||
{ key: 'startDate', label: 'Start Date', width: 120, fieldKey: 'START_DATE' },
|
||||
{ key: 'dueDate', label: 'Due Date', width: 120, fieldKey: 'DUE_DATE' },
|
||||
{ key: 'dueTime', label: 'Due Time', width: 100, fieldKey: 'DUE_TIME' },
|
||||
{ key: 'completedDate', label: 'Completed Date', width: 130, fieldKey: 'COMPLETED_DATE' },
|
||||
{ key: 'createdDate', label: 'Created Date', width: 120, fieldKey: 'CREATED_DATE' },
|
||||
{ key: 'lastUpdated', label: 'Last Updated', width: 130, fieldKey: 'LAST_UPDATED' },
|
||||
{ key: 'reporter', label: 'Reporter', width: 100, fieldKey: 'REPORTER' },
|
||||
];
|
||||
|
||||
// Filter columns based on field visibility
|
||||
const visibleFixedColumns = useMemo(() => {
|
||||
return allFixedColumns.filter(col => {
|
||||
// Always show columns marked as alwaysVisible
|
||||
if (col.alwaysVisible) return true;
|
||||
|
||||
// For other columns, check field visibility
|
||||
if (col.fieldKey) {
|
||||
const field = taskListFields.find(f => f.key === col.fieldKey);
|
||||
return field?.visible ?? false;
|
||||
}
|
||||
|
||||
return false;
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: group.id,
|
||||
data: {
|
||||
type: 'group',
|
||||
groupId: group.id,
|
||||
},
|
||||
});
|
||||
}, [taskListFields, allFixedColumns]);
|
||||
|
||||
const visibleScrollableColumns = useMemo(() => {
|
||||
return allScrollableColumns.filter(col => {
|
||||
// For scrollable columns, check field visibility
|
||||
if (col.fieldKey) {
|
||||
const field = taskListFields.find(f => f.key === col.fieldKey);
|
||||
return field?.visible ?? false;
|
||||
// Get all tasks from the store
|
||||
const allTasks = useSelector(taskManagementSelectors.selectAll);
|
||||
|
||||
// Get theme from Redux store
|
||||
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
||||
|
||||
// Get field visibility from taskListFields slice
|
||||
const taskListFields = useSelector(
|
||||
(state: RootState) => state.taskManagementFields
|
||||
) as TaskListField[];
|
||||
|
||||
// Define all possible columns
|
||||
const allFixedColumns = [
|
||||
{ key: 'drag', label: '', width: 40, alwaysVisible: true },
|
||||
{ key: 'select', label: '', width: 40, alwaysVisible: true },
|
||||
{ key: 'key', label: 'KEY', width: 80, fieldKey: 'KEY' },
|
||||
{ key: 'task', label: 'TASK', width: 220, alwaysVisible: true },
|
||||
];
|
||||
|
||||
const allScrollableColumns = [
|
||||
{ key: 'description', label: 'Description', width: 200, fieldKey: 'DESCRIPTION' },
|
||||
{ key: 'progress', label: 'Progress', width: 90, fieldKey: 'PROGRESS' },
|
||||
{ key: 'status', label: 'Status', width: 100, fieldKey: 'STATUS' },
|
||||
{ key: 'members', label: 'Members', width: 150, fieldKey: 'ASSIGNEES' },
|
||||
{ key: 'labels', label: 'Labels', width: 200, fieldKey: 'LABELS' },
|
||||
{ key: 'phase', label: 'Phase', width: 100, fieldKey: 'PHASE' },
|
||||
{ key: 'priority', label: 'Priority', width: 100, fieldKey: 'PRIORITY' },
|
||||
{ key: 'timeTracking', label: 'Time Tracking', width: 120, fieldKey: 'TIME_TRACKING' },
|
||||
{ key: 'estimation', label: 'Estimation', width: 100, fieldKey: 'ESTIMATION' },
|
||||
{ key: 'startDate', label: 'Start Date', width: 120, fieldKey: 'START_DATE' },
|
||||
{ key: 'dueDate', label: 'Due Date', width: 120, fieldKey: 'DUE_DATE' },
|
||||
{ key: 'dueTime', label: 'Due Time', width: 100, fieldKey: 'DUE_TIME' },
|
||||
{ key: 'completedDate', label: 'Completed Date', width: 130, fieldKey: 'COMPLETED_DATE' },
|
||||
{ key: 'createdDate', label: 'Created Date', width: 120, fieldKey: 'CREATED_DATE' },
|
||||
{ key: 'lastUpdated', label: 'Last Updated', width: 130, fieldKey: 'LAST_UPDATED' },
|
||||
{ key: 'reporter', label: 'Reporter', width: 100, fieldKey: 'REPORTER' },
|
||||
];
|
||||
|
||||
// Filter columns based on field visibility
|
||||
const visibleFixedColumns = useMemo(() => {
|
||||
return allFixedColumns.filter(col => {
|
||||
// Always show columns marked as alwaysVisible
|
||||
if (col.alwaysVisible) return true;
|
||||
|
||||
// For other columns, check field visibility
|
||||
if (col.fieldKey) {
|
||||
const field = taskListFields.find(f => f.key === col.fieldKey);
|
||||
return field?.visible ?? false;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}, [taskListFields, allFixedColumns]);
|
||||
|
||||
const visibleScrollableColumns = useMemo(() => {
|
||||
return allScrollableColumns.filter(col => {
|
||||
// For scrollable columns, check field visibility
|
||||
if (col.fieldKey) {
|
||||
const field = taskListFields.find(f => f.key === col.fieldKey);
|
||||
return field?.visible ?? false;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}, [taskListFields, allScrollableColumns]);
|
||||
|
||||
// Get tasks for this group using memoization for performance
|
||||
const groupTasks = useMemo(() => {
|
||||
return group.taskIds
|
||||
.map(taskId => allTasks.find(task => task.id === taskId))
|
||||
.filter((task): task is Task => task !== undefined);
|
||||
}, [group.taskIds, allTasks]);
|
||||
|
||||
// Calculate group statistics - memoized
|
||||
const { completedTasks, totalTasks, completionRate } = useMemo(() => {
|
||||
const completed = groupTasks.filter(task => task.progress === 100).length;
|
||||
const total = groupTasks.length;
|
||||
const rate = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return {
|
||||
completedTasks: completed,
|
||||
totalTasks: total,
|
||||
completionRate: rate,
|
||||
};
|
||||
}, [groupTasks]);
|
||||
|
||||
// Calculate selection state for the group checkbox
|
||||
const { isAllSelected, isIndeterminate } = useMemo(() => {
|
||||
if (groupTasks.length === 0) {
|
||||
return { isAllSelected: false, isIndeterminate: false };
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
}, [taskListFields, allScrollableColumns]);
|
||||
|
||||
// Get tasks for this group using memoization for performance
|
||||
const groupTasks = useMemo(() => {
|
||||
return group.taskIds
|
||||
.map(taskId => allTasks.find(task => task.id === taskId))
|
||||
.filter((task): task is Task => task !== undefined);
|
||||
}, [group.taskIds, allTasks]);
|
||||
const selectedTasksInGroup = groupTasks.filter(task => selectedTaskIds.includes(task.id));
|
||||
const isAllSelected = selectedTasksInGroup.length === groupTasks.length;
|
||||
const isIndeterminate =
|
||||
selectedTasksInGroup.length > 0 && selectedTasksInGroup.length < groupTasks.length;
|
||||
|
||||
// Calculate group statistics - memoized
|
||||
const { completedTasks, totalTasks, completionRate } = useMemo(() => {
|
||||
const completed = groupTasks.filter(task => task.progress === 100).length;
|
||||
const total = groupTasks.length;
|
||||
const rate = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||
|
||||
return {
|
||||
completedTasks: completed,
|
||||
totalTasks: total,
|
||||
completionRate: rate,
|
||||
};
|
||||
}, [groupTasks]);
|
||||
return { isAllSelected, isIndeterminate };
|
||||
}, [groupTasks, selectedTaskIds]);
|
||||
|
||||
// Calculate selection state for the group checkbox
|
||||
const { isAllSelected, isIndeterminate } = useMemo(() => {
|
||||
if (groupTasks.length === 0) {
|
||||
return { isAllSelected: false, isIndeterminate: false };
|
||||
}
|
||||
|
||||
const selectedTasksInGroup = groupTasks.filter(task => selectedTaskIds.includes(task.id));
|
||||
const isAllSelected = selectedTasksInGroup.length === groupTasks.length;
|
||||
const isIndeterminate = selectedTasksInGroup.length > 0 && selectedTasksInGroup.length < groupTasks.length;
|
||||
|
||||
return { isAllSelected, isIndeterminate };
|
||||
}, [groupTasks, selectedTaskIds]);
|
||||
// Get group color based on grouping type - memoized
|
||||
const groupColor = useMemo(() => {
|
||||
if (group.color) return group.color;
|
||||
|
||||
// Fallback colors based on group value
|
||||
switch (currentGrouping) {
|
||||
case 'status':
|
||||
return (
|
||||
GROUP_COLORS.status[group.groupValue as keyof typeof GROUP_COLORS.status] ||
|
||||
GROUP_COLORS.default
|
||||
);
|
||||
case 'priority':
|
||||
return (
|
||||
GROUP_COLORS.priority[group.groupValue as keyof typeof GROUP_COLORS.priority] ||
|
||||
GROUP_COLORS.default
|
||||
);
|
||||
case 'phase':
|
||||
return GROUP_COLORS.phase;
|
||||
default:
|
||||
return GROUP_COLORS.default;
|
||||
}
|
||||
}, [group.color, group.groupValue, currentGrouping]);
|
||||
|
||||
// Memoized event handlers
|
||||
const handleToggleCollapse = useCallback(() => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
onToggleCollapse?.(group.id);
|
||||
}, [isCollapsed, onToggleCollapse, group.id]);
|
||||
|
||||
// Get group color based on grouping type - memoized
|
||||
const groupColor = useMemo(() => {
|
||||
if (group.color) return group.color;
|
||||
const handleAddTask = useCallback(() => {
|
||||
onAddTask?.(group.id);
|
||||
}, [onAddTask, group.id]);
|
||||
|
||||
// Fallback colors based on group value
|
||||
switch (currentGrouping) {
|
||||
case 'status':
|
||||
return GROUP_COLORS.status[group.groupValue as keyof typeof GROUP_COLORS.status] || GROUP_COLORS.default;
|
||||
case 'priority':
|
||||
return GROUP_COLORS.priority[group.groupValue as keyof typeof GROUP_COLORS.priority] || GROUP_COLORS.default;
|
||||
case 'phase':
|
||||
return GROUP_COLORS.phase;
|
||||
default:
|
||||
return GROUP_COLORS.default;
|
||||
}
|
||||
}, [group.color, group.groupValue, currentGrouping]);
|
||||
|
||||
// Memoized event handlers
|
||||
const handleToggleCollapse = useCallback(() => {
|
||||
setIsCollapsed(!isCollapsed);
|
||||
onToggleCollapse?.(group.id);
|
||||
}, [isCollapsed, onToggleCollapse, group.id]);
|
||||
|
||||
const handleAddTask = useCallback(() => {
|
||||
onAddTask?.(group.id);
|
||||
}, [onAddTask, group.id]);
|
||||
|
||||
// Handle select all tasks in group
|
||||
const handleSelectAllInGroup = useCallback((checked: boolean) => {
|
||||
if (checked) {
|
||||
// Select all tasks in the group
|
||||
groupTasks.forEach(task => {
|
||||
if (!selectedTaskIds.includes(task.id)) {
|
||||
onSelectTask?.(task.id, true);
|
||||
// Handle select all tasks in group
|
||||
const handleSelectAllInGroup = useCallback(
|
||||
(checked: boolean) => {
|
||||
if (checked) {
|
||||
// Select all tasks in the group
|
||||
groupTasks.forEach(task => {
|
||||
if (!selectedTaskIds.includes(task.id)) {
|
||||
onSelectTask?.(task.id, true);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Deselect all tasks in the group
|
||||
groupTasks.forEach(task => {
|
||||
if (selectedTaskIds.includes(task.id)) {
|
||||
onSelectTask?.(task.id, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Deselect all tasks in the group
|
||||
groupTasks.forEach(task => {
|
||||
if (selectedTaskIds.includes(task.id)) {
|
||||
onSelectTask?.(task.id, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [groupTasks, selectedTaskIds, onSelectTask]);
|
||||
},
|
||||
[groupTasks, selectedTaskIds, onSelectTask]
|
||||
);
|
||||
|
||||
// Memoized style object
|
||||
const containerStyle = useMemo(() => ({
|
||||
backgroundColor: isOver
|
||||
? (isDarkMode ? '#1a2332' : '#f0f8ff')
|
||||
: undefined,
|
||||
}), [isOver, isDarkMode]);
|
||||
// Memoized style object
|
||||
const containerStyle = useMemo(
|
||||
() => ({
|
||||
backgroundColor: isOver ? (isDarkMode ? '#1a2332' : '#f0f8ff') : undefined,
|
||||
}),
|
||||
[isOver, isDarkMode]
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`task-group`}
|
||||
style={{ ...containerStyle, overflowX: 'unset' }}
|
||||
>
|
||||
<div className="task-group-scroll-wrapper" style={{ overflowX: 'auto', width: '100%' }}>
|
||||
<div style={{ minWidth: visibleFixedColumns.reduce((sum, col) => sum + col.width, 0) + visibleScrollableColumns.reduce((sum, col) => sum + col.width, 0) }}>
|
||||
{/* Group Header Row */}
|
||||
<div className="task-group-header">
|
||||
<div className="task-group-header-row">
|
||||
<div
|
||||
className="task-group-header-content"
|
||||
style={{ backgroundColor: groupColor }}
|
||||
return (
|
||||
<div className={`task-group`} style={{ ...containerStyle, overflowX: 'unset' }}>
|
||||
<div className="task-group-scroll-wrapper" style={{ overflowX: 'auto', width: '100%' }}>
|
||||
<div
|
||||
style={{
|
||||
minWidth:
|
||||
visibleFixedColumns.reduce((sum, col) => sum + col.width, 0) +
|
||||
visibleScrollableColumns.reduce((sum, col) => sum + col.width, 0),
|
||||
}}
|
||||
>
|
||||
{/* Group Header Row */}
|
||||
<div className="task-group-header">
|
||||
<div className="task-group-header-row">
|
||||
<div className="task-group-header-content" style={{ backgroundColor: groupColor }}>
|
||||
<Button
|
||||
{...taskManagementAntdConfig.taskButtonDefaults}
|
||||
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}` }}
|
||||
>
|
||||
<Button
|
||||
{...taskManagementAntdConfig.taskButtonDefaults}
|
||||
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">
|
||||
{visibleFixedColumns.map(col => (
|
||||
<div
|
||||
key={col.key}
|
||||
className="task-table-cell task-table-header-cell"
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
{col.key === 'select' ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onChange={handleSelectAllInGroup}
|
||||
isDarkMode={isDarkMode}
|
||||
indeterminate={isIndeterminate}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
col.label && <Text className="column-header-text">{col.label}</Text>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="task-table-scrollable-columns">
|
||||
{visibleScrollableColumns.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>
|
||||
)}
|
||||
|
||||
{/* Tasks List */}
|
||||
{!isCollapsed && (
|
||||
<div
|
||||
className="task-group-body"
|
||||
style={{ borderLeft: `4px solid ${groupColor}` }}
|
||||
>
|
||||
{groupTasks.length === 0 ? (
|
||||
<div className="task-group-empty">
|
||||
<div className="task-group-column-headers-row">
|
||||
<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
|
||||
{...taskManagementAntdConfig.taskButtonDefaults}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddTask}
|
||||
className="mt-2"
|
||||
>
|
||||
Add first task
|
||||
</Button>
|
||||
{visibleFixedColumns.map(col => (
|
||||
<div
|
||||
key={col.key}
|
||||
className="task-table-cell task-table-header-cell"
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
{col.key === 'select' ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
onChange={handleSelectAllInGroup}
|
||||
isDarkMode={isDarkMode}
|
||||
indeterminate={isIndeterminate}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
col.label && <Text className="column-header-text">{col.label}</Text>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="task-table-scrollable-columns">
|
||||
{visibleScrollableColumns.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>
|
||||
)}
|
||||
|
||||
{/* Tasks List */}
|
||||
{!isCollapsed && (
|
||||
<div className="task-group-body" 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
|
||||
{...taskManagementAntdConfig.taskButtonDefaults}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddTask}
|
||||
className="mt-2"
|
||||
>
|
||||
Add first task
|
||||
</Button>
|
||||
</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}
|
||||
fixedColumns={visibleFixedColumns}
|
||||
scrollableColumns={visibleScrollableColumns}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
) : (
|
||||
<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={visibleFixedColumns}
|
||||
scrollableColumns={visibleScrollableColumns}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
|
||||
{/* Add Task Row - Always show when not collapsed */}
|
||||
<div className="task-group-add-task">
|
||||
<AddTaskListRow groupId={group.id} />
|
||||
{/* Add Task Row - Always show when not collapsed */}
|
||||
<div className="task-group-add-task">
|
||||
<AddTaskListRow groupId={group.id} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
<style>{`
|
||||
.task-group {
|
||||
border: 1px solid var(--task-border-primary, #e8e8e8);
|
||||
border-radius: 8px;
|
||||
@@ -622,19 +627,21 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
||||
box-shadow: 0 1px 3px var(--task-shadow, rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
// More comprehensive comparison to detect task movements
|
||||
return (
|
||||
prevProps.group.id === nextProps.group.id &&
|
||||
prevProps.group.taskIds.length === nextProps.group.taskIds.length &&
|
||||
prevProps.group.taskIds.every((id, index) => id === nextProps.group.taskIds[index]) &&
|
||||
prevProps.group.collapsed === nextProps.group.collapsed &&
|
||||
prevProps.selectedTaskIds.length === nextProps.selectedTaskIds.length &&
|
||||
prevProps.currentGrouping === nextProps.currentGrouping
|
||||
);
|
||||
});
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) => {
|
||||
// More comprehensive comparison to detect task movements
|
||||
return (
|
||||
prevProps.group.id === nextProps.group.id &&
|
||||
prevProps.group.taskIds.length === nextProps.group.taskIds.length &&
|
||||
prevProps.group.taskIds.every((id, index) => id === nextProps.group.taskIds[index]) &&
|
||||
prevProps.group.collapsed === nextProps.group.collapsed &&
|
||||
prevProps.selectedTaskIds.length === nextProps.selectedTaskIds.length &&
|
||||
prevProps.currentGrouping === nextProps.currentGrouping
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
TaskGroup.displayName = 'TaskGroup';
|
||||
|
||||
|
||||
@@ -138,7 +138,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
|
||||
// Prevent duplicate API calls in React StrictMode
|
||||
const hasInitialized = useRef(false);
|
||||
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Frame rate monitoring and throttling
|
||||
const frameTimeRef = useRef(performance.now());
|
||||
const renderCountRef = useRef(0);
|
||||
@@ -159,7 +159,9 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
const taskGroups = useSelector(selectTaskGroupsV3, shallowEqual);
|
||||
const currentGrouping = useSelector(selectCurrentGroupingV3, shallowEqual);
|
||||
// Use bulk action slice for selected tasks instead of selection slice
|
||||
const selectedTaskIds = useSelector((state: RootState) => state.bulkActionReducer.selectedTaskIdsList);
|
||||
const selectedTaskIds = useSelector(
|
||||
(state: RootState) => state.bulkActionReducer.selectedTaskIdsList
|
||||
);
|
||||
const selectedTasks = useSelector((state: RootState) => state.bulkActionReducer.selectedTasks);
|
||||
const loading = useSelector((state: RootState) => state.taskManagement.loading, shallowEqual);
|
||||
const error = useSelector((state: RootState) => state.taskManagement.error);
|
||||
@@ -180,7 +182,9 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
const tasksById = useMemo(() => {
|
||||
const map: Record<string, Task> = {};
|
||||
// Cache all tasks for full functionality - performance optimizations are handled at the virtualization level
|
||||
tasks.forEach(task => { map[task.id] = task; });
|
||||
tasks.forEach(task => {
|
||||
map[task.id] = task;
|
||||
});
|
||||
return map;
|
||||
}, [tasks]);
|
||||
|
||||
@@ -204,7 +208,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
const now = performance.now();
|
||||
const frameTime = now - frameTimeRef.current;
|
||||
renderCountRef.current++;
|
||||
|
||||
|
||||
// If frame time is consistently over 16.67ms (60fps), enable throttling
|
||||
if (frameTime > 20 && renderCountRef.current > 10) {
|
||||
setShouldThrottle(true);
|
||||
@@ -212,10 +216,10 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
setShouldThrottle(false);
|
||||
renderCountRef.current = 0; // Reset counter
|
||||
}
|
||||
|
||||
|
||||
frameTimeRef.current = now;
|
||||
};
|
||||
|
||||
|
||||
const interval = setInterval(monitorPerformance, 100);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
@@ -224,7 +228,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
useEffect(() => {
|
||||
if (projectId && !hasInitialized.current) {
|
||||
hasInitialized.current = true;
|
||||
|
||||
|
||||
// Fetch real tasks from V3 API (minimal processing needed)
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
}
|
||||
@@ -387,7 +391,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
// Emit socket event to backend
|
||||
if (connected && socket && currentDragState.activeTask) {
|
||||
const currentSession = JSON.parse(localStorage.getItem('session') || '{}');
|
||||
|
||||
|
||||
const socketData = {
|
||||
from_index: sourceIndex,
|
||||
to_index: finalTargetIndex,
|
||||
@@ -459,9 +463,22 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
[dispatch, selectedTasks, selectedTaskIds, tasks]
|
||||
);
|
||||
|
||||
const handleToggleSubtasks = useCallback((taskId: string) => {
|
||||
// Implementation for toggling subtasks
|
||||
}, []);
|
||||
const handleToggleSubtasks = useCallback(
|
||||
(taskId: string) => {
|
||||
const task = tasksById[taskId];
|
||||
if (
|
||||
task &&
|
||||
!task.show_sub_tasks &&
|
||||
task.sub_tasks_count &&
|
||||
task.sub_tasks_count > 0 &&
|
||||
(!task.sub_tasks || task.sub_tasks.length === 0)
|
||||
) {
|
||||
dispatch(fetchSubTasks({ taskId, projectId }));
|
||||
}
|
||||
dispatch(toggleTaskExpansion(taskId));
|
||||
},
|
||||
[dispatch, projectId, tasksById]
|
||||
);
|
||||
|
||||
// Memoized DragOverlay content for better performance
|
||||
const dragOverlayContent = useMemo(() => {
|
||||
@@ -485,92 +502,101 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
dispatch(clearSelection());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleBulkStatusChange = useCallback(async (statusId: string) => {
|
||||
if (!statusId || !projectId) return;
|
||||
try {
|
||||
// Find the status object
|
||||
const status = statusList.find(s => s.id === statusId);
|
||||
if (!status || !status.id) return;
|
||||
const handleBulkStatusChange = useCallback(
|
||||
async (statusId: string) => {
|
||||
if (!statusId || !projectId) return;
|
||||
try {
|
||||
// Find the status object
|
||||
const status = statusList.find(s => s.id === statusId);
|
||||
if (!status || !status.id) return;
|
||||
|
||||
const body: IBulkTasksStatusChangeRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
status_id: status.id,
|
||||
};
|
||||
|
||||
// Check task dependencies first
|
||||
for (const taskId of selectedTaskIds) {
|
||||
const canContinue = await checkTaskDependencyStatus(taskId, status.id);
|
||||
if (!canContinue) {
|
||||
if (selectedTaskIds.length > 1) {
|
||||
alertService.warning(
|
||||
'Incomplete Dependencies!',
|
||||
'Some tasks were not updated. Please ensure all dependent tasks are completed before proceeding.'
|
||||
);
|
||||
} else {
|
||||
alertService.error(
|
||||
'Task is not completed',
|
||||
'Please complete the task dependencies before proceeding'
|
||||
);
|
||||
const body: IBulkTasksStatusChangeRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
status_id: status.id,
|
||||
};
|
||||
|
||||
// Check task dependencies first
|
||||
for (const taskId of selectedTaskIds) {
|
||||
const canContinue = await checkTaskDependencyStatus(taskId, status.id);
|
||||
if (!canContinue) {
|
||||
if (selectedTaskIds.length > 1) {
|
||||
alertService.warning(
|
||||
'Incomplete Dependencies!',
|
||||
'Some tasks were not updated. Please ensure all dependent tasks are completed before proceeding.'
|
||||
);
|
||||
} else {
|
||||
alertService.error(
|
||||
'Task is not completed',
|
||||
'Please complete the task dependencies before proceeding'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
|
||||
dispatch(deselectAllBulk());
|
||||
dispatch(clearSelection());
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing status:', error);
|
||||
}
|
||||
},
|
||||
[selectedTaskIds, statusList, projectId, trackMixpanelEvent, dispatch]
|
||||
);
|
||||
|
||||
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
|
||||
dispatch(deselectAllBulk());
|
||||
dispatch(clearSelection());
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
const handleBulkPriorityChange = useCallback(
|
||||
async (priorityId: string) => {
|
||||
if (!priorityId || !projectId) return;
|
||||
try {
|
||||
const priority = priorityList.find(p => p.id === priorityId);
|
||||
if (!priority || !priority.id) return;
|
||||
|
||||
const body: IBulkTasksPriorityChangeRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
priority_id: priority.id,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
|
||||
dispatch(deselectAllBulk());
|
||||
dispatch(clearSelection());
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing priority:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing status:', error);
|
||||
}
|
||||
}, [selectedTaskIds, statusList, projectId, trackMixpanelEvent, dispatch]);
|
||||
},
|
||||
[selectedTaskIds, priorityList, projectId, trackMixpanelEvent, dispatch]
|
||||
);
|
||||
|
||||
const handleBulkPriorityChange = useCallback(async (priorityId: string) => {
|
||||
if (!priorityId || !projectId) return;
|
||||
try {
|
||||
const priority = priorityList.find(p => p.id === priorityId);
|
||||
if (!priority || !priority.id) return;
|
||||
const handleBulkPhaseChange = useCallback(
|
||||
async (phaseId: string) => {
|
||||
if (!phaseId || !projectId) return;
|
||||
try {
|
||||
const phase = phaseList.find(p => p.id === phaseId);
|
||||
if (!phase || !phase.id) return;
|
||||
|
||||
const body: IBulkTasksPriorityChangeRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
priority_id: priority.id,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
|
||||
dispatch(deselectAllBulk());
|
||||
dispatch(clearSelection());
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
const body: IBulkTasksPhaseChangeRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
phase_id: phase.id,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
|
||||
dispatch(deselectAllBulk());
|
||||
dispatch(clearSelection());
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing phase:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing priority:', error);
|
||||
}
|
||||
}, [selectedTaskIds, priorityList, projectId, trackMixpanelEvent, dispatch]);
|
||||
|
||||
const handleBulkPhaseChange = useCallback(async (phaseId: string) => {
|
||||
if (!phaseId || !projectId) return;
|
||||
try {
|
||||
const phase = phaseList.find(p => p.id === phaseId);
|
||||
if (!phase || !phase.id) return;
|
||||
|
||||
const body: IBulkTasksPhaseChangeRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
phase_id: phase.id,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
|
||||
dispatch(deselectAllBulk());
|
||||
dispatch(clearSelection());
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing phase:', error);
|
||||
}
|
||||
}, [selectedTaskIds, phaseList, projectId, trackMixpanelEvent, dispatch]);
|
||||
},
|
||||
[selectedTaskIds, phaseList, projectId, trackMixpanelEvent, dispatch]
|
||||
);
|
||||
|
||||
const handleBulkAssignToMe = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
@@ -591,63 +617,67 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
}
|
||||
}, [selectedTaskIds, projectId, trackMixpanelEvent, dispatch]);
|
||||
|
||||
const handleBulkAssignMembers = useCallback(async (memberIds: string[]) => {
|
||||
if (!projectId || !members?.data) return;
|
||||
try {
|
||||
// Convert memberIds to member objects with proper type checking
|
||||
const selectedMembers = members.data.filter(member =>
|
||||
member.id && memberIds.includes(member.id)
|
||||
);
|
||||
|
||||
const body = {
|
||||
tasks: selectedTaskIds,
|
||||
project_id: projectId,
|
||||
members: selectedMembers.map(member => ({
|
||||
id: member.id!,
|
||||
name: member.name || '',
|
||||
email: member.email || '',
|
||||
avatar_url: member.avatar_url || '',
|
||||
team_member_id: member.id!,
|
||||
project_member_id: member.id!,
|
||||
})),
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.assignTasks(body);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
|
||||
dispatch(deselectAllBulk());
|
||||
dispatch(clearSelection());
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error assigning tasks:', error);
|
||||
}
|
||||
}, [selectedTaskIds, projectId, members, trackMixpanelEvent, dispatch]);
|
||||
const handleBulkAssignMembers = useCallback(
|
||||
async (memberIds: string[]) => {
|
||||
if (!projectId || !members?.data) return;
|
||||
try {
|
||||
// Convert memberIds to member objects with proper type checking
|
||||
const selectedMembers = members.data.filter(
|
||||
member => member.id && memberIds.includes(member.id)
|
||||
);
|
||||
|
||||
const handleBulkAddLabels = useCallback(async (labelIds: string[]) => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
// Convert labelIds to label objects with proper type checking
|
||||
const selectedLabels = labelsList.filter(label =>
|
||||
label.id && labelIds.includes(label.id)
|
||||
);
|
||||
|
||||
const body: IBulkTasksLabelsRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
labels: selectedLabels,
|
||||
text: null,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
|
||||
dispatch(deselectAllBulk());
|
||||
dispatch(clearSelection());
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
dispatch(fetchLabels());
|
||||
const body = {
|
||||
tasks: selectedTaskIds,
|
||||
project_id: projectId,
|
||||
members: selectedMembers.map(member => ({
|
||||
id: member.id!,
|
||||
name: member.name || '',
|
||||
email: member.email || '',
|
||||
avatar_url: member.avatar_url || '',
|
||||
team_member_id: member.id!,
|
||||
project_member_id: member.id!,
|
||||
})),
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.assignTasks(body);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
|
||||
dispatch(deselectAllBulk());
|
||||
dispatch(clearSelection());
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error assigning tasks:', error);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating labels:', error);
|
||||
}
|
||||
}, [selectedTaskIds, projectId, labelsList, trackMixpanelEvent, dispatch]);
|
||||
},
|
||||
[selectedTaskIds, projectId, members, trackMixpanelEvent, dispatch]
|
||||
);
|
||||
|
||||
const handleBulkAddLabels = useCallback(
|
||||
async (labelIds: string[]) => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
// Convert labelIds to label objects with proper type checking
|
||||
const selectedLabels = labelsList.filter(label => label.id && labelIds.includes(label.id));
|
||||
|
||||
const body: IBulkTasksLabelsRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
labels: selectedLabels,
|
||||
text: null,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
|
||||
dispatch(deselectAllBulk());
|
||||
dispatch(clearSelection());
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
dispatch(fetchLabels());
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating labels:', error);
|
||||
}
|
||||
},
|
||||
[selectedTaskIds, projectId, labelsList, trackMixpanelEvent, dispatch]
|
||||
);
|
||||
|
||||
const handleBulkArchive = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
@@ -696,9 +726,12 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
// This would need to be implemented in the API service
|
||||
}, [selectedTaskIds]);
|
||||
|
||||
const handleBulkSetDueDate = useCallback(async (date: string) => {
|
||||
// This would need to be implemented in the API service
|
||||
}, [selectedTaskIds]);
|
||||
const handleBulkSetDueDate = useCallback(
|
||||
async (date: string) => {
|
||||
// This would need to be implemented in the API service
|
||||
},
|
||||
[selectedTaskIds]
|
||||
);
|
||||
|
||||
// Cleanup effect
|
||||
useEffect(() => {
|
||||
@@ -757,18 +790,20 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
</div>
|
||||
) : taskGroups.length === 0 ? (
|
||||
<div className="empty-container">
|
||||
<Empty
|
||||
<Empty
|
||||
description={
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
<div style={{ fontSize: '14px', fontWeight: 500, marginBottom: '4px' }}>
|
||||
No task groups available
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: 'var(--task-text-secondary, #595959)' }}>
|
||||
<div
|
||||
style={{ fontSize: '12px', color: 'var(--task-text-secondary, #595959)' }}
|
||||
>
|
||||
Create tasks to see them organized in groups
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -778,7 +813,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
const groupTasks = group.taskIds.length;
|
||||
const baseHeight = 120; // Header + column headers + add task row
|
||||
const taskRowsHeight = groupTasks * 40; // 40px per task row
|
||||
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Enhanced virtualization threshold for better UX
|
||||
const shouldVirtualizeGroup = groupTasks > 25; // Increased threshold for smoother experience
|
||||
const minGroupHeight = shouldVirtualizeGroup ? 200 : 120; // Minimum height for virtualized groups
|
||||
@@ -815,10 +850,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DragOverlay
|
||||
adjustScale={false}
|
||||
dropAnimation={null}
|
||||
>
|
||||
<DragOverlay adjustScale={false} dropAnimation={null}>
|
||||
{dragOverlayContent}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
@@ -1321,4 +1353,4 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListBoard;
|
||||
export default TaskListBoard;
|
||||
|
||||
@@ -12,40 +12,40 @@ interface TaskPhaseDropdownProps {
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
const TaskPhaseDropdown: React.FC<TaskPhaseDropdownProps> = ({
|
||||
task,
|
||||
projectId,
|
||||
isDarkMode = false
|
||||
const TaskPhaseDropdown: React.FC<TaskPhaseDropdownProps> = ({
|
||||
task,
|
||||
projectId,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
const { phaseList } = useAppSelector(state => state.phaseReducer);
|
||||
|
||||
|
||||
// Find current phase details
|
||||
const currentPhase = useMemo(() => {
|
||||
return phaseList.find(phase => phase.name === task.phase);
|
||||
}, [phaseList, task.phase]);
|
||||
|
||||
// Handle phase change
|
||||
const handlePhaseChange = useCallback((phaseId: string, phaseName: string) => {
|
||||
if (!task.id || !phaseId || !connected) return;
|
||||
const handlePhaseChange = useCallback(
|
||||
(phaseId: string, phaseName: string) => {
|
||||
if (!task.id || !phaseId || !connected) return;
|
||||
|
||||
console.log('🎯 Phase change initiated:', { taskId: task.id, phaseId, phaseName });
|
||||
console.log('🎯 Phase change initiated:', { taskId: task.id, phaseId, phaseName });
|
||||
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_PHASE_CHANGE.toString(),
|
||||
{
|
||||
socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
|
||||
task_id: task.id,
|
||||
phase_id: phaseId,
|
||||
parent_task: null, // Assuming top-level tasks for now
|
||||
}
|
||||
);
|
||||
setIsOpen(false);
|
||||
}, [task.id, connected, socket]);
|
||||
});
|
||||
setIsOpen(false);
|
||||
},
|
||||
[task.id, connected, socket]
|
||||
);
|
||||
|
||||
// Handle phase clear
|
||||
const handlePhaseClear = useCallback(() => {
|
||||
@@ -53,14 +53,11 @@ const TaskPhaseDropdown: React.FC<TaskPhaseDropdownProps> = ({
|
||||
|
||||
console.log('🎯 Phase clear initiated:', { taskId: task.id });
|
||||
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_PHASE_CHANGE.toString(),
|
||||
{
|
||||
task_id: task.id,
|
||||
phase_id: null,
|
||||
parent_task: null,
|
||||
}
|
||||
);
|
||||
socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
|
||||
task_id: task.id,
|
||||
phase_id: null,
|
||||
parent_task: null,
|
||||
});
|
||||
setIsOpen(false);
|
||||
}, [task.id, connected, socket]);
|
||||
|
||||
@@ -82,7 +79,7 @@ const TaskPhaseDropdown: React.FC<TaskPhaseDropdownProps> = ({
|
||||
top: rect.bottom + window.scrollY + 4,
|
||||
left: rect.left + window.scrollX,
|
||||
});
|
||||
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
@@ -110,7 +107,7 @@ const TaskPhaseDropdown: React.FC<TaskPhaseDropdownProps> = ({
|
||||
{/* Phase Button - Show "Select" when no phase */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
@@ -121,16 +118,19 @@ const TaskPhaseDropdown: React.FC<TaskPhaseDropdownProps> = ({
|
||||
whitespace-nowrap
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: hasPhase && currentPhase
|
||||
? getPhaseColor(currentPhase)
|
||||
: (isDarkMode ? '#4b5563' : '#9ca3af'),
|
||||
backgroundColor:
|
||||
hasPhase && currentPhase
|
||||
? getPhaseColor(currentPhase)
|
||||
: isDarkMode
|
||||
? '#4b5563'
|
||||
: '#9ca3af',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<span className="truncate">
|
||||
{hasPhase && currentPhase ? formatPhaseName(currentPhase.name || '') : 'Select'}
|
||||
</span>
|
||||
<svg
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -141,131 +141,148 @@ const TaskPhaseDropdown: React.FC<TaskPhaseDropdownProps> = ({
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={`
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={`
|
||||
fixed min-w-[160px] max-w-[220px]
|
||||
rounded border backdrop-blur-xs z-9999
|
||||
${isDarkMode
|
||||
? 'bg-gray-900/95 border-gray-600 shadow-2xl shadow-black/50'
|
||||
: 'bg-white/95 border-gray-200 shadow-2xl shadow-gray-500/20'
|
||||
${
|
||||
isDarkMode
|
||||
? 'bg-gray-900/95 border-gray-600 shadow-2xl shadow-black/50'
|
||||
: 'bg-white/95 border-gray-200 shadow-2xl shadow-gray-500/20'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
zIndex: 9999,
|
||||
animation: 'fadeInScale 0.15s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Phase Options */}
|
||||
<div className="py-1 max-h-64 overflow-y-auto">
|
||||
{/* No Phase Option */}
|
||||
<button
|
||||
onClick={handlePhaseClear}
|
||||
className={`
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
zIndex: 9999,
|
||||
animation: 'fadeInScale 0.15s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Phase Options */}
|
||||
<div className="py-1 max-h-64 overflow-y-auto">
|
||||
{/* No Phase Option */}
|
||||
<button
|
||||
onClick={handlePhaseClear}
|
||||
className={`
|
||||
w-full px-3 py-2.5 text-left text-xs font-medium flex items-center gap-3
|
||||
transition-all duration-150 hover:scale-[1.02] active:scale-[0.98]
|
||||
${isDarkMode
|
||||
? 'hover:bg-gray-700/80 text-gray-100'
|
||||
: 'hover:bg-gray-50/70 text-gray-900'
|
||||
${
|
||||
isDarkMode
|
||||
? 'hover:bg-gray-700/80 text-gray-100'
|
||||
: 'hover:bg-gray-50/70 text-gray-900'
|
||||
}
|
||||
${!hasPhase
|
||||
? (isDarkMode ? 'bg-gray-700/60 ring-1 ring-blue-400/40' : 'bg-blue-50/50 ring-1 ring-blue-200')
|
||||
: ''
|
||||
${
|
||||
!hasPhase
|
||||
? isDarkMode
|
||||
? 'bg-gray-700/60 ring-1 ring-blue-400/40'
|
||||
: 'bg-blue-50/50 ring-1 ring-blue-200'
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
animation: 'slideInFromLeft 0.2s ease-out forwards',
|
||||
}}
|
||||
>
|
||||
{/* Clear Icon */}
|
||||
<div className="flex items-center justify-center w-4 h-4">
|
||||
<ClearOutlined className="w-3 h-3" />
|
||||
</div>
|
||||
|
||||
{/* No Phase Color Indicator */}
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full shadow-sm border-2 ${
|
||||
isDarkMode ? 'border-gray-800/30' : 'border-white/20'
|
||||
}`}
|
||||
style={{ backgroundColor: isDarkMode ? '#4b5563' : '#9ca3af' }}
|
||||
/>
|
||||
|
||||
{/* No Phase Text */}
|
||||
<span className="flex-1 truncate">No Phase</span>
|
||||
|
||||
{/* Current Selection Badge */}
|
||||
{!hasPhase && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${isDarkMode ? 'bg-blue-400' : 'bg-blue-500'}`} />
|
||||
<span className={`text-xs font-medium ${isDarkMode ? 'text-blue-300' : 'text-blue-600'}`}>
|
||||
Current
|
||||
</span>
|
||||
style={{
|
||||
animation: 'slideInFromLeft 0.2s ease-out forwards',
|
||||
}}
|
||||
>
|
||||
{/* Clear Icon */}
|
||||
<div className="flex items-center justify-center w-4 h-4">
|
||||
<ClearOutlined className="w-3 h-3" />
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Phase Options */}
|
||||
{phaseList.map((phase, index) => {
|
||||
const isSelected = phase.name === task.phase;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={phase.id}
|
||||
onClick={() => handlePhaseChange(phase.id!, phase.name!)}
|
||||
className={`
|
||||
{/* No Phase Color Indicator */}
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full shadow-sm border-2 ${
|
||||
isDarkMode ? 'border-gray-800/30' : 'border-white/20'
|
||||
}`}
|
||||
style={{ backgroundColor: isDarkMode ? '#4b5563' : '#9ca3af' }}
|
||||
/>
|
||||
|
||||
{/* No Phase Text */}
|
||||
<span className="flex-1 truncate">No Phase</span>
|
||||
|
||||
{/* Current Selection Badge */}
|
||||
{!hasPhase && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className={`w-1.5 h-1.5 rounded-full ${isDarkMode ? 'bg-blue-400' : 'bg-blue-500'}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${isDarkMode ? 'text-blue-300' : 'text-blue-600'}`}
|
||||
>
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Phase Options */}
|
||||
{phaseList.map((phase, index) => {
|
||||
const isSelected = phase.name === task.phase;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={phase.id}
|
||||
onClick={() => handlePhaseChange(phase.id!, phase.name!)}
|
||||
className={`
|
||||
w-full px-3 py-2.5 text-left text-xs font-medium flex items-center gap-3
|
||||
transition-all duration-150 hover:scale-[1.02] active:scale-[0.98]
|
||||
${isDarkMode
|
||||
? 'hover:bg-gray-700/80 text-gray-100'
|
||||
: 'hover:bg-gray-50/70 text-gray-900'
|
||||
${
|
||||
isDarkMode
|
||||
? 'hover:bg-gray-700/80 text-gray-100'
|
||||
: 'hover:bg-gray-50/70 text-gray-900'
|
||||
}
|
||||
${isSelected
|
||||
? (isDarkMode ? 'bg-gray-700/60 ring-1 ring-blue-400/40' : 'bg-blue-50/50 ring-1 ring-blue-200')
|
||||
: ''
|
||||
${
|
||||
isSelected
|
||||
? isDarkMode
|
||||
? 'bg-gray-700/60 ring-1 ring-blue-400/40'
|
||||
: 'bg-blue-50/50 ring-1 ring-blue-200'
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
animationDelay: `${(index + 1) * 30}ms`,
|
||||
animation: 'slideInFromLeft 0.2s ease-out forwards',
|
||||
}}
|
||||
>
|
||||
{/* Phase Color Indicator */}
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full shadow-sm border-2 ${
|
||||
isDarkMode ? 'border-gray-800/30' : 'border-white/20'
|
||||
}`}
|
||||
style={{ backgroundColor: getPhaseColor(phase) }}
|
||||
/>
|
||||
|
||||
{/* Phase Name */}
|
||||
<span className="flex-1 truncate">
|
||||
{formatPhaseName(phase.name || '')}
|
||||
</span>
|
||||
|
||||
{/* Current Phase Badge */}
|
||||
{isSelected && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${isDarkMode ? 'bg-blue-400' : 'bg-blue-500'}`} />
|
||||
<span className={`text-xs font-medium ${isDarkMode ? 'text-blue-300' : 'text-blue-600'}`}>
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${(index + 1) * 30}ms`,
|
||||
animation: 'slideInFromLeft 0.2s ease-out forwards',
|
||||
}}
|
||||
>
|
||||
{/* Phase Color Indicator */}
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full shadow-sm border-2 ${
|
||||
isDarkMode ? 'border-gray-800/30' : 'border-white/20'
|
||||
}`}
|
||||
style={{ backgroundColor: getPhaseColor(phase) }}
|
||||
/>
|
||||
|
||||
{/* Phase Name */}
|
||||
<span className="flex-1 truncate">{formatPhaseName(phase.name || '')}</span>
|
||||
|
||||
{/* Current Phase Badge */}
|
||||
{isSelected && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className={`w-1.5 h-1.5 rounded-full ${isDarkMode ? 'bg-blue-400' : 'bg-blue-500'}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${isDarkMode ? 'text-blue-300' : 'text-blue-600'}`}
|
||||
>
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* CSS Animations */}
|
||||
{isOpen && createPortal(
|
||||
<style>
|
||||
{`
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<style>
|
||||
{`
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -288,11 +305,11 @@ const TaskPhaseDropdown: React.FC<TaskPhaseDropdownProps> = ({
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>,
|
||||
document.head
|
||||
)}
|
||||
</style>,
|
||||
document.head
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskPhaseDropdown;
|
||||
export default TaskPhaseDropdown;
|
||||
|
||||
@@ -12,43 +12,47 @@ interface TaskPriorityDropdownProps {
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
const TaskPriorityDropdown: React.FC<TaskPriorityDropdownProps> = ({
|
||||
task,
|
||||
projectId,
|
||||
isDarkMode = false
|
||||
const TaskPriorityDropdown: React.FC<TaskPriorityDropdownProps> = ({
|
||||
task,
|
||||
projectId,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
|
||||
|
||||
|
||||
// Find current priority details
|
||||
const currentPriority = useMemo(() => {
|
||||
return priorityList.find(priority =>
|
||||
priority.name?.toLowerCase() === task.priority?.toLowerCase() ||
|
||||
priority.id === task.priority
|
||||
return priorityList.find(
|
||||
priority =>
|
||||
priority.name?.toLowerCase() === task.priority?.toLowerCase() ||
|
||||
priority.id === task.priority
|
||||
);
|
||||
}, [priorityList, task.priority]);
|
||||
|
||||
// Handle priority change
|
||||
const handlePriorityChange = useCallback((priorityId: string, priorityName: string) => {
|
||||
if (!task.id || !priorityId || !connected) return;
|
||||
const handlePriorityChange = useCallback(
|
||||
(priorityId: string, priorityName: string) => {
|
||||
if (!task.id || !priorityId || !connected) return;
|
||||
|
||||
console.log('🎯 Priority change initiated:', { taskId: task.id, priorityId, priorityName });
|
||||
console.log('🎯 Priority change initiated:', { taskId: task.id, priorityId, priorityName });
|
||||
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
priority_id: priorityId,
|
||||
team_id: projectId, // Using projectId as teamId
|
||||
})
|
||||
);
|
||||
setIsOpen(false);
|
||||
}, [task.id, connected, socket, projectId]);
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
priority_id: priorityId,
|
||||
team_id: projectId, // Using projectId as teamId
|
||||
})
|
||||
);
|
||||
setIsOpen(false);
|
||||
},
|
||||
[task.id, connected, socket, projectId]
|
||||
);
|
||||
|
||||
// Calculate dropdown position and handle outside clicks
|
||||
useEffect(() => {
|
||||
@@ -68,7 +72,7 @@ const TaskPriorityDropdown: React.FC<TaskPriorityDropdownProps> = ({
|
||||
top: rect.bottom + window.scrollY + 4,
|
||||
left: rect.left + window.scrollX,
|
||||
});
|
||||
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
@@ -78,12 +82,15 @@ const TaskPriorityDropdown: React.FC<TaskPriorityDropdownProps> = ({
|
||||
}, [isOpen]);
|
||||
|
||||
// Get priority color
|
||||
const getPriorityColor = useCallback((priority: any) => {
|
||||
if (isDarkMode) {
|
||||
return priority?.color_code_dark || priority?.color_code || '#4b5563';
|
||||
}
|
||||
return priority?.color_code || '#6b7280';
|
||||
}, [isDarkMode]);
|
||||
const getPriorityColor = useCallback(
|
||||
(priority: any) => {
|
||||
if (isDarkMode) {
|
||||
return priority?.color_code_dark || priority?.color_code || '#4b5563';
|
||||
}
|
||||
return priority?.color_code || '#6b7280';
|
||||
},
|
||||
[isDarkMode]
|
||||
);
|
||||
|
||||
// Get priority icon
|
||||
const getPriorityIcon = useCallback((priorityName: string) => {
|
||||
@@ -113,7 +120,7 @@ const TaskPriorityDropdown: React.FC<TaskPriorityDropdownProps> = ({
|
||||
{/* Priority Button - Simple text display like status */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
@@ -124,14 +131,20 @@ const TaskPriorityDropdown: React.FC<TaskPriorityDropdownProps> = ({
|
||||
whitespace-nowrap
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: currentPriority ? getPriorityColor(currentPriority) : (isDarkMode ? '#4b5563' : '#9ca3af'),
|
||||
backgroundColor: currentPriority
|
||||
? getPriorityColor(currentPriority)
|
||||
: isDarkMode
|
||||
? '#4b5563'
|
||||
: '#9ca3af',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<span className="truncate">
|
||||
{currentPriority ? formatPriorityName(currentPriority.name || '') : formatPriorityName(task.priority)}
|
||||
{currentPriority
|
||||
? formatPriorityName(currentPriority.name || '')
|
||||
: formatPriorityName(task.priority)}
|
||||
</span>
|
||||
<svg
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -142,89 +155,102 @@ const TaskPriorityDropdown: React.FC<TaskPriorityDropdownProps> = ({
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
{isOpen && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={`
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={`
|
||||
fixed min-w-[160px] max-w-[220px]
|
||||
rounded border backdrop-blur-xs z-9999
|
||||
${isDarkMode
|
||||
? 'bg-gray-900/95 border-gray-600 shadow-2xl shadow-black/50'
|
||||
: 'bg-white/95 border-gray-200 shadow-2xl shadow-gray-500/20'
|
||||
${
|
||||
isDarkMode
|
||||
? 'bg-gray-900/95 border-gray-600 shadow-2xl shadow-black/50'
|
||||
: 'bg-white/95 border-gray-200 shadow-2xl shadow-gray-500/20'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
zIndex: 9999,
|
||||
animation: 'fadeInScale 0.15s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Priority Options */}
|
||||
<div className="py-1 max-h-64 overflow-y-auto">
|
||||
{priorityList.map((priority, index) => {
|
||||
const isSelected = priority.name?.toLowerCase() === task.priority?.toLowerCase() || priority.id === task.priority;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={priority.id}
|
||||
onClick={() => handlePriorityChange(priority.id!, priority.name!)}
|
||||
className={`
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
zIndex: 9999,
|
||||
animation: 'fadeInScale 0.15s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Priority Options */}
|
||||
<div className="py-1 max-h-64 overflow-y-auto">
|
||||
{priorityList.map((priority, index) => {
|
||||
const isSelected =
|
||||
priority.name?.toLowerCase() === task.priority?.toLowerCase() ||
|
||||
priority.id === task.priority;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={priority.id}
|
||||
onClick={() => handlePriorityChange(priority.id!, priority.name!)}
|
||||
className={`
|
||||
w-full px-3 py-2.5 text-left text-xs font-medium flex items-center gap-3
|
||||
transition-all duration-150 hover:scale-[1.02] active:scale-[0.98]
|
||||
${isDarkMode
|
||||
? 'hover:bg-gray-700/80 text-gray-100'
|
||||
: 'hover:bg-gray-50/70 text-gray-900'
|
||||
${
|
||||
isDarkMode
|
||||
? 'hover:bg-gray-700/80 text-gray-100'
|
||||
: 'hover:bg-gray-50/70 text-gray-900'
|
||||
}
|
||||
${isSelected
|
||||
? (isDarkMode ? 'bg-gray-700/60 ring-1 ring-blue-400/40' : 'bg-blue-50/50 ring-1 ring-blue-200')
|
||||
: ''
|
||||
${
|
||||
isSelected
|
||||
? isDarkMode
|
||||
? 'bg-gray-700/60 ring-1 ring-blue-400/40'
|
||||
: 'bg-blue-50/50 ring-1 ring-blue-200'
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
animationDelay: `${index * 30}ms`,
|
||||
animation: 'slideInFromLeft 0.2s ease-out forwards',
|
||||
}}
|
||||
>
|
||||
{/* Priority Icon */}
|
||||
<div className="flex items-center justify-center w-4 h-4">
|
||||
{getPriorityIcon(priority.name || '')}
|
||||
</div>
|
||||
|
||||
{/* Priority Color Indicator */}
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full shadow-sm border-2 ${
|
||||
isDarkMode ? 'border-gray-800/30' : 'border-white/20'
|
||||
}`}
|
||||
style={{ backgroundColor: getPriorityColor(priority) }}
|
||||
/>
|
||||
|
||||
{/* Priority Name */}
|
||||
<span className="flex-1 truncate">
|
||||
{formatPriorityName(priority.name || '')}
|
||||
</span>
|
||||
|
||||
{/* Current Priority Badge */}
|
||||
{isSelected && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${isDarkMode ? 'bg-blue-400' : 'bg-blue-500'}`} />
|
||||
<span className={`text-xs font-medium ${isDarkMode ? 'text-blue-300' : 'text-blue-600'}`}>
|
||||
Current
|
||||
</span>
|
||||
style={{
|
||||
animationDelay: `${index * 30}ms`,
|
||||
animation: 'slideInFromLeft 0.2s ease-out forwards',
|
||||
}}
|
||||
>
|
||||
{/* Priority Icon */}
|
||||
<div className="flex items-center justify-center w-4 h-4">
|
||||
{getPriorityIcon(priority.name || '')}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* Priority Color Indicator */}
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full shadow-sm border-2 ${
|
||||
isDarkMode ? 'border-gray-800/30' : 'border-white/20'
|
||||
}`}
|
||||
style={{ backgroundColor: getPriorityColor(priority) }}
|
||||
/>
|
||||
|
||||
{/* Priority Name */}
|
||||
<span className="flex-1 truncate">
|
||||
{formatPriorityName(priority.name || '')}
|
||||
</span>
|
||||
|
||||
{/* Current Priority Badge */}
|
||||
{isSelected && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className={`w-1.5 h-1.5 rounded-full ${isDarkMode ? 'bg-blue-400' : 'bg-blue-500'}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${isDarkMode ? 'text-blue-300' : 'text-blue-600'}`}
|
||||
>
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* CSS Animations */}
|
||||
{isOpen && createPortal(
|
||||
<style>
|
||||
{`
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<style>
|
||||
{`
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -247,11 +273,11 @@ const TaskPriorityDropdown: React.FC<TaskPriorityDropdownProps> = ({
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>,
|
||||
document.head
|
||||
)}
|
||||
</style>,
|
||||
document.head
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskPriorityDropdown;
|
||||
export default TaskPriorityDropdown;
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
.task-row-optimized {
|
||||
contain: layout style;
|
||||
/* Remove conflicting will-change and transform */
|
||||
transition: background-color 0.15s ease-out, border-color 0.15s ease-out;
|
||||
transition:
|
||||
background-color 0.15s ease-out,
|
||||
border-color 0.15s ease-out;
|
||||
background: var(--task-bg-primary, #fff);
|
||||
color: var(--task-text-primary, #262626);
|
||||
border-color: var(--task-border-primary, #e8e8e8);
|
||||
@@ -165,7 +167,9 @@
|
||||
/* Remove will-change to prevent GPU conflicts */
|
||||
min-height: 40px;
|
||||
/* Simplified transitions */
|
||||
transition: background-color 0.15s ease-out, border-color 0.15s ease-out;
|
||||
transition:
|
||||
background-color 0.15s ease-out,
|
||||
border-color 0.15s ease-out;
|
||||
}
|
||||
|
||||
.task-row-optimized.stable-content * {
|
||||
@@ -190,8 +194,13 @@
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 0.4; }
|
||||
50% { opacity: 0.8; }
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.task-name-edit-active {
|
||||
@@ -293,7 +302,7 @@
|
||||
transition: none !important;
|
||||
animation: none !important;
|
||||
}
|
||||
|
||||
|
||||
.task-row-optimized .animate-pulse {
|
||||
animation: none !important;
|
||||
}
|
||||
@@ -362,7 +371,7 @@
|
||||
contain: strict;
|
||||
will-change: auto;
|
||||
}
|
||||
|
||||
|
||||
.task-row-optimized.initial-load {
|
||||
contain: strict;
|
||||
}
|
||||
@@ -521,7 +530,7 @@
|
||||
color: var(--task-text-primary, #262626);
|
||||
border: 1px solid var(--task-border-primary, #e8e8e8);
|
||||
border-radius: 6px;
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.12);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
|
||||
z-index: 1000;
|
||||
opacity: 0.95;
|
||||
}
|
||||
@@ -538,12 +547,14 @@
|
||||
background: var(--task-bg-primary, #1f1f1f);
|
||||
color: var(--task-text-primary, #fff);
|
||||
border: 1px solid var(--task-border-primary, #303030);
|
||||
box-shadow: 0 6px 16px rgba(0,0,0,0.32);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.32);
|
||||
}
|
||||
|
||||
.task-row-optimized.is-dragging {
|
||||
border: 3px solid #1890ff !important;
|
||||
box-shadow: 0 0 24px 4px #1890ff, 0 6px 16px rgba(0,0,0,0.18);
|
||||
box-shadow:
|
||||
0 0 24px 4px #1890ff,
|
||||
0 6px 16px rgba(0, 0, 0, 0.18);
|
||||
opacity: 0.85 !important;
|
||||
background: var(--task-bg-primary, #fff) !important;
|
||||
z-index: 2000 !important;
|
||||
@@ -553,13 +564,17 @@
|
||||
.dark .task-row-optimized.is-dragging,
|
||||
[data-theme="dark"] .task-row-optimized.is-dragging {
|
||||
border: 3px solid #40a9ff !important;
|
||||
box-shadow: 0 0 24px 4px #40a9ff, 0 6px 16px rgba(0,0,0,0.38);
|
||||
box-shadow:
|
||||
0 0 24px 4px #40a9ff,
|
||||
0 6px 16px rgba(0, 0, 0, 0.38);
|
||||
background: var(--task-bg-primary, #1f1f1f) !important;
|
||||
}
|
||||
|
||||
.task-row-optimized.drag-overlay {
|
||||
border: 3px dashed #ff4d4f !important;
|
||||
box-shadow: 0 0 32px 8px #ff4d4f, 0 6px 16px rgba(0,0,0,0.22);
|
||||
box-shadow:
|
||||
0 0 32px 8px #ff4d4f,
|
||||
0 6px 16px rgba(0, 0, 0, 0.22);
|
||||
background: #fffbe6 !important;
|
||||
opacity: 0.95 !important;
|
||||
z-index: 3000 !important;
|
||||
@@ -568,6 +583,8 @@
|
||||
.dark .task-row-optimized.drag-overlay,
|
||||
[data-theme="dark"] .task-row-optimized.drag-overlay {
|
||||
border: 3px dashed #ff7875 !important;
|
||||
box-shadow: 0 0 32px 8px #ff7875, 0 6px 16px rgba(0,0,0,0.42);
|
||||
box-shadow:
|
||||
0 0 32px 8px #ff7875,
|
||||
0 6px 16px rgba(0, 0, 0, 0.42);
|
||||
background: #2a2a2a !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,11 +68,11 @@ export const formatDateTimeCache = new DateFormatCache();
|
||||
// Optimized date formatters with caching
|
||||
export const formatDate = (dateString?: string): string => {
|
||||
if (!dateString) return '';
|
||||
|
||||
|
||||
if (formatDateCache.has(dateString)) {
|
||||
return formatDateCache.get(dateString)!;
|
||||
}
|
||||
|
||||
|
||||
const formatted = dayjs(dateString).format('MMM DD, YYYY');
|
||||
formatDateCache.set(dateString, formatted);
|
||||
return formatted;
|
||||
@@ -80,11 +80,11 @@ export const formatDate = (dateString?: string): string => {
|
||||
|
||||
export const formatDateTime = (dateString?: string): string => {
|
||||
if (!dateString) return '';
|
||||
|
||||
|
||||
if (formatDateTimeCache.has(dateString)) {
|
||||
return formatDateTimeCache.get(dateString)!;
|
||||
}
|
||||
|
||||
|
||||
const formatted = dayjs(dateString).format('MMM DD, YYYY HH:mm');
|
||||
formatDateTimeCache.set(dateString, formatted);
|
||||
return formatted;
|
||||
@@ -104,18 +104,18 @@ export class PerformanceMonitor {
|
||||
|
||||
startTiming(operation: string): () => void {
|
||||
const startTime = performance.now();
|
||||
|
||||
|
||||
return () => {
|
||||
const endTime = performance.now();
|
||||
const duration = endTime - startTime;
|
||||
|
||||
|
||||
if (!this.metrics.has(operation)) {
|
||||
this.metrics.set(operation, []);
|
||||
}
|
||||
|
||||
|
||||
const operationMetrics = this.metrics.get(operation)!;
|
||||
operationMetrics.push(duration);
|
||||
|
||||
|
||||
// Keep only last 100 measurements
|
||||
if (operationMetrics.length > 100) {
|
||||
operationMetrics.shift();
|
||||
@@ -126,14 +126,14 @@ export class PerformanceMonitor {
|
||||
getAverageTime(operation: string): number {
|
||||
const times = this.metrics.get(operation);
|
||||
if (!times || times.length === 0) return 0;
|
||||
|
||||
|
||||
const sum = times.reduce((acc, time) => acc + time, 0);
|
||||
return sum / times.length;
|
||||
}
|
||||
|
||||
getMetrics(): Record<string, { average: number; count: number; latest: number }> {
|
||||
const result: Record<string, { average: number; count: number; latest: number }> = {};
|
||||
|
||||
|
||||
this.metrics.forEach((times, operation) => {
|
||||
if (times.length > 0) {
|
||||
result[operation] = {
|
||||
@@ -143,7 +143,7 @@ export class PerformanceMonitor {
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -165,8 +165,14 @@ export const taskPropsEqual = (prevTask: Task, nextTask: Task): boolean => {
|
||||
|
||||
// Check commonly changing properties
|
||||
const criticalProps: (keyof Task)[] = [
|
||||
'title', 'progress', 'status', 'priority', 'description',
|
||||
'startDate', 'dueDate', 'updatedAt'
|
||||
'title',
|
||||
'progress',
|
||||
'status',
|
||||
'priority',
|
||||
'description',
|
||||
'startDate',
|
||||
'dueDate',
|
||||
'updatedAt',
|
||||
];
|
||||
|
||||
for (const prop of criticalProps) {
|
||||
@@ -245,19 +251,19 @@ export const getOptimizedClasses = (
|
||||
isSelected: boolean
|
||||
): string => {
|
||||
const classes = ['task-row-optimized'];
|
||||
|
||||
|
||||
if (isDragging) {
|
||||
classes.push('task-row-dragging');
|
||||
}
|
||||
|
||||
|
||||
if (isVirtualized) {
|
||||
classes.push('task-row-virtualized');
|
||||
}
|
||||
|
||||
|
||||
if (isSelected) {
|
||||
classes.push('task-row-selected');
|
||||
}
|
||||
|
||||
|
||||
return classes.join(' ');
|
||||
};
|
||||
|
||||
@@ -284,7 +290,7 @@ export const setupAutoCleanup = (): void => {
|
||||
if (cleanupInterval) {
|
||||
clearInterval(cleanupInterval);
|
||||
}
|
||||
|
||||
|
||||
cleanupInterval = setInterval(() => {
|
||||
clearAllCaches();
|
||||
}, PERFORMANCE_CONSTANTS.CACHE_CLEAR_INTERVAL);
|
||||
@@ -300,7 +306,7 @@ export const teardownAutoCleanup = (): void => {
|
||||
// Initialize auto-cleanup
|
||||
if (typeof window !== 'undefined') {
|
||||
setupAutoCleanup();
|
||||
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', teardownAutoCleanup);
|
||||
}
|
||||
@@ -309,35 +315,40 @@ if (typeof window !== 'undefined') {
|
||||
export const performanceMonitor = PerformanceMonitor.getInstance();
|
||||
|
||||
// Task adapter utilities
|
||||
export const createLabelsAdapter = (task: Task) => ({
|
||||
id: task.id,
|
||||
name: task.title,
|
||||
parent_task_id: undefined,
|
||||
manual_progress: false,
|
||||
all_labels: task.labels?.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color_code: label.color
|
||||
})) || [],
|
||||
labels: task.labels?.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color_code: label.color
|
||||
})) || [],
|
||||
} as any);
|
||||
export const createLabelsAdapter = (task: Task) =>
|
||||
({
|
||||
id: task.id,
|
||||
name: task.title,
|
||||
parent_task_id: undefined,
|
||||
manual_progress: false,
|
||||
all_labels:
|
||||
task.labels?.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color_code: label.color,
|
||||
})) || [],
|
||||
labels:
|
||||
task.labels?.map(label => ({
|
||||
id: label.id,
|
||||
name: label.name,
|
||||
color_code: label.color,
|
||||
})) || [],
|
||||
}) as any;
|
||||
|
||||
export const createAssigneeAdapter = (task: Task) => ({
|
||||
id: task.id,
|
||||
name: task.title,
|
||||
parent_task_id: undefined,
|
||||
manual_progress: false,
|
||||
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);
|
||||
export const createAssigneeAdapter = (task: Task) =>
|
||||
({
|
||||
id: task.id,
|
||||
name: task.title,
|
||||
parent_task_id: undefined,
|
||||
manual_progress: false,
|
||||
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;
|
||||
|
||||
// Color utilities
|
||||
export const getPriorityColor = (priority: string): string => {
|
||||
@@ -346,4 +357,4 @@ export const getPriorityColor = (priority: string): string => {
|
||||
|
||||
export const getStatusColor = (status: string): string => {
|
||||
return STATUS_COLORS[status as keyof typeof STATUS_COLORS] || '#d9d9d9';
|
||||
};
|
||||
};
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,7 +5,10 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { Task } from '@/types/task-management.types';
|
||||
import { updateTask, selectCurrentGroupingV3 } from '@/features/task-management/task-management.slice';
|
||||
import {
|
||||
updateTask,
|
||||
selectCurrentGroupingV3,
|
||||
} from '@/features/task-management/task-management.slice';
|
||||
|
||||
interface TaskStatusDropdownProps {
|
||||
task: Task;
|
||||
@@ -13,10 +16,10 @@ interface TaskStatusDropdownProps {
|
||||
isDarkMode?: boolean;
|
||||
}
|
||||
|
||||
const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
||||
task,
|
||||
projectId,
|
||||
isDarkMode = false
|
||||
const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
||||
task,
|
||||
projectId,
|
||||
isDarkMode = false,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { socket, connected } = useSocket();
|
||||
@@ -24,36 +27,39 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
|
||||
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
||||
const currentGroupingV3 = useAppSelector(selectCurrentGroupingV3);
|
||||
|
||||
|
||||
// Find current status details
|
||||
const currentStatus = useMemo(() => {
|
||||
return statusList.find(status =>
|
||||
status.name?.toLowerCase() === task.status?.toLowerCase() ||
|
||||
status.id === task.status
|
||||
return statusList.find(
|
||||
status =>
|
||||
status.name?.toLowerCase() === task.status?.toLowerCase() || status.id === task.status
|
||||
);
|
||||
}, [statusList, task.status]);
|
||||
|
||||
// Handle status change
|
||||
const handleStatusChange = useCallback((statusId: string, statusName: string) => {
|
||||
if (!task.id || !statusId || !connected) return;
|
||||
const handleStatusChange = useCallback(
|
||||
(statusId: string, statusName: string) => {
|
||||
if (!task.id || !statusId || !connected) return;
|
||||
|
||||
console.log('🎯 Status change initiated:', { taskId: task.id, statusId, statusName });
|
||||
console.log('🎯 Status change initiated:', { taskId: task.id, statusId, statusName });
|
||||
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
status_id: statusId,
|
||||
parent_task: null, // Assuming top-level tasks for now
|
||||
team_id: projectId, // Using projectId as teamId
|
||||
})
|
||||
);
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||
setIsOpen(false);
|
||||
}, [task.id, connected, socket, projectId]);
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
status_id: statusId,
|
||||
parent_task: null, // Assuming top-level tasks for now
|
||||
team_id: projectId, // Using projectId as teamId
|
||||
})
|
||||
);
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||
setIsOpen(false);
|
||||
},
|
||||
[task.id, connected, socket, projectId]
|
||||
);
|
||||
|
||||
// Calculate dropdown position and handle outside clicks
|
||||
useEffect(() => {
|
||||
@@ -73,7 +79,7 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
||||
top: rect.bottom + window.scrollY + 4,
|
||||
left: rect.left + window.scrollX,
|
||||
});
|
||||
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
|
||||
@@ -83,14 +89,15 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
||||
}, [isOpen]);
|
||||
|
||||
// Get status color - enhanced dark mode support
|
||||
const getStatusColor = useCallback((status: any) => {
|
||||
if (isDarkMode) {
|
||||
return status?.color_code_dark || status?.color_code || '#4b5563';
|
||||
}
|
||||
return status?.color_code || '#6b7280';
|
||||
}, [isDarkMode]);
|
||||
|
||||
|
||||
const getStatusColor = useCallback(
|
||||
(status: any) => {
|
||||
if (isDarkMode) {
|
||||
return status?.color_code_dark || status?.color_code || '#4b5563';
|
||||
}
|
||||
return status?.color_code || '#6b7280';
|
||||
},
|
||||
[isDarkMode]
|
||||
);
|
||||
|
||||
// Status display name - format status names by replacing underscores with spaces
|
||||
const getStatusDisplayName = useCallback((status: string) => {
|
||||
@@ -112,7 +119,7 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
||||
{/* Status Button - Rounded Pill Design */}
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={(e) => {
|
||||
onClick={e => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsOpen(!isOpen);
|
||||
@@ -123,12 +130,20 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
||||
whitespace-nowrap
|
||||
`}
|
||||
style={{
|
||||
backgroundColor: currentStatus ? getStatusColor(currentStatus) : (isDarkMode ? '#4b5563' : '#9ca3af'),
|
||||
backgroundColor: currentStatus
|
||||
? getStatusColor(currentStatus)
|
||||
: isDarkMode
|
||||
? '#4b5563'
|
||||
: '#9ca3af',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
<span className="truncate">{currentStatus ? formatStatusName(currentStatus.name || '') : getStatusDisplayName(task.status)}</span>
|
||||
<svg
|
||||
<span className="truncate">
|
||||
{currentStatus
|
||||
? formatStatusName(currentStatus.name || '')
|
||||
: getStatusDisplayName(task.status)}
|
||||
</span>
|
||||
<svg
|
||||
className={`w-3 h-3 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
@@ -139,84 +154,95 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu - Redesigned */}
|
||||
{isOpen && createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={`
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={`
|
||||
fixed min-w-[160px] max-w-[220px]
|
||||
rounded border backdrop-blur-xs z-9999
|
||||
${isDarkMode
|
||||
? 'bg-gray-900/95 border-gray-600 shadow-2xl shadow-black/50'
|
||||
: 'bg-white/95 border-gray-200 shadow-2xl shadow-gray-500/20'
|
||||
${
|
||||
isDarkMode
|
||||
? 'bg-gray-900/95 border-gray-600 shadow-2xl shadow-black/50'
|
||||
: 'bg-white/95 border-gray-200 shadow-2xl shadow-gray-500/20'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
zIndex: 9999,
|
||||
animation: 'fadeInScale 0.15s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Status Options */}
|
||||
<div className="py-1 max-h-64 overflow-y-auto">
|
||||
{statusList.map((status, index) => {
|
||||
const isSelected = status.name?.toLowerCase() === task.status?.toLowerCase() || status.id === task.status;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={status.id}
|
||||
onClick={() => handleStatusChange(status.id!, status.name!)}
|
||||
className={`
|
||||
style={{
|
||||
top: dropdownPosition.top,
|
||||
left: dropdownPosition.left,
|
||||
zIndex: 9999,
|
||||
animation: 'fadeInScale 0.15s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Status Options */}
|
||||
<div className="py-1 max-h-64 overflow-y-auto">
|
||||
{statusList.map((status, index) => {
|
||||
const isSelected =
|
||||
status.name?.toLowerCase() === task.status?.toLowerCase() ||
|
||||
status.id === task.status;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={status.id}
|
||||
onClick={() => handleStatusChange(status.id!, status.name!)}
|
||||
className={`
|
||||
w-full px-3 py-2.5 text-left text-xs font-medium flex items-center gap-3
|
||||
transition-all duration-150 hover:scale-[1.02] active:scale-[0.98]
|
||||
${isDarkMode
|
||||
? 'hover:bg-gray-700/80 text-gray-100'
|
||||
: 'hover:bg-gray-50/70 text-gray-900'
|
||||
${
|
||||
isDarkMode
|
||||
? 'hover:bg-gray-700/80 text-gray-100'
|
||||
: 'hover:bg-gray-50/70 text-gray-900'
|
||||
}
|
||||
${isSelected
|
||||
? (isDarkMode ? 'bg-gray-700/60 ring-1 ring-blue-400/40' : 'bg-blue-50/50 ring-1 ring-blue-200')
|
||||
: ''
|
||||
${
|
||||
isSelected
|
||||
? isDarkMode
|
||||
? 'bg-gray-700/60 ring-1 ring-blue-400/40'
|
||||
: 'bg-blue-50/50 ring-1 ring-blue-200'
|
||||
: ''
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
animationDelay: `${index * 30}ms`,
|
||||
animation: 'slideInFromLeft 0.2s ease-out forwards',
|
||||
}}
|
||||
>
|
||||
{/* Status Color Indicator */}
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full shadow-sm border-2 ${
|
||||
isDarkMode ? 'border-gray-800/30' : 'border-white/20'
|
||||
}`}
|
||||
style={{ backgroundColor: getStatusColor(status) }}
|
||||
/>
|
||||
|
||||
{/* Status Name */}
|
||||
<span className="flex-1 truncate">
|
||||
{formatStatusName(status.name || '')}
|
||||
</span>
|
||||
|
||||
{/* Current Status Badge */}
|
||||
{isSelected && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className={`w-1.5 h-1.5 rounded-full ${isDarkMode ? 'bg-blue-400' : 'bg-blue-500'}`} />
|
||||
<span className={`text-xs font-medium ${isDarkMode ? 'text-blue-300' : 'text-blue-600'}`}>
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
style={{
|
||||
animationDelay: `${index * 30}ms`,
|
||||
animation: 'slideInFromLeft 0.2s ease-out forwards',
|
||||
}}
|
||||
>
|
||||
{/* Status Color Indicator */}
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full shadow-sm border-2 ${
|
||||
isDarkMode ? 'border-gray-800/30' : 'border-white/20'
|
||||
}`}
|
||||
style={{ backgroundColor: getStatusColor(status) }}
|
||||
/>
|
||||
|
||||
{/* Status Name */}
|
||||
<span className="flex-1 truncate">{formatStatusName(status.name || '')}</span>
|
||||
|
||||
{/* Current Status Badge */}
|
||||
{isSelected && (
|
||||
<div className="flex items-center gap-1">
|
||||
<div
|
||||
className={`w-1.5 h-1.5 rounded-full ${isDarkMode ? 'bg-blue-400' : 'bg-blue-500'}`}
|
||||
/>
|
||||
<span
|
||||
className={`text-xs font-medium ${isDarkMode ? 'text-blue-300' : 'text-blue-600'}`}
|
||||
>
|
||||
Current
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
|
||||
{/* CSS Animations - Injected as style tag */}
|
||||
{isOpen && createPortal(
|
||||
<style>
|
||||
{`
|
||||
{isOpen &&
|
||||
createPortal(
|
||||
<style>
|
||||
{`
|
||||
@keyframes fadeInScale {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -239,11 +265,11 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
|
||||
}
|
||||
}
|
||||
`}
|
||||
</style>,
|
||||
document.head
|
||||
)}
|
||||
</style>,
|
||||
document.head
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskStatusDropdown;
|
||||
export default TaskStatusDropdown;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user