expand sub tasks

This commit is contained in:
chamiakJ
2025-07-03 01:31:05 +05:30
parent 3bef18901a
commit ecd4d29a38
435 changed files with 13150 additions and 11087 deletions

View File

@@ -37,4 +37,4 @@
/* Hide drag handle during drag */
[data-dnd-dragging="true"] .drag-handle-optimized {
opacity: 0;
}
}

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);
}
}

View File

@@ -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 &gt; 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;

View File

@@ -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';

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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

View File

@@ -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;