Merge pull request #238 from Worklenz/fix/task-drag-and-drop-improvement

Fix/task drag and drop improvement
This commit is contained in:
Chamika J
2025-07-07 07:05:59 +05:30
committed by GitHub
42 changed files with 1100 additions and 627 deletions

View File

@@ -20,7 +20,8 @@ import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import useTabSearchParam from '@/hooks/useTabSearchParam';
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
import { toggleField } from '@/features/task-management/taskListFields.slice';
import { toggleField, syncFieldWithDatabase } from '@/features/task-management/taskListFields.slice';
import { selectColumns } from '@/features/task-management/task-management.slice';
// Import Redux actions
import {
@@ -372,6 +373,7 @@ const FilterDropdown: React.FC<{
isDarkMode,
className = '',
}) => {
const { t } = useTranslation('task-list-filters');
// Add permission checks for groupBy section
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const isProjectManager = useIsProjectManager();
@@ -440,14 +442,14 @@ const FilterDropdown: React.FC<{
{/* Trigger Button */}
<button
onClick={onToggle}
className={`
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-gray-600 text-white border-gray-500'
: 'bg-blue-50 text-blue-800 border-blue-300 font-semibold'
: 'bg-gray-200 text-gray-800 border-gray-300 font-semibold'
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
}
hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2
@@ -458,7 +460,14 @@ const FilterDropdown: React.FC<{
>
<IconComponent className="w-3.5 h-3.5" />
<span>{section.label}</span>
{selectedCount > 0 && (
{/* Show selected option for single-select (group by) */}
{section.id === 'groupBy' && selectedCount > 0 && (
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-600'}`}>
{section.options.find(opt => opt.value === section.selectedValues[0])?.label}
</span>
)}
{/* Show count for multi-select filters */}
{section.id !== 'groupBy' && selectedCount > 0 && (
<span className="inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-gray-500 rounded-full">
{selectedCount}
</span>
@@ -489,7 +498,7 @@ const FilterDropdown: React.FC<{
<input
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
placeholder={`Search ${section.label.toLowerCase()}...`}
placeholder={`${t('searchPlaceholder')} ${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'
@@ -504,7 +513,7 @@ const FilterDropdown: React.FC<{
<div className="max-h-48 overflow-y-auto">
{filteredOptions.length === 0 ? (
<div className={`p-2 text-xs text-center ${themeClasses.secondaryText}`}>
No options found
{t('noOptionsFound')}
</div>
) : (
<div className="p-0.5">
@@ -522,24 +531,26 @@ const FilterDropdown: React.FC<{
isSelected
? isDarkMode
? 'bg-gray-600 text-white'
: 'bg-blue-50 text-blue-800 font-semibold'
: 'bg-gray-200 text-gray-800 font-semibold'
: `${themeClasses.optionText} ${themeClasses.optionHover}`
}
`}
>
{/* Checkbox/Radio indicator */}
<div
className={`
flex items-center justify-center w-3.5 h-3.5 border rounded
${
isSelected
? 'bg-gray-600 border-gray-800 text-white'
: 'border-gray-300 dark:border-gray-600'
}
`}
>
{isSelected && <CheckOutlined className="w-2.5 h-2.5" />}
</div>
{/* Checkbox/Radio indicator - hide for group by */}
{section.id !== 'groupBy' && (
<div
className={`
flex items-center justify-center w-3.5 h-3.5 border rounded
${
isSelected
? 'bg-gray-600 border-gray-800 text-white'
: 'border-gray-300 dark:border-gray-600'
}
`}
>
{isSelected && <CheckOutlined className="w-2.5 h-2.5" />}
</div>
)}
{/* Color indicator */}
{option.color && (
@@ -588,11 +599,21 @@ const SearchFilter: React.FC<{
placeholder?: string;
themeClasses: any;
className?: string;
}> = ({ value, onChange, placeholder = 'Search tasks...', themeClasses, className = '' }) => {
}> = ({ value, onChange, placeholder, themeClasses, className = '' }) => {
const { t } = useTranslation('task-list-filters');
const [isExpanded, setIsExpanded] = useState(false);
const [localValue, setLocalValue] = useState(value);
const inputRef = useRef<HTMLInputElement>(null);
// Sync local value with external value prop
useEffect(() => {
setLocalValue(value);
// Keep expanded if there's a search value
if (value) {
setIsExpanded(true);
}
}, [value]);
const handleToggle = useCallback(() => {
setIsExpanded(!isExpanded);
if (!isExpanded) {
@@ -611,7 +632,6 @@ const SearchFilter: React.FC<{
const handleClear = useCallback(() => {
setLocalValue('');
onChange('');
setIsExpanded(false);
}, [onChange]);
// Redux selectors for theme and other state
@@ -619,17 +639,17 @@ const SearchFilter: React.FC<{
return (
<div className={`relative ${className}`}>
{!isExpanded ? (
{!isExpanded && !value ? (
<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} ${
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-gray-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>
<span>{t('search')}</span>
</button>
) : (
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
@@ -640,18 +660,22 @@ const SearchFilter: React.FC<{
type="text"
value={localValue}
onChange={e => setLocalValue(e.target.value)}
placeholder={placeholder}
className={`w-full pr-4 pl-8 py-1 rounded border focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-150 ${
isDarkMode
? 'bg-gray-700 text-gray-100 placeholder-gray-400 border-gray-600'
: 'bg-white text-gray-900 placeholder-gray-400 border-gray-300'
}`}
placeholder={placeholder || t('searchTasks')}
className={`w-full pr-4 pl-8 py-1 rounded border focus:outline-none focus:ring-2 focus:ring-gray-500 transition-colors duration-150 ${
isDarkMode
? 'bg-gray-700 text-gray-100 placeholder-gray-400 border-gray-600'
: 'bg-white text-gray-900 placeholder-gray-400 border-gray-300'
}`}
/>
{localValue && (
<button
type="button"
onClick={handleClear}
className={`absolute right-1.5 top-1/2 transform -translate-y-1/2 ${themeClasses.secondaryText} hover:${themeClasses.optionText} transition-colors duration-150`}
className={`absolute right-1.5 top-1/2 transform -translate-y-1/2 transition-colors duration-150 ${
isDarkMode
? 'text-gray-400 hover:text-gray-200'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<CloseOutlined className="w-3.5 h-3.5" />
</button>
@@ -659,16 +683,28 @@ const SearchFilter: React.FC<{
</div>
<button
type="submit"
className="px-2.5 py-1.5 text-xs font-medium text-white bg-blue-500 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition-colors duration-200"
>
Search
</button>
className={`px-2.5 py-1.5 text-xs font-medium rounded-md focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2 transition-colors duration-200 ${
isDarkMode
? 'text-white bg-gray-600 hover:bg-gray-700'
: 'text-gray-800 bg-gray-200 hover:bg-gray-300'
}`}
>
{t('search')}
</button>
<button
type="button"
onClick={() => setIsExpanded(false)}
className={`px-2.5 py-1.5 text-xs font-medium transition-colors duration-200 ${themeClasses.secondaryText} hover:${themeClasses.optionText}`}
onClick={() => {
setLocalValue('');
onChange('');
setIsExpanded(false);
}}
className={`px-2.5 py-1.5 text-xs font-medium transition-colors duration-200 ${
isDarkMode
? 'text-gray-400 hover:text-gray-200'
: 'text-gray-600 hover:text-gray-800'
}`}
>
Cancel
{t('cancel')}
</button>
</form>
)}
@@ -682,8 +718,11 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
themeClasses,
isDarkMode,
}) => {
const dispatch = useDispatch();
const { t } = useTranslation('task-list-filters');
const dispatch = useAppDispatch();
const fieldsRaw = useSelector((state: RootState) => state.taskManagementFields);
const columns = useSelector(selectColumns);
const projectId = useAppSelector(state => state.projectReducer.projectId);
const fields = Array.isArray(fieldsRaw) ? fieldsRaw : [];
const sortedFields = useMemo(() => [...fields].sort((a, b) => a.order - b.order), [fields]);
@@ -734,17 +773,17 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
visibleCount > 0
? isDarkMode
? 'bg-gray-600 text-white border-gray-500'
: 'bg-blue-50 text-blue-800 border-blue-300 font-semibold'
: 'bg-gray-200 text-gray-800 border-gray-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
hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2
${isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'}
`}
aria-expanded={open}
aria-haspopup="true"
>
<EyeOutlined className="w-3.5 h-3.5" />
<span>Fields</span>
<span>{t('fieldsText')}</span>
{visibleCount > 0 && (
<span
className={`inline-flex items-center justify-center w-4 h-4 text-xs font-bold ${isDarkMode ? 'text-white bg-gray-500' : 'text-gray-800 bg-gray-300'} rounded-full`}
@@ -765,9 +804,9 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
{/* Options List */}
<div className="max-h-48 overflow-y-auto">
{sortedFields.length === 0 ? (
<div className={`p-2 text-xs text-center ${themeClasses.secondaryText}`}>
No fields available
</div>
<div className={`p-2 text-xs text-center ${themeClasses.secondaryText}`}>
{t('noOptionsFound')}
</div>
) : (
<div className="p-0.5">
{sortedFields.map(field => {
@@ -776,7 +815,20 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
return (
<button
key={field.key}
onClick={() => dispatch(toggleField(field.key))}
onClick={() => {
// Toggle field locally first
dispatch(toggleField(field.key));
// Sync with database if projectId is available
if (projectId) {
dispatch(syncFieldWithDatabase({
projectId,
fieldKey: field.key,
visible: !field.visible,
columns
}));
}
}}
className={`
w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded
transition-colors duration-150 text-left
@@ -839,9 +891,14 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
// Use the filter data loader hook
useFilterDataLoader();
// Get search value from Redux based on position
const taskReducerSearch = useAppSelector(state => state.taskReducer?.search || '');
const kanbanSearch = useAppSelector(state => state.enhancedKanbanReducer?.search || '');
const searchValue = position === 'board' ? kanbanSearch : taskReducerSearch;
// Local state for filter sections
const [filterSections, setFilterSections] = useState<FilterSection[]>([]);
const [searchValue, setSearchValue] = useState('');
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
const [activeFiltersCount, setActiveFiltersCount] = useState(0);
const [clearingFilters, setClearingFilters] = useState(false);
@@ -858,8 +915,9 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
const filterSectionsData = useFilterData(position);
// Check if data is loaded - memoize this computation
// Keep filters visible even during refetch if we have any filter sections
const isDataLoaded = useMemo(() => {
return filterSectionsData.some(section => section.options.length > 0);
return filterSectionsData.length > 0;
}, [filterSectionsData]);
// Initialize filter sections from data - memoize this to prevent unnecessary updates
@@ -881,7 +939,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
const { projectView } = useTabSearchParam();
// 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
// Using greyish colors for both dark and light modes
const themeClasses = useMemo(
() => ({
containerBg: isDarkMode ? 'bg-[#1f1f1f]' : 'bg-white',
@@ -897,8 +955,8 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
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',
pillActiveBg: isDarkMode ? 'bg-gray-600' : 'bg-gray-200',
pillActiveText: isDarkMode ? 'text-white' : 'text-gray-800',
searchBg: isDarkMode ? 'bg-[#141414]' : 'bg-gray-50',
searchBorder: isDarkMode ? 'border-[#303030]' : 'border-gray-300',
searchText: isDarkMode ? 'text-[#d9d9d9]' : 'text-gray-900',
@@ -916,12 +974,8 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
// 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));
}
// Always use taskReducer search for list view since that's what we read from
dispatch(setSearch(value));
// Trigger task refetch with new search value
dispatch(fetchTasksV3(projectId));
@@ -1052,8 +1106,6 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
const handleSearchChange = useCallback(
(value: string) => {
setSearchValue(value);
if (!projectId) return;
if (position === 'board') {
@@ -1062,7 +1114,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
dispatch(fetchEnhancedKanbanGroups(projectId));
}
} else {
// Use debounced search
// Use debounced search for list view
if (projectId) {
debouncedSearchChangeRef.current?.(projectId, value);
}
@@ -1084,9 +1136,6 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
// Batch all state updates together to prevent multiple re-renders
const batchUpdates = () => {
// Clear local state immediately for UI feedback
setSearchValue('');
// Update local filter sections state immediately
setFilterSections(prev =>
prev.map(section => ({
@@ -1101,12 +1150,8 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
// Prepare all Redux actions to be dispatched together
const reduxUpdates = () => {
// Clear search based on view
if (projectView === 'list') {
dispatch(setSearch(''));
} else {
dispatch(setKanbanBoardSearch(''));
}
// Clear search - always use taskReducer for list view
dispatch(setSearch(''));
// Clear label filters
const clearedLabels = currentTaskLabels.map(label => ({
@@ -1196,12 +1241,12 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
))
) : (
// Loading state
<div
className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${themeClasses.secondaryText}`}
>
<div className="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-blue-500"></div>
<span>Loading filters...</span>
</div>
<div
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-gray-500"></div>
<span>{t('loadingFilters')}</span>
</div>
)}
</div>
@@ -1211,20 +1256,20 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
{activeFiltersCount > 0 && (
<div className="flex items-center gap-1.5">
<span className={`text-xs ${themeClasses.secondaryText}`}>
{activeFiltersCount} filter{activeFiltersCount !== 1 ? 's' : ''} active
{activeFiltersCount} {activeFiltersCount !== 1 ? t('filtersActive') : t('filterActive')}
</span>
<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'
? 'text-gray-400 cursor-not-allowed'
: isDarkMode
? 'text-gray-400 hover:text-gray-300'
: 'text-gray-600 hover:text-gray-700'
}`}
>
{clearingFilters ? 'Clearing...' : 'Clear all'}
{clearingFilters ? t('clearing') : t('clearAll')}
</button>
</div>
)}
@@ -1236,14 +1281,13 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
type="checkbox"
checked={showArchived}
onChange={toggleArchived}
className={`w-3.5 h-3.5 text-blue-600 rounded focus:ring-blue-500 transition-colors duration-150 ${
className={`w-3.5 h-3.5 text-gray-600 rounded focus:ring-gray-500 transition-colors duration-150 ${
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>
<InboxOutlined className={`w-3.5 h-3.5 ${themeClasses.secondaryText}`} />
<span className={`text-xs ${themeClasses.optionText}`}>{t('showArchivedText')}</span>
</label>
)}
@@ -1253,97 +1297,6 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
)}
</div>
</div>
{/* Active Filters Pills */}
{activeFiltersCount > 0 && (
<div
className={`flex flex-wrap items-center gap-1.5 mt-2 pt-2 border-t ${themeClasses.dividerBorder}`}
>
{searchValue && (
<div
className={`inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-medium rounded-full ${themeClasses.pillActiveBg} ${themeClasses.pillActiveText}`}
>
<SearchOutlined className="w-2.5 h-2.5" />
<span>"{searchValue}"</span>
<button
onClick={() => {
setSearchValue('');
if (projectId) {
// Cancel pending search and immediately clear
debouncedSearchChangeRef.current?.cancel();
if (position === 'board') {
dispatch(setKanbanSearch(''));
dispatch(fetchEnhancedKanbanGroups(projectId));
} else {
if (projectView === 'list') {
dispatch(setSearch(''));
} else {
dispatch(setKanbanBoardSearch(''));
}
dispatch(fetchTasksV3(projectId));
}
}
}}
className={`ml-1 rounded-full p-0.5 transition-colors duration-150 ${
isDarkMode ? 'hover:bg-blue-800' : 'hover:bg-blue-200'
}`}
>
<CloseOutlined className="w-2.5 h-2.5" />
</button>
</div>
)}
{filterSectionsData
.filter(section => section.id !== 'groupBy') // <-- skip groupBy
.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'
}`}
>
<CloseOutlined className="w-2.5 h-2.5" />
</button>
</div>
);
})
.filter(Boolean)
)}
</div>
)}
</div>
);
};

View File

@@ -1017,7 +1017,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
<div className="task-indicators flex items-center gap-2">
{/* Comments indicator */}
{(task as any).comments_count > 0 && (
<Tooltip title={t('taskManagement.comments', 'Comments')}>
<Tooltip title={t(`task-management:indicators.tooltips.comments${(task as any).comments_count === 1 ? '' : '_plural'}`, { count: (task as any).comments_count })}>
<MessageOutlined
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
/>
@@ -1025,7 +1025,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
)}
{/* Attachments indicator */}
{(task as any).attachments_count > 0 && (
<Tooltip title={t('taskManagement.attachments', 'Attachments')}>
<Tooltip title={t(`task-management:indicators.tooltips.attachments${(task as any).attachments_count === 1 ? '' : '_plural'}`, { count: (task as any).attachments_count })}>
<PaperClipOutlined
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
/>
@@ -1033,7 +1033,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
)}
{/* Dependencies indicator */}
{(task as any).has_dependencies && (
<Tooltip title={t('taskManagement.dependencies', 'Dependencies')}>
<Tooltip title={t('task-management:indicators.tooltips.dependencies')}>
<MinusCircleOutlined
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
/>
@@ -1041,7 +1041,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
)}
{/* Subscribers indicator */}
{(task as any).has_subscribers && (
<Tooltip title={t('taskManagement.subscribers', 'Subscribers')}>
<Tooltip title={t('task-management:indicators.tooltips.subscribers')}>
<EyeOutlined
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
/>
@@ -1049,7 +1049,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(
)}
{/* Recurring indicator */}
{(task as any).schedule_id && (
<Tooltip title={t('taskManagement.recurringTask', 'Recurring Task')}>
<Tooltip title={t('task-management:indicators.tooltips.recurring')}>
<RetweetOutlined
style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }}
/>