Merge branch 'main' of https://github.com/Worklenz/worklenz into test/row-kanban-board-v1.2.0
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
import apiClient from '@/api/api-client';
|
||||
import { API_BASE_URL } from '@/shared/constants';
|
||||
import { IServerResponse } from '@/types/common.types';
|
||||
import apiClient from '@api/api-client';
|
||||
import { API_BASE_URL } from '@/shared/constants';
|
||||
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||
import { toQueryString } from '@/utils/toQueryString';
|
||||
|
||||
const rootUrl = `${API_BASE_URL}/task-phases`;
|
||||
|
||||
interface UpdateSortOrderBody {
|
||||
export interface UpdateSortOrderBody {
|
||||
from_index: number;
|
||||
to_index: number;
|
||||
phases: ITaskPhase[];
|
||||
@@ -14,9 +14,10 @@ interface UpdateSortOrderBody {
|
||||
}
|
||||
|
||||
export const phasesApiService = {
|
||||
addPhaseOption: async (projectId: string) => {
|
||||
addPhaseOption: async (projectId: string, name?: string) => {
|
||||
const q = toQueryString({ id: projectId, current_project_id: projectId });
|
||||
const response = await apiClient.post<IServerResponse<ITaskPhase>>(`${rootUrl}${q}`);
|
||||
const body = name ? { name } : {};
|
||||
const response = await apiClient.post<IServerResponse<ITaskPhase>>(`${rootUrl}${q}`, body);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
|
||||
@@ -139,7 +139,7 @@ const EnhancedKanbanCreateSection: React.FC = () => {
|
||||
}
|
||||
if (groupBy === IGroupBy.PHASE) {
|
||||
try {
|
||||
const response = await phasesApiService.addPhaseOption(projectId);
|
||||
const response = await phasesApiService.addPhaseOption(projectId, name);
|
||||
if (response.done && response.body) {
|
||||
dispatch(fetchEnhancedKanbanGroups(projectId));
|
||||
}
|
||||
|
||||
@@ -73,8 +73,17 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = ({
|
||||
|
||||
if (tasks.length === 0) {
|
||||
return (
|
||||
<div className="virtualized-empty-state" style={{ height }}>
|
||||
<div className="empty-message">No tasks in this group</div>
|
||||
<div className="virtualized-empty-state" style={{ height, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<div className="empty-message" style={{
|
||||
padding: '32px 24px',
|
||||
color: '#8c8c8c',
|
||||
fontSize: '14px',
|
||||
backgroundColor: '#fafafa',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #f0f0f0'
|
||||
}}>
|
||||
No tasks in this group
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ const KanbanGroup: React.FC<TaskGroupProps> = ({
|
||||
.kanban-group-empty {
|
||||
text-align: center;
|
||||
color: #bfbfbf;
|
||||
padding: 32px 0;
|
||||
padding: 48px 16px;
|
||||
}
|
||||
.kanban-group-add-task {
|
||||
padding: 12px;
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface GroupProgressBarProps {
|
||||
todoProgress: number;
|
||||
doingProgress: number;
|
||||
doneProgress: number;
|
||||
groupType: string;
|
||||
}
|
||||
|
||||
const GroupProgressBar: React.FC<GroupProgressBarProps> = ({
|
||||
todoProgress,
|
||||
doingProgress,
|
||||
doneProgress,
|
||||
groupType
|
||||
}) => {
|
||||
const { t } = useTranslation('task-management');
|
||||
|
||||
// Only show for priority and phase grouping
|
||||
if (groupType !== 'priority' && groupType !== 'phase') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const total = todoProgress + doingProgress + doneProgress;
|
||||
|
||||
// Don't show if no progress values exist
|
||||
if (total === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Compact progress text */}
|
||||
<span className="text-xs text-gray-600 dark:text-gray-400 whitespace-nowrap font-medium">
|
||||
{doneProgress}% {t('done')}
|
||||
</span>
|
||||
|
||||
{/* Compact progress bar */}
|
||||
<div className="w-20 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 overflow-hidden">
|
||||
<div className="h-full flex">
|
||||
{/* Todo section - light green */}
|
||||
{todoProgress > 0 && (
|
||||
<div
|
||||
className="bg-green-200 dark:bg-green-800 transition-all duration-300"
|
||||
style={{ width: `${(todoProgress / total) * 100}%` }}
|
||||
title={`${t('todo')}: ${todoProgress}%`}
|
||||
/>
|
||||
)}
|
||||
{/* Doing section - medium green */}
|
||||
{doingProgress > 0 && (
|
||||
<div
|
||||
className="bg-green-400 dark:bg-green-600 transition-all duration-300"
|
||||
style={{ width: `${(doingProgress / total) * 100}%` }}
|
||||
title={`${t('inProgress')}: ${doingProgress}%`}
|
||||
/>
|
||||
)}
|
||||
{/* Done section - dark green */}
|
||||
{doneProgress > 0 && (
|
||||
<div
|
||||
className="bg-green-600 dark:bg-green-400 transition-all duration-300"
|
||||
style={{ width: `${(doneProgress / total) * 100}%` }}
|
||||
title={`${t('done')}: ${doneProgress}%`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Small legend dots with better spacing */}
|
||||
<div className="flex items-center gap-1">
|
||||
{todoProgress > 0 && (
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-green-200 dark:bg-green-800 rounded-full"
|
||||
title={`${t('todo')}: ${todoProgress}%`}
|
||||
/>
|
||||
)}
|
||||
{doingProgress > 0 && (
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-green-400 dark:bg-green-600 rounded-full"
|
||||
title={`${t('inProgress')}: ${doingProgress}%`}
|
||||
/>
|
||||
)}
|
||||
{doneProgress > 0 && (
|
||||
<div
|
||||
className="w-1.5 h-1.5 bg-green-600 dark:bg-green-400 rounded-full"
|
||||
title={`${t('done')}: ${doneProgress}%`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupProgressBar;
|
||||
@@ -3,12 +3,13 @@ import { useDroppable } from '@dnd-kit/core';
|
||||
// @ts-ignore: Heroicons module types
|
||||
import { ChevronDownIcon, ChevronRightIcon, EllipsisHorizontalIcon, PencilIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
|
||||
import { Checkbox, Dropdown, Menu, Input, Modal, Badge, Flex } from 'antd';
|
||||
import GroupProgressBar from './GroupProgressBar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getContrastColor } from '@/utils/colorUtils';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { selectSelectedTaskIds, selectTask, deselectTask } from '@/features/task-management/selection.slice';
|
||||
import { selectGroups, fetchTasksV3 } from '@/features/task-management/task-management.slice';
|
||||
import { selectGroups, fetchTasksV3, selectAllTasksArray } from '@/features/task-management/task-management.slice';
|
||||
import { selectCurrentGrouping } from '@/features/task-management/grouping.slice';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
|
||||
@@ -27,6 +28,10 @@ interface TaskGroupHeaderProps {
|
||||
name: string;
|
||||
count: number;
|
||||
color?: string; // Color for the group indicator
|
||||
todo_progress?: number;
|
||||
doing_progress?: number;
|
||||
done_progress?: number;
|
||||
groupType?: string;
|
||||
};
|
||||
isCollapsed: boolean;
|
||||
onToggle: () => void;
|
||||
@@ -38,13 +43,14 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
||||
const dispatch = useAppDispatch();
|
||||
const selectedTaskIds = useAppSelector(selectSelectedTaskIds);
|
||||
const groups = useAppSelector(selectGroups);
|
||||
const allTasks = useAppSelector(selectAllTasksArray);
|
||||
const currentGrouping = useAppSelector(selectCurrentGrouping);
|
||||
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
|
||||
const { statusCategories, status: statusList } = useAppSelector(state => state.taskStatusReducer);
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const { isOwnerOrAdmin } = useAuthService();
|
||||
|
||||
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||
const [categoryModalVisible, setCategoryModalVisible] = useState(false);
|
||||
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [isChangingCategory, setIsChangingCategory] = useState(false);
|
||||
const [isEditingName, setIsEditingName] = useState(false);
|
||||
@@ -62,6 +68,74 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
||||
return currentGroup?.taskIds || [];
|
||||
}, [currentGroup]);
|
||||
|
||||
// Calculate group progress values dynamically
|
||||
const groupProgressValues = useMemo(() => {
|
||||
if (!currentGroup || !allTasks.length) {
|
||||
return { todoProgress: 0, doingProgress: 0, doneProgress: 0 };
|
||||
}
|
||||
|
||||
const tasksInCurrentGroup = currentGroup.taskIds
|
||||
.map(taskId => allTasks.find(task => task.id === taskId))
|
||||
.filter(task => task !== undefined);
|
||||
|
||||
if (tasksInCurrentGroup.length === 0) {
|
||||
return { todoProgress: 0, doingProgress: 0, doneProgress: 0 };
|
||||
}
|
||||
|
||||
// If we're grouping by status, show progress based on task completion
|
||||
if (currentGrouping === 'status') {
|
||||
// For status grouping, calculate based on task progress values
|
||||
const progressStats = tasksInCurrentGroup.reduce((acc, task) => {
|
||||
const progress = task.progress || 0;
|
||||
if (progress === 0) {
|
||||
acc.todo += 1;
|
||||
} else if (progress === 100) {
|
||||
acc.done += 1;
|
||||
} else {
|
||||
acc.doing += 1;
|
||||
}
|
||||
return acc;
|
||||
}, { todo: 0, doing: 0, done: 0 });
|
||||
|
||||
const totalTasks = tasksInCurrentGroup.length;
|
||||
|
||||
return {
|
||||
todoProgress: totalTasks > 0 ? Math.round((progressStats.todo / totalTasks) * 100) : 0,
|
||||
doingProgress: totalTasks > 0 ? Math.round((progressStats.doing / totalTasks) * 100) : 0,
|
||||
doneProgress: totalTasks > 0 ? Math.round((progressStats.done / totalTasks) * 100) : 0,
|
||||
};
|
||||
} else {
|
||||
// For priority/phase grouping, show progress based on status distribution
|
||||
// Use a simplified approach based on status names and common patterns
|
||||
const statusCounts = tasksInCurrentGroup.reduce((acc, task) => {
|
||||
// Find the status by ID first
|
||||
const statusInfo = statusList.find(s => s.id === task.status);
|
||||
const statusName = statusInfo?.name?.toLowerCase() || task.status?.toLowerCase() || '';
|
||||
|
||||
// Categorize based on common status name patterns
|
||||
if (statusName.includes('todo') || statusName.includes('to do') || statusName.includes('pending') || statusName.includes('open') || statusName.includes('backlog')) {
|
||||
acc.todo += 1;
|
||||
} else if (statusName.includes('doing') || statusName.includes('progress') || statusName.includes('active') || statusName.includes('working') || statusName.includes('development')) {
|
||||
acc.doing += 1;
|
||||
} else if (statusName.includes('done') || statusName.includes('completed') || statusName.includes('finished') || statusName.includes('closed') || statusName.includes('resolved')) {
|
||||
acc.done += 1;
|
||||
} else {
|
||||
// Default unknown statuses to "doing" (in progress)
|
||||
acc.doing += 1;
|
||||
}
|
||||
return acc;
|
||||
}, { todo: 0, doing: 0, done: 0 });
|
||||
|
||||
const totalTasks = tasksInCurrentGroup.length;
|
||||
|
||||
return {
|
||||
todoProgress: totalTasks > 0 ? Math.round((statusCounts.todo / totalTasks) * 100) : 0,
|
||||
doingProgress: totalTasks > 0 ? Math.round((statusCounts.doing / totalTasks) * 100) : 0,
|
||||
doneProgress: totalTasks > 0 ? Math.round((statusCounts.done / totalTasks) * 100) : 0,
|
||||
};
|
||||
}
|
||||
}, [currentGroup, allTasks, statusList, currentGrouping]);
|
||||
|
||||
// Calculate selection state for this group
|
||||
const { isAllSelected, isPartiallySelected } = useMemo(() => {
|
||||
if (tasksInGroup.length === 0) {
|
||||
@@ -94,7 +168,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
||||
|
||||
// Handle inline name editing
|
||||
const handleNameSave = useCallback(async () => {
|
||||
if (!editingName.trim() || editingName.trim() === group.name || isRenaming) return;
|
||||
// If no changes or already renaming, just exit editing mode
|
||||
if (!editingName.trim() || editingName.trim() === group.name || isRenaming) {
|
||||
setIsEditingName(false);
|
||||
setEditingName(group.name);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsRenaming(true);
|
||||
try {
|
||||
@@ -122,12 +201,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
||||
|
||||
// Refresh task list to get updated group names
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
setIsEditingName(false);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error renaming group:', error);
|
||||
setEditingName(group.name);
|
||||
} finally {
|
||||
setIsEditingName(false);
|
||||
setIsRenaming(false);
|
||||
}
|
||||
}, [editingName, group.name, group.id, currentGrouping, projectId, dispatch, trackMixpanelEvent, isRenaming]);
|
||||
@@ -150,9 +229,8 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
||||
}, [group.name, handleNameSave]);
|
||||
|
||||
const handleNameBlur = useCallback(() => {
|
||||
setIsEditingName(false);
|
||||
setEditingName(group.name);
|
||||
}, [group.name]);
|
||||
handleNameSave();
|
||||
}, [handleNameSave]);
|
||||
|
||||
// Handle dropdown menu actions
|
||||
const handleRenameGroup = useCallback(() => {
|
||||
@@ -161,10 +239,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
||||
setEditingName(group.name);
|
||||
}, [group.name]);
|
||||
|
||||
const handleChangeCategory = useCallback(() => {
|
||||
setDropdownVisible(false);
|
||||
setCategoryModalVisible(true);
|
||||
}, []);
|
||||
|
||||
|
||||
// Handle category change
|
||||
const handleCategoryChange = useCallback(async (categoryId: string, e?: React.MouseEvent) => {
|
||||
@@ -182,7 +257,6 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
||||
// Refresh status list and tasks
|
||||
dispatch(fetchStatuses(projectId));
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
setCategoryModalVisible(false);
|
||||
|
||||
} catch (error) {
|
||||
logger.error('Error changing category:', error);
|
||||
@@ -209,19 +283,30 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
||||
|
||||
// Only show "Change Category" when grouped by status
|
||||
if (currentGrouping === 'status') {
|
||||
items.push({
|
||||
key: 'changeCategory',
|
||||
icon: <ArrowPathIcon className="h-4 w-4" />,
|
||||
label: t('changeCategory'),
|
||||
const categorySubMenuItems = statusCategories.map((category) => ({
|
||||
key: `category-${category.id}`,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge color={category.color_code} />
|
||||
<span>{category.name}</span>
|
||||
</div>
|
||||
),
|
||||
onClick: (e: any) => {
|
||||
e?.domEvent?.stopPropagation();
|
||||
handleChangeCategory();
|
||||
handleCategoryChange(category.id || '', e?.domEvent);
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
||||
items.push({
|
||||
key: 'changeCategory',
|
||||
icon: <ArrowPathIcon className="h-4 w-4" />,
|
||||
label: t('changeCategory'),
|
||||
children: categorySubMenuItems,
|
||||
} as any);
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [currentGrouping, handleRenameGroup, handleChangeCategory, isOwnerOrAdmin]);
|
||||
}, [currentGrouping, handleRenameGroup, handleCategoryChange, isOwnerOrAdmin, statusCategories, t]);
|
||||
|
||||
// Make the group header droppable
|
||||
const { isOver, setNodeRef } = useDroppable({
|
||||
@@ -232,75 +317,146 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${
|
||||
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
|
||||
color: headerTextColor,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 25, // Higher than task rows but lower than column headers (z-30)
|
||||
height: '36px',
|
||||
minHeight: '36px',
|
||||
maxHeight: '36px'
|
||||
}}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{/* Drag Handle Space - ultra minimal width */}
|
||||
<div style={{ width: '20px' }} className="flex items-center justify-center">
|
||||
{/* Chevron button */}
|
||||
<button
|
||||
className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
|
||||
style={{ backgroundColor: 'transparent', color: headerTextColor }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="transition-transform duration-300 ease-out"
|
||||
style={{
|
||||
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)',
|
||||
transformOrigin: 'center'
|
||||
return (
|
||||
<div className="relative flex items-center">
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${
|
||||
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor,
|
||||
color: headerTextColor,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
zIndex: 25, // Higher than task rows but lower than column headers (z-30)
|
||||
height: '36px',
|
||||
minHeight: '36px',
|
||||
maxHeight: '36px'
|
||||
}}
|
||||
onClick={onToggle}
|
||||
>
|
||||
{/* Drag Handle Space - ultra minimal width */}
|
||||
<div style={{ width: '20px' }} className="flex items-center justify-center">
|
||||
{/* Chevron button */}
|
||||
<button
|
||||
className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
|
||||
style={{ backgroundColor: 'transparent', color: headerTextColor }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggle();
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon className="h-3 w-3" style={{ color: headerTextColor }} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
className="transition-transform duration-300 ease-out"
|
||||
style={{
|
||||
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)',
|
||||
transformOrigin: 'center'
|
||||
}}
|
||||
>
|
||||
<ChevronRightIcon className="h-3 w-3" style={{ color: headerTextColor }} />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Select All Checkbox Space - ultra minimal width */}
|
||||
<div style={{ width: '28px' }} className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isPartiallySelected}
|
||||
onChange={handleSelectAllChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
color: headerTextColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{/* Select All Checkbox Space - ultra minimal width */}
|
||||
<div style={{ width: '28px' }} className="flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isAllSelected}
|
||||
indeterminate={isPartiallySelected}
|
||||
onChange={handleSelectAllChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
color: headerTextColor,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Group indicator and name - no gap at all */}
|
||||
{/* Group indicator and name - no gap at all */}
|
||||
<div className="flex items-center flex-1 ml-1">
|
||||
{/* Group name and count */}
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className="text-sm font-semibold pr-2"
|
||||
style={{ color: headerTextColor }}
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
{isEditingName ? (
|
||||
<Input
|
||||
value={editingName}
|
||||
onChange={(e) => setEditingName(e.target.value)}
|
||||
onKeyDown={handleNameKeyDown}
|
||||
onBlur={handleNameBlur}
|
||||
autoFocus
|
||||
size="small"
|
||||
className="text-sm font-semibold"
|
||||
style={{
|
||||
width: 'auto',
|
||||
minWidth: '100px',
|
||||
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||
color: headerTextColor,
|
||||
border: '1px solid rgba(255, 255, 255, 0.3)'
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-sm font-semibold pr-2 cursor-pointer hover:underline"
|
||||
style={{ color: headerTextColor }}
|
||||
onClick={handleNameClick}
|
||||
>
|
||||
{group.name}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm font-semibold ml-1" style={{ color: headerTextColor }}>
|
||||
({group.count})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Three-dot menu - only show for status and phase grouping */}
|
||||
{menuItems.length > 0 && (currentGrouping === 'status' || currentGrouping === 'phase') && (
|
||||
<div className="flex items-center ml-2">
|
||||
<Dropdown
|
||||
menu={{ items: menuItems }}
|
||||
trigger={['click']}
|
||||
open={dropdownVisible}
|
||||
onOpenChange={setDropdownVisible}
|
||||
placement="bottomRight"
|
||||
overlayStyle={{ zIndex: 1000 }}
|
||||
>
|
||||
<button
|
||||
className="p-1 rounded-sm hover:bg-black hover:bg-opacity-10 transition-colors duration-200"
|
||||
style={{ color: headerTextColor }}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setDropdownVisible(!dropdownVisible);
|
||||
}}
|
||||
>
|
||||
<EllipsisHorizontalIcon className="h-4 w-4" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Progress Bar - sticky to the right edge during horizontal scroll */}
|
||||
{(currentGrouping === 'priority' || currentGrouping === 'phase') &&
|
||||
(groupProgressValues.todoProgress || groupProgressValues.doingProgress || groupProgressValues.doneProgress) && (
|
||||
<div
|
||||
className="flex items-center bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded-md shadow-sm px-3 py-1.5 ml-auto"
|
||||
style={{
|
||||
position: 'sticky',
|
||||
right: '16px',
|
||||
zIndex: 35, // Higher than header
|
||||
minWidth: '160px',
|
||||
height: '30px'
|
||||
}}
|
||||
>
|
||||
<GroupProgressBar
|
||||
todoProgress={groupProgressValues.todoProgress}
|
||||
doingProgress={groupProgressValues.doingProgress}
|
||||
doneProgress={groupProgressValues.doneProgress}
|
||||
groupType={group.groupType || currentGrouping || ''}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -54,6 +54,7 @@ import {
|
||||
setCustomColumnModalAttributes,
|
||||
toggleCustomColumnModalOpen,
|
||||
} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
||||
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
|
||||
|
||||
// Components
|
||||
import TaskRowWithSubtasks from './TaskRowWithSubtasks';
|
||||
@@ -64,6 +65,7 @@ import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-t
|
||||
import AddTaskRow from './components/AddTaskRow';
|
||||
import { AddCustomColumnButton, CustomColumnHeader } from './components/CustomColumnComponents';
|
||||
import TaskListSkeleton from './components/TaskListSkeleton';
|
||||
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
|
||||
|
||||
// Hooks and utilities
|
||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||
@@ -212,6 +214,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
if (urlProjectId) {
|
||||
dispatch(fetchTasksV3(urlProjectId));
|
||||
dispatch(fetchTaskListColumns(urlProjectId));
|
||||
dispatch(fetchPhasesByProjectId(urlProjectId));
|
||||
}
|
||||
}, [dispatch, urlProjectId]);
|
||||
|
||||
@@ -452,6 +455,10 @@ const TaskListV2Section: React.FC = () => {
|
||||
name: group.title,
|
||||
count: group.actualCount,
|
||||
color: group.color,
|
||||
todo_progress: group.todo_progress,
|
||||
doing_progress: group.doing_progress,
|
||||
done_progress: group.done_progress,
|
||||
groupType: group.groupType,
|
||||
}}
|
||||
isCollapsed={isGroupCollapsed}
|
||||
onToggle={() => handleGroupCollapse(group.id)}
|
||||
@@ -459,7 +466,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
/>
|
||||
{isGroupEmpty && !isGroupCollapsed && (
|
||||
<div className="relative w-full">
|
||||
<div className="flex items-center min-w-max px-1 py-3">
|
||||
<div className="flex items-center min-w-max px-1 py-6">
|
||||
{visibleColumns.map((column, index) => {
|
||||
const emptyColumnStyle = {
|
||||
width: column.width,
|
||||
@@ -478,7 +485,7 @@ const TaskListV2Section: React.FC = () => {
|
||||
})}
|
||||
</div>
|
||||
<div className="absolute left-4 top-1/2 transform -translate-y-1/2 flex items-center">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-900 px-3 py-1.5 rounded-md border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 bg-white dark:bg-gray-900 px-4 py-3 rounded-md border border-gray-200 dark:border-gray-700 shadow-sm">
|
||||
{t('noTasksInGroup')}
|
||||
</div>
|
||||
</div>
|
||||
@@ -760,6 +767,9 @@ const TaskListV2Section: React.FC = () => {
|
||||
|
||||
{/* Custom Column Modal */}
|
||||
{createPortal(<CustomColumnModal />, document.body, 'custom-column-modal')}
|
||||
|
||||
{/* Convert To Subtask Drawer */}
|
||||
{createPortal(<ConvertToSubtaskDrawer />, document.body, 'convert-to-subtask-drawer')}
|
||||
</div>
|
||||
</DndContext>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,529 @@
|
||||
import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { Task } from '@/types/task-management.types';
|
||||
import { tasksApiService } from '@/api/tasks/tasks.api.service';
|
||||
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
|
||||
import { IBulkAssignRequest } from '@/types/tasks/bulk-action-bar.types';
|
||||
import {
|
||||
deleteTask,
|
||||
fetchTasksV3,
|
||||
IGroupBy,
|
||||
toggleTaskExpansion,
|
||||
updateTaskAssignees,
|
||||
} from '@/features/task-management/task-management.slice';
|
||||
import { deselectAll, selectTasks } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||
import { setConvertToSubtaskDrawerOpen } from '@/features/tasks/tasks.slice';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import {
|
||||
evt_project_task_list_context_menu_archive,
|
||||
evt_project_task_list_context_menu_assign_me,
|
||||
evt_project_task_list_context_menu_delete,
|
||||
} from '@/shared/worklenz-analytics-events';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DoubleRightOutlined,
|
||||
InboxOutlined,
|
||||
RetweetOutlined,
|
||||
UserAddOutlined,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
interface TaskContextMenuProps {
|
||||
task: Task;
|
||||
projectId: string;
|
||||
position: { x: number; y: number };
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const TaskContextMenu: React.FC<TaskContextMenuProps> = ({
|
||||
task,
|
||||
projectId,
|
||||
position,
|
||||
onClose,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const { socket, connected } = useSocket();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
const { groups: taskGroups } = useAppSelector(state => state.taskManagement);
|
||||
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
||||
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
|
||||
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
|
||||
const currentGrouping = useAppSelector(state => state.grouping.currentGrouping);
|
||||
const archived = useAppSelector(state => state.taskReducer.archived);
|
||||
|
||||
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
|
||||
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (menuRef.current && !menuRef.current.contains(event.target as Node)) {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handleAssignToMe = useCallback(async () => {
|
||||
if (!projectId || !task.id || !currentSession?.team_member_id) return;
|
||||
|
||||
try {
|
||||
setUpdatingAssignToMe(true);
|
||||
|
||||
// Immediate UI update - add current user to assignees
|
||||
const currentUser = {
|
||||
id: currentSession.team_member_id,
|
||||
name: currentSession.name || '',
|
||||
email: currentSession.email || '',
|
||||
avatar_url: currentSession.avatar_url || '',
|
||||
team_member_id: currentSession.team_member_id,
|
||||
};
|
||||
|
||||
const updatedAssignees = task.assignees || [];
|
||||
const updatedAssigneeNames = task.assignee_names || [];
|
||||
|
||||
// Check if current user is already assigned
|
||||
const isAlreadyAssigned = updatedAssignees.includes(currentSession.team_member_id);
|
||||
|
||||
if (!isAlreadyAssigned) {
|
||||
// Add current user to assignees for immediate UI feedback
|
||||
const newAssignees = [...updatedAssignees, currentSession.team_member_id];
|
||||
const newAssigneeNames = [...updatedAssigneeNames, currentUser];
|
||||
|
||||
// Update Redux store immediately for instant UI feedback
|
||||
dispatch(
|
||||
updateTaskAssignees({
|
||||
taskId: task.id,
|
||||
assigneeIds: newAssignees,
|
||||
assigneeNames: newAssigneeNames,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const body: IBulkAssignRequest = {
|
||||
tasks: [task.id],
|
||||
project_id: projectId,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.assignToMe(body);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_context_menu_assign_me);
|
||||
// Socket event will handle syncing with other users
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error assigning to me:', error);
|
||||
// Revert the optimistic update on error
|
||||
dispatch(
|
||||
updateTaskAssignees({
|
||||
taskId: task.id,
|
||||
assigneeIds: task.assignees || [],
|
||||
assigneeNames: task.assignee_names || [],
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setUpdatingAssignToMe(false);
|
||||
onClose();
|
||||
}
|
||||
}, [projectId, task.id, task.assignees, task.assignee_names, currentSession, dispatch, onClose, trackMixpanelEvent]);
|
||||
|
||||
const handleArchive = useCallback(async () => {
|
||||
if (!projectId || !task.id) return;
|
||||
|
||||
try {
|
||||
const res = await taskListBulkActionsApiService.archiveTasks(
|
||||
{
|
||||
tasks: [task.id],
|
||||
project_id: projectId,
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_context_menu_archive);
|
||||
dispatch(deleteTask(task.id));
|
||||
dispatch(deselectAll());
|
||||
if (task.parent_task_id) {
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error archiving task:', error);
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
}, [projectId, task.id, task.parent_task_id, dispatch, socket, onClose, trackMixpanelEvent]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!projectId || !task.id) return;
|
||||
|
||||
try {
|
||||
const res = await taskListBulkActionsApiService.deleteTasks({ tasks: [task.id] }, projectId);
|
||||
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_context_menu_delete);
|
||||
dispatch(deleteTask(task.id));
|
||||
dispatch(deselectAll());
|
||||
if (task.parent_task_id) {
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting task:', error);
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
}, [projectId, task.id, task.parent_task_id, dispatch, socket, onClose, trackMixpanelEvent]);
|
||||
|
||||
const handleStatusMoveTo = useCallback(
|
||||
async (targetId: string) => {
|
||||
if (!projectId || !task.id || !targetId) return;
|
||||
|
||||
try {
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
status_id: targetId,
|
||||
parent_task: task.parent_task_id || null,
|
||||
team_id: currentSession?.team_id,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error moving status:', error);
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose]
|
||||
);
|
||||
|
||||
const handlePriorityMoveTo = useCallback(
|
||||
async (targetId: string) => {
|
||||
if (!projectId || !task.id || !targetId) return;
|
||||
|
||||
try {
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
priority_id: targetId,
|
||||
parent_task: task.parent_task_id || null,
|
||||
team_id: currentSession?.team_id,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error moving priority:', error);
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose]
|
||||
);
|
||||
|
||||
const handlePhaseMoveTo = useCallback(
|
||||
async (targetId: string) => {
|
||||
if (!projectId || !task.id || !targetId) return;
|
||||
|
||||
try {
|
||||
socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
|
||||
task_id: task.id,
|
||||
phase_id: targetId,
|
||||
parent_task: task.parent_task_id || null,
|
||||
team_id: currentSession?.team_id,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error moving phase:', error);
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[projectId, task.id, task.parent_task_id, currentSession?.team_id, socket, onClose]
|
||||
);
|
||||
|
||||
const getMoveToOptions = useCallback(() => {
|
||||
let options: { key: string; label: React.ReactNode; onClick: () => void }[] = [];
|
||||
|
||||
if (currentGrouping === IGroupBy.STATUS) {
|
||||
options = statusList.filter(status => status.id).map(status => ({
|
||||
key: status.id!,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: status.color_code }}
|
||||
></span>
|
||||
<span>{status.name}</span>
|
||||
</div>
|
||||
),
|
||||
onClick: () => handleStatusMoveTo(status.id!),
|
||||
}));
|
||||
} else if (currentGrouping === IGroupBy.PRIORITY) {
|
||||
options = priorityList.filter(priority => priority.id).map(priority => ({
|
||||
key: priority.id!,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: priority.color_code }}
|
||||
></span>
|
||||
<span>{priority.name}</span>
|
||||
</div>
|
||||
),
|
||||
onClick: () => handlePriorityMoveTo(priority.id!),
|
||||
}));
|
||||
} else if (currentGrouping === IGroupBy.PHASE) {
|
||||
options = phaseList.filter(phase => phase.id).map(phase => ({
|
||||
key: phase.id!,
|
||||
label: (
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: phase.color_code }}
|
||||
></span>
|
||||
<span>{phase.name}</span>
|
||||
</div>
|
||||
),
|
||||
onClick: () => handlePhaseMoveTo(phase.id!),
|
||||
}));
|
||||
}
|
||||
return options;
|
||||
}, [
|
||||
currentGrouping,
|
||||
statusList,
|
||||
priorityList,
|
||||
phaseList,
|
||||
handleStatusMoveTo,
|
||||
handlePriorityMoveTo,
|
||||
handlePhaseMoveTo,
|
||||
]);
|
||||
|
||||
const handleConvertToTask = useCallback(async () => {
|
||||
if (!task?.id || !projectId) return;
|
||||
|
||||
try {
|
||||
const res = await tasksApiService.convertToTask(task.id as string, projectId as string);
|
||||
if (res.done) {
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTasksV3(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error converting to task', error);
|
||||
} finally {
|
||||
onClose();
|
||||
}
|
||||
}, [task?.id, projectId, dispatch, onClose]);
|
||||
|
||||
const menuItems = useMemo(() => {
|
||||
const items = [
|
||||
{
|
||||
key: 'assignToMe',
|
||||
label: (
|
||||
<button
|
||||
onClick={handleAssignToMe}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||
disabled={updatingAssignToMe}
|
||||
>
|
||||
{updatingAssignToMe ? (
|
||||
<LoadingOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
) : (
|
||||
<UserAddOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
)}
|
||||
<span>{t('contextMenu.assignToMe')}</span>
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Add Move To submenu if there are options
|
||||
const moveToOptions = getMoveToOptions();
|
||||
if (moveToOptions.length > 0) {
|
||||
items.push({
|
||||
key: 'moveTo',
|
||||
label: (
|
||||
<div className="relative group">
|
||||
<button className="flex items-center justify-between gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<RetweetOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('contextMenu.moveTo')}</span>
|
||||
</div>
|
||||
<svg
|
||||
className="w-4 h-4 text-gray-500 dark:text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 5l7 7-7 7"
|
||||
></path>
|
||||
</svg>
|
||||
</button>
|
||||
<ul className="absolute left-full top-0 mt-0 w-48 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg z-20 hidden group-hover:block">
|
||||
{moveToOptions.map(option => (
|
||||
<li key={option.key}>
|
||||
<button
|
||||
onClick={option.onClick}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Add Archive/Unarchive for parent tasks only
|
||||
if (!task?.parent_task_id) {
|
||||
items.push({
|
||||
key: 'archive',
|
||||
label: (
|
||||
<button
|
||||
onClick={handleArchive}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||
>
|
||||
<InboxOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{archived ? t('contextMenu.unarchive') : t('contextMenu.archive')}</span>
|
||||
</button>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Add Convert to Sub Task for parent tasks with no subtasks
|
||||
if (task?.sub_tasks_count === 0 && !task?.parent_task_id) {
|
||||
items.push({
|
||||
key: 'convertToSubTask',
|
||||
label: (
|
||||
<button
|
||||
onClick={() => {
|
||||
// Convert task to the format expected by bulkActionSlice
|
||||
const projectTask = {
|
||||
id: task.id,
|
||||
name: task.title || task.name || '',
|
||||
task_key: task.task_key,
|
||||
status: task.status,
|
||||
status_id: task.status,
|
||||
priority: task.priority,
|
||||
phase_id: task.phase,
|
||||
phase_name: task.phase,
|
||||
description: task.description,
|
||||
start_date: task.startDate,
|
||||
end_date: task.dueDate,
|
||||
total_hours: task.timeTracking?.estimated || 0,
|
||||
total_minutes: task.timeTracking?.logged || 0,
|
||||
progress: task.progress,
|
||||
sub_tasks_count: task.sub_tasks_count || 0,
|
||||
assignees: task.assignees?.map((assigneeId: string) => ({
|
||||
id: assigneeId,
|
||||
name: '',
|
||||
email: '',
|
||||
avatar_url: '',
|
||||
team_member_id: assigneeId,
|
||||
project_member_id: assigneeId,
|
||||
})) || [],
|
||||
labels: task.labels || [],
|
||||
manual_progress: false,
|
||||
created_at: task.createdAt,
|
||||
updated_at: task.updatedAt,
|
||||
sort_order: task.order,
|
||||
};
|
||||
|
||||
// Select the task in bulk action reducer
|
||||
dispatch(selectTasks([projectTask]));
|
||||
|
||||
// Open the drawer
|
||||
dispatch(setConvertToSubtaskDrawerOpen(true));
|
||||
}}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||
>
|
||||
<DoubleRightOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('contextMenu.convertToSubTask')}</span>
|
||||
</button>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Add Convert to Task for subtasks
|
||||
if (task?.parent_task_id) {
|
||||
items.push({
|
||||
key: 'convertToTask',
|
||||
label: (
|
||||
<button
|
||||
onClick={handleConvertToTask}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 w-full text-left"
|
||||
>
|
||||
<DoubleRightOutlined className="text-gray-500 dark:text-gray-400" />
|
||||
<span>{t('contextMenu.convertToTask')}</span>
|
||||
</button>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
// Add Delete
|
||||
items.push({
|
||||
key: 'delete',
|
||||
label: (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className="flex items-center gap-2 px-4 py-2 text-sm text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/20 w-full text-left"
|
||||
>
|
||||
<DeleteOutlined className="text-red-500 dark:text-red-400" />
|
||||
<span>{t('contextMenu.delete')}</span>
|
||||
</button>
|
||||
),
|
||||
});
|
||||
|
||||
return items;
|
||||
}, [
|
||||
task,
|
||||
projectId,
|
||||
updatingAssignToMe,
|
||||
archived,
|
||||
handleAssignToMe,
|
||||
handleArchive,
|
||||
handleDelete,
|
||||
handleConvertToTask,
|
||||
getMoveToOptions,
|
||||
dispatch,
|
||||
t,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
className="fixed bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-md shadow-lg py-1 min-w-48"
|
||||
style={{
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
zIndex: 9999,
|
||||
}}
|
||||
>
|
||||
<ul className="list-none p-0 m-0">
|
||||
{menuItems.map(item => (
|
||||
<li key={item.key} className="relative group">
|
||||
{item.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskContextMenu;
|
||||
@@ -2,6 +2,7 @@ import React, { memo, useCallback, useState, useRef, useEffect } from 'react';
|
||||
import { RightOutlined, DoubleRightOutlined, ArrowsAltOutlined, CommentOutlined, EyeOutlined, PaperClipOutlined, MinusCircleOutlined, RetweetOutlined } from '@ant-design/icons';
|
||||
import { Input, Tooltip } from 'antd';
|
||||
import type { InputRef } from 'antd';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { Task } from '@/types/task-management.types';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleTaskExpansion, fetchSubTasks } from '@/features/task-management/task-management.slice';
|
||||
@@ -10,6 +11,7 @@ import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getTaskDisplayName } from './TaskRowColumns';
|
||||
import TaskContextMenu from './TaskContextMenu';
|
||||
|
||||
interface TitleColumnProps {
|
||||
width: string;
|
||||
@@ -41,6 +43,10 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Context menu state
|
||||
const [contextMenuVisible, setContextMenuVisible] = useState(false);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
||||
|
||||
// Handle task expansion toggle
|
||||
const handleToggleExpansion = useCallback((e: React.MouseEvent) => {
|
||||
@@ -71,6 +77,24 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
onEditTaskName(false);
|
||||
}, [taskName, connected, socket, task.id, task.parent_task_id, task.title, task.name, onEditTaskName]);
|
||||
|
||||
// Handle context menu
|
||||
const handleContextMenu = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
// Use clientX and clientY directly for fixed positioning
|
||||
setContextMenuPosition({
|
||||
x: e.clientX,
|
||||
y: e.clientY
|
||||
});
|
||||
setContextMenuVisible(true);
|
||||
}, []);
|
||||
|
||||
// Handle context menu close
|
||||
const handleContextMenuClose = useCallback(() => {
|
||||
setContextMenuVisible(false);
|
||||
}, []);
|
||||
|
||||
// Handle click outside for task name editing
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
@@ -169,6 +193,7 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
e.preventDefault();
|
||||
onEditTaskName(true);
|
||||
}}
|
||||
onContextMenu={handleContextMenu}
|
||||
title={taskDisplayName}
|
||||
>
|
||||
{taskDisplayName}
|
||||
@@ -251,6 +276,17 @@ export const TitleColumn: React.FC<TitleColumnProps> = memo(({
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Context Menu */}
|
||||
{contextMenuVisible && createPortal(
|
||||
<TaskContextMenu
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
position={contextMenuPosition}
|
||||
onClose={handleContextMenuClose}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -20,6 +20,112 @@
|
||||
border-top: 1px solid #303030;
|
||||
}
|
||||
|
||||
/* Dark mode confirmation modal styling */
|
||||
.dark .ant-modal-confirm .ant-modal-content,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-content {
|
||||
background-color: #1f1f1f !important;
|
||||
border: 1px solid #303030 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-modal-header,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-header {
|
||||
background-color: #1f1f1f !important;
|
||||
border-bottom: 1px solid #303030 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-modal-body,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-body {
|
||||
background-color: #1f1f1f !important;
|
||||
color: #d9d9d9 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-modal-footer,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-footer {
|
||||
background-color: #1f1f1f !important;
|
||||
border-top: 1px solid #303030 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-modal-confirm-title,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-title {
|
||||
color: #d9d9d9 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-modal-confirm-content,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-content {
|
||||
color: #8c8c8c !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-default,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-default {
|
||||
background-color: #141414 !important;
|
||||
border-color: #303030 !important;
|
||||
color: #d9d9d9 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-default:hover,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-default:hover {
|
||||
background-color: #262626 !important;
|
||||
border-color: #40a9ff !important;
|
||||
color: #d9d9d9 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-primary,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary {
|
||||
background-color: #1890ff !important;
|
||||
border-color: #1890ff !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-primary:hover,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary:hover {
|
||||
background-color: #40a9ff !important;
|
||||
border-color: #40a9ff !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-dangerous,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous {
|
||||
background-color: #ff4d4f !important;
|
||||
border-color: #ff4d4f !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-dangerous:hover,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous:hover {
|
||||
background-color: #ff7875 !important;
|
||||
border-color: #ff7875 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Light mode confirmation modal styling (ensure consistency) */
|
||||
.ant-modal-confirm .ant-modal-content {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-modal-confirm .ant-modal-header {
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-modal-confirm .ant-modal-body {
|
||||
background-color: #ffffff;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.ant-modal-confirm .ant-modal-footer {
|
||||
background-color: #ffffff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-modal-confirm .ant-modal-confirm-title {
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.ant-modal-confirm .ant-modal-confirm-content {
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.dark-modal .ant-form-item-label > label {
|
||||
color: #d9d9d9;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
deletePhaseOption,
|
||||
updatePhaseColor,
|
||||
} from '@/features/projects/singleProject/phase/phases.slice';
|
||||
import { updatePhaseLabel } from '@/features/project/project.slice';
|
||||
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||
import { Modal as AntModal } from 'antd';
|
||||
import { fetchTasksV3 } from '@/features/task-management/task-management.slice';
|
||||
@@ -307,7 +308,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
if (!newPhaseName.trim() || !finalProjectId) return;
|
||||
|
||||
try {
|
||||
await dispatch(addPhaseOption({ projectId: finalProjectId }));
|
||||
await dispatch(addPhaseOption({ projectId: finalProjectId, name: newPhaseName.trim() }));
|
||||
await dispatch(fetchPhasesByProjectId(finalProjectId));
|
||||
await refreshTasks();
|
||||
setNewPhaseName('');
|
||||
@@ -408,6 +409,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
).unwrap();
|
||||
|
||||
if (res.done) {
|
||||
dispatch(updatePhaseLabel(phaseName));
|
||||
setInitialPhaseName(phaseName);
|
||||
await refreshTasks();
|
||||
}
|
||||
@@ -428,7 +430,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
<Title level={4} className={`m-0 font-semibold ${
|
||||
isDarkMode ? 'text-gray-100' : 'text-gray-800'
|
||||
}`}>
|
||||
{t('configurePhases')}
|
||||
{t('configure')} {phaseName || project?.phase_label || t('phasesText')}
|
||||
</Title>
|
||||
}
|
||||
open={open}
|
||||
@@ -495,7 +497,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
<Text className={`text-xs font-medium ${
|
||||
isDarkMode ? 'text-gray-300' : 'text-blue-700'
|
||||
}`}>
|
||||
🎨 Drag phases to reorder them. Click on a phase name to rename it. Each phase can have a custom color.
|
||||
🎨 Drag {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()} to reorder them. Click on a {(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} name to rename it. Each {(phaseName || project?.phase_label || t('phaseText')).toLowerCase()} can have a custom color.
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
@@ -558,7 +560,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
<Text className={`text-xs font-medium ${
|
||||
isDarkMode ? 'text-gray-300' : 'text-gray-700'
|
||||
}`}>
|
||||
{t('phaseOptions')}
|
||||
{phaseName || project?.phase_label || t('phasesText')} {t('optionsText')}
|
||||
</Text>
|
||||
<Button
|
||||
type="primary"
|
||||
@@ -601,7 +603,7 @@ const ManagePhaseModal: React.FC<ManagePhaseModalProps> = ({
|
||||
isDarkMode ? 'text-gray-400' : 'text-gray-500'
|
||||
}`}>
|
||||
<Text className="text-sm font-medium">
|
||||
{t('noPhasesFound')}
|
||||
{t('no')} {(phaseName || project?.phase_label || t('phasesText')).toLowerCase()} {t('found')}
|
||||
</Text>
|
||||
<br />
|
||||
<Button
|
||||
|
||||
@@ -20,6 +20,112 @@
|
||||
border-top: 1px solid #303030;
|
||||
}
|
||||
|
||||
/* Dark mode confirmation modal styling */
|
||||
.dark .ant-modal-confirm .ant-modal-content,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-content {
|
||||
background-color: #1f1f1f !important;
|
||||
border: 1px solid #303030 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-modal-header,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-header {
|
||||
background-color: #1f1f1f !important;
|
||||
border-bottom: 1px solid #303030 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-modal-body,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-body {
|
||||
background-color: #1f1f1f !important;
|
||||
color: #d9d9d9 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-modal-footer,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-footer {
|
||||
background-color: #1f1f1f !important;
|
||||
border-top: 1px solid #303030 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-modal-confirm-title,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-title {
|
||||
color: #d9d9d9 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-modal-confirm-content,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-content {
|
||||
color: #8c8c8c !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-default,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-default {
|
||||
background-color: #141414 !important;
|
||||
border-color: #303030 !important;
|
||||
color: #d9d9d9 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-default:hover,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-default:hover {
|
||||
background-color: #262626 !important;
|
||||
border-color: #40a9ff !important;
|
||||
color: #d9d9d9 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-primary,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary {
|
||||
background-color: #1890ff !important;
|
||||
border-color: #1890ff !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-primary:hover,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary:hover {
|
||||
background-color: #40a9ff !important;
|
||||
border-color: #40a9ff !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-dangerous,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous {
|
||||
background-color: #ff4d4f !important;
|
||||
border-color: #ff4d4f !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-dangerous:hover,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous:hover {
|
||||
background-color: #ff7875 !important;
|
||||
border-color: #ff7875 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Light mode confirmation modal styling (ensure consistency) */
|
||||
.ant-modal-confirm .ant-modal-content {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-modal-confirm .ant-modal-header {
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-modal-confirm .ant-modal-body {
|
||||
background-color: #ffffff;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.ant-modal-confirm .ant-modal-footer {
|
||||
background-color: #ffffff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-modal-confirm .ant-modal-confirm-title {
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.ant-modal-confirm .ant-modal-confirm-content {
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
.dark-modal .ant-form-item-label > label {
|
||||
color: #d9d9d9;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,6 @@ import { IKanbanTaskStatus } from '@/types/tasks/taskStatus.types';
|
||||
import { Modal as AntModal } from 'antd';
|
||||
import { fetchTasksV3 } from '@/features/task-management/task-management.slice';
|
||||
import { fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import { fetchTaskGroups } from '@/features/tasks/tasks.slice';
|
||||
import './ManageStatusModal.css';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
@@ -594,7 +593,6 @@ const ManageStatusModal: React.FC<ManageStatusModalProps> = ({
|
||||
// Refresh from server to ensure consistency
|
||||
dispatch(fetchStatuses(finalProjectId));
|
||||
dispatch(fetchTasksV3(finalProjectId));
|
||||
dispatch(fetchTaskGroups(finalProjectId));
|
||||
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
|
||||
} catch (error) {
|
||||
console.error('Error changing status category:', error);
|
||||
@@ -736,7 +734,6 @@ const ManageStatusModal: React.FC<ManageStatusModalProps> = ({
|
||||
statusApiService.updateStatusOrder(requestBody, finalProjectId).then(() => {
|
||||
// Refresh task lists after status order change
|
||||
dispatch(fetchTasksV3(finalProjectId));
|
||||
dispatch(fetchTaskGroups(finalProjectId));
|
||||
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
|
||||
}).catch(error => {
|
||||
console.error('Error updating status order:', error);
|
||||
@@ -767,7 +764,6 @@ const ManageStatusModal: React.FC<ManageStatusModalProps> = ({
|
||||
if (res.done) {
|
||||
dispatch(fetchStatuses(finalProjectId));
|
||||
dispatch(fetchTasksV3(finalProjectId));
|
||||
dispatch(fetchTaskGroups(finalProjectId));
|
||||
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -791,7 +787,6 @@ const ManageStatusModal: React.FC<ManageStatusModalProps> = ({
|
||||
await statusApiService.updateNameOfStatus(id, body, finalProjectId);
|
||||
dispatch(fetchStatuses(finalProjectId));
|
||||
dispatch(fetchTasksV3(finalProjectId));
|
||||
dispatch(fetchTaskGroups(finalProjectId));
|
||||
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
|
||||
} catch (error) {
|
||||
console.error('Error renaming status:', error);
|
||||
@@ -813,7 +808,6 @@ const ManageStatusModal: React.FC<ManageStatusModalProps> = ({
|
||||
await statusApiService.deleteStatus(id, finalProjectId, replacingStatusId);
|
||||
dispatch(fetchStatuses(finalProjectId));
|
||||
dispatch(fetchTasksV3(finalProjectId));
|
||||
dispatch(fetchTaskGroups(finalProjectId));
|
||||
dispatch(fetchEnhancedKanbanGroups(finalProjectId));
|
||||
} catch (error) {
|
||||
console.error('Error deleting status:', error);
|
||||
|
||||
@@ -369,6 +369,7 @@ const FilterDropdown: React.FC<{
|
||||
dispatch?: any;
|
||||
onManageStatus?: () => void;
|
||||
onManagePhase?: () => void;
|
||||
projectPhaseLabel?: string; // Add this prop
|
||||
}> = ({
|
||||
section,
|
||||
onSelectionChange,
|
||||
@@ -380,6 +381,7 @@ const FilterDropdown: React.FC<{
|
||||
dispatch,
|
||||
onManageStatus,
|
||||
onManagePhase,
|
||||
projectPhaseLabel, // Add this prop
|
||||
}) => {
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
// Add permission checks for groupBy section
|
||||
@@ -495,7 +497,7 @@ const FilterDropdown: React.FC<{
|
||||
isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'
|
||||
}`}
|
||||
>
|
||||
{t('managePhases')}
|
||||
{t('manage')} {projectPhaseLabel || t('phasesText')}
|
||||
</button>
|
||||
)}
|
||||
{section.selectedValues[0] === 'status' && (
|
||||
@@ -994,6 +996,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
||||
const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark');
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { projectView } = useTabSearchParam();
|
||||
const projectPhaseLabel = useAppSelector(state => state.projectReducer.project?.phase_label);
|
||||
|
||||
// Theme-aware class names - memoize to prevent unnecessary re-renders
|
||||
// Using greyish colors for both dark and light modes
|
||||
@@ -1298,6 +1301,7 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
||||
dispatch={dispatch}
|
||||
onManageStatus={() => setShowManageStatusModal(true)}
|
||||
onManagePhase={() => setShowManagePhaseModal(true)}
|
||||
projectPhaseLabel={projectPhaseLabel}
|
||||
/>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -312,7 +312,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(
|
||||
{groupTasks.length === 0 ? (
|
||||
<div className="task-group-empty">
|
||||
<div className="task-table-fixed-columns">
|
||||
<div style={{ width: '380px', padding: '20px 12px' }}>
|
||||
<div style={{ width: '380px', padding: '32px 12px' }}>
|
||||
<div className="text-center text-gray-500">
|
||||
<Text type="secondary">No tasks in this group</Text>
|
||||
<br />
|
||||
@@ -487,7 +487,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(
|
||||
|
||||
.task-group-empty {
|
||||
display: flex;
|
||||
height: 80px;
|
||||
height: 120px;
|
||||
align-items: center;
|
||||
background: var(--task-bg-primary, white);
|
||||
transition: background-color 0.3s ease;
|
||||
|
||||
@@ -35,8 +35,6 @@ const TaskPhaseDropdown: React.FC<TaskPhaseDropdownProps> = ({
|
||||
(phaseId: string, phaseName: string) => {
|
||||
if (!task.id || !phaseId || !connected) return;
|
||||
|
||||
console.log('🎯 Phase change initiated:', { taskId: task.id, phaseId, phaseName });
|
||||
|
||||
socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
|
||||
task_id: task.id,
|
||||
phase_id: phaseId,
|
||||
@@ -51,8 +49,6 @@ const TaskPhaseDropdown: React.FC<TaskPhaseDropdownProps> = ({
|
||||
const handlePhaseClear = useCallback(() => {
|
||||
if (!task.id || !connected) return;
|
||||
|
||||
console.log('🎯 Phase clear initiated:', { taskId: task.id });
|
||||
|
||||
socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
|
||||
task_id: task.id,
|
||||
phase_id: null,
|
||||
|
||||
@@ -33,7 +33,7 @@ export const GROUP_BY_OPTIONS: IGroupByOption[] = [
|
||||
{ label: 'Phase', value: IGroupBy.PHASE },
|
||||
];
|
||||
|
||||
const LOCALSTORAGE_GROUP_KEY = 'worklenz.enhanced-kanban.group_by';
|
||||
const LOCALSTORAGE_GROUP_KEY = 'worklenz.kanban.group_by';
|
||||
|
||||
export const getCurrentGroup = (): IGroupBy => {
|
||||
const key = localStorage.getItem(LOCALSTORAGE_GROUP_KEY);
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React from 'react';
|
||||
import { useSelectedProject } from '../../../../hooks/useSelectedProject';
|
||||
import { useAppSelector } from '../../../../hooks/useAppSelector';
|
||||
import { Flex } from 'antd';
|
||||
import ConfigPhaseButton from './ConfigPhaseButton';
|
||||
@@ -10,19 +9,13 @@ const PhaseHeader = () => {
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
|
||||
// get selected project for useSelectedProject hook
|
||||
const selectedProject = useSelectedProject();
|
||||
|
||||
// get phase data from redux
|
||||
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
|
||||
|
||||
//get phases details from phases slice
|
||||
const phase = phaseList.find(el => el.projectId === selectedProject?.projectId);
|
||||
// get project data from redux
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="space-between">
|
||||
{phase?.phase || t('phasesText')}
|
||||
<ConfigPhaseButton color={colors.darkGray} />
|
||||
{project?.phase_label || t('phasesText')}
|
||||
<ConfigPhaseButton />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -16,9 +16,9 @@ const initialState: PhaseState = {
|
||||
|
||||
export const addPhaseOption = createAsyncThunk(
|
||||
'phase/addPhaseOption',
|
||||
async ({ projectId }: { projectId: string }, { rejectWithValue }) => {
|
||||
async ({ projectId, name }: { projectId: string; name?: string }, { rejectWithValue }) => {
|
||||
try {
|
||||
const response = await phasesApiService.addPhaseOption(projectId);
|
||||
const response = await phasesApiService.addPhaseOption(projectId, name);
|
||||
return response;
|
||||
} catch (error) {
|
||||
return rejectWithValue(error);
|
||||
|
||||
@@ -17,8 +17,36 @@ interface LocalGroupingState {
|
||||
collapsedGroups: string[];
|
||||
}
|
||||
|
||||
// Local storage constants
|
||||
const LOCALSTORAGE_GROUP_KEY = 'worklenz.tasklist.group_by';
|
||||
|
||||
// Utility functions for local storage
|
||||
const loadGroupingFromLocalStorage = (): GroupingType | null => {
|
||||
try {
|
||||
const stored = localStorage.getItem(LOCALSTORAGE_GROUP_KEY);
|
||||
if (stored && ['status', 'priority', 'phase'].includes(stored)) {
|
||||
return stored as GroupingType;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load grouping from localStorage:', error);
|
||||
}
|
||||
return 'status'; // Default to 'status' instead of null
|
||||
};
|
||||
|
||||
const saveGroupingToLocalStorage = (grouping: GroupingType | null): void => {
|
||||
try {
|
||||
if (grouping) {
|
||||
localStorage.setItem(LOCALSTORAGE_GROUP_KEY, grouping);
|
||||
} else {
|
||||
localStorage.removeItem(LOCALSTORAGE_GROUP_KEY);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to save grouping to localStorage:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const initialState: LocalGroupingState = {
|
||||
currentGrouping: null,
|
||||
currentGrouping: loadGroupingFromLocalStorage(),
|
||||
customPhases: ['Planning', 'Development', 'Testing', 'Deployment'],
|
||||
groupOrder: {
|
||||
status: ['todo', 'doing', 'done'],
|
||||
@@ -35,6 +63,7 @@ const groupingSlice = createSlice({
|
||||
reducers: {
|
||||
setCurrentGrouping: (state, action: PayloadAction<GroupingType | null>) => {
|
||||
state.currentGrouping = action.payload;
|
||||
saveGroupingToLocalStorage(action.payload);
|
||||
},
|
||||
|
||||
addCustomPhase: (state, action: PayloadAction<string>) => {
|
||||
|
||||
@@ -123,11 +123,11 @@ const TasksList: React.FC = React.memo(() => {
|
||||
<span>{t('tasks.name')}</span>
|
||||
</Flex>
|
||||
),
|
||||
width: '150px',
|
||||
width: '40%',
|
||||
render: (_, record) => (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Tooltip title={record.name}>
|
||||
<Typography.Text ellipsis={{ tooltip: true }} style={{ maxWidth: 150 }}>
|
||||
<Typography.Text style={{ flex: 1, marginRight: 8 }}>
|
||||
{record.name}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
@@ -155,15 +155,14 @@ const TasksList: React.FC = React.memo(() => {
|
||||
{
|
||||
key: 'project',
|
||||
title: t('tasks.project'),
|
||||
width: '120px',
|
||||
width: '25%',
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<Tooltip title={record.project_name}>
|
||||
<Typography.Paragraph
|
||||
style={{ margin: 0, paddingInlineEnd: 6, maxWidth: 120 }}
|
||||
ellipsis={{ tooltip: true }}
|
||||
style={{ margin: 0, paddingInlineEnd: 6 }}
|
||||
>
|
||||
<Badge color={record.phase_color || 'blue'} style={{ marginInlineEnd: 4 }} />
|
||||
<Badge color={record.project_color || 'blue'} style={{ marginInlineEnd: 4 }} />
|
||||
{record.project_name}
|
||||
</Typography.Paragraph>
|
||||
</Tooltip>
|
||||
@@ -173,7 +172,7 @@ const TasksList: React.FC = React.memo(() => {
|
||||
{
|
||||
key: 'status',
|
||||
title: t('tasks.status'),
|
||||
width: '180px',
|
||||
width: '20%',
|
||||
render: (_, record) => (
|
||||
<HomeTasksStatusDropdown task={record} teamId={record.team_id || ''} />
|
||||
),
|
||||
@@ -181,7 +180,7 @@ const TasksList: React.FC = React.memo(() => {
|
||||
{
|
||||
key: 'dueDate',
|
||||
title: t('tasks.dueDate'),
|
||||
width: '180px',
|
||||
width: '15%',
|
||||
dataIndex: 'end_date',
|
||||
render: (_, record) => <HomeTasksDatePicker record={record} />,
|
||||
},
|
||||
|
||||
@@ -106,13 +106,8 @@ const BoardCreateSectionCard = () => {
|
||||
}
|
||||
|
||||
if (groupBy === IGroupBy.PHASE && projectId) {
|
||||
const body = {
|
||||
name: sectionName,
|
||||
project_id: projectId,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await phasesApiService.addPhaseOption(projectId);
|
||||
const response = await phasesApiService.addPhaseOption(projectId, sectionName);
|
||||
if (response.done && response.body) {
|
||||
dispatch(fetchBoardTaskGroups(projectId));
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ const ProjectViewHeader = memo(() => {
|
||||
setCreatingTask(true);
|
||||
|
||||
const body: Partial<ITaskCreateRequest> = {
|
||||
name: DEFAULT_TASK_NAME,
|
||||
name: t('defaultTaskName'),
|
||||
project_id: selectedProject.id,
|
||||
reporter_id: currentSession.id,
|
||||
team_id: currentSession.team_id,
|
||||
@@ -242,7 +242,7 @@ const ProjectViewHeader = memo(() => {
|
||||
logger.error('Error creating task', error);
|
||||
setCreatingTask(false);
|
||||
}
|
||||
}, [selectedProject?.id, currentSession, socket, dispatch, groupBy, tab]);
|
||||
}, [selectedProject?.id, currentSession, socket, dispatch, groupBy, tab, t]);
|
||||
|
||||
// Memoized import task template handler
|
||||
const handleImportTaskTemplate = useCallback(() => {
|
||||
|
||||
@@ -524,6 +524,69 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
});
|
||||
}
|
||||
|
||||
// NEW SIMPLIFIED APPROACH: Calculate all affected task updates and send them
|
||||
const taskUpdates: Array<{
|
||||
task_id: string;
|
||||
sort_order: number;
|
||||
status_id?: string;
|
||||
priority_id?: string;
|
||||
phase_id?: string;
|
||||
}> = [];
|
||||
|
||||
// Add updates for all tasks in affected groups
|
||||
if (activeGroupId === overGroupId) {
|
||||
// Same group - just reorder
|
||||
const updatedTasks = [...sourceGroup.tasks];
|
||||
updatedTasks.splice(fromIndex, 1);
|
||||
updatedTasks.splice(toIndex, 0, task);
|
||||
|
||||
updatedTasks.forEach((task, index) => {
|
||||
taskUpdates.push({
|
||||
task_id: task.id,
|
||||
sort_order: index + 1, // 1-based indexing
|
||||
});
|
||||
});
|
||||
} else {
|
||||
// Different groups - update both source and target
|
||||
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
|
||||
const updatedTargetTasks = [...targetGroup.tasks];
|
||||
|
||||
if (isTargetGroupEmpty) {
|
||||
updatedTargetTasks.push(task);
|
||||
} else if (toIndex >= 0 && toIndex <= updatedTargetTasks.length) {
|
||||
updatedTargetTasks.splice(toIndex, 0, task);
|
||||
} else {
|
||||
updatedTargetTasks.push(task);
|
||||
}
|
||||
|
||||
// Add updates for source group
|
||||
updatedSourceTasks.forEach((task, index) => {
|
||||
taskUpdates.push({
|
||||
task_id: task.id,
|
||||
sort_order: index + 1,
|
||||
});
|
||||
});
|
||||
|
||||
// Add updates for target group (including the moved task)
|
||||
updatedTargetTasks.forEach((task, index) => {
|
||||
const update: any = {
|
||||
task_id: task.id,
|
||||
sort_order: index + 1,
|
||||
};
|
||||
|
||||
// Add group-specific updates
|
||||
if (groupBy === IGroupBy.STATUS) {
|
||||
update.status_id = targetGroup.id;
|
||||
} else if (groupBy === IGroupBy.PRIORITY) {
|
||||
update.priority_id = targetGroup.id;
|
||||
} else if (groupBy === IGroupBy.PHASE) {
|
||||
update.phase_id = targetGroup.id;
|
||||
}
|
||||
|
||||
taskUpdates.push(update);
|
||||
});
|
||||
}
|
||||
|
||||
socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
|
||||
project_id: projectId,
|
||||
from_index: sourceGroup.tasks[fromIndex].sort_order,
|
||||
@@ -534,6 +597,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
group_by: groupBy,
|
||||
task: sourceGroup.tasks[fromIndex],
|
||||
team_id: currentSession?.team_id,
|
||||
task_updates: taskUpdates, // NEW: Send calculated updates
|
||||
});
|
||||
|
||||
setTimeout(resetTaskRowStyles, 0);
|
||||
|
||||
@@ -208,6 +208,18 @@ const TaskListTableWrapper = ({
|
||||
>
|
||||
<Flex vertical>
|
||||
<Flex style={{ transform: 'translateY(6px)' }}>
|
||||
{groupBy !== IGroupBy.PRIORITY &&
|
||||
!showRenameInput &&
|
||||
isEditable &&
|
||||
name !== 'Unmapped' && (
|
||||
<Dropdown menu={{ items }}>
|
||||
<Button
|
||||
icon={<EllipsisOutlined />}
|
||||
className="borderless-icon-btn"
|
||||
title={isEditable ? undefined : t('noPermission')}
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
<Button
|
||||
className="custom-collapse-button"
|
||||
style={{
|
||||
@@ -243,18 +255,6 @@ const TaskListTableWrapper = ({
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Button>
|
||||
{groupBy !== IGroupBy.PRIORITY &&
|
||||
!showRenameInput &&
|
||||
isEditable &&
|
||||
name !== 'Unmapped' && (
|
||||
<Dropdown menu={{ items }}>
|
||||
<Button
|
||||
icon={<EllipsisOutlined />}
|
||||
className="borderless-icon-btn"
|
||||
title={isEditable ? undefined : t('noPermission')}
|
||||
/>
|
||||
</Dropdown>
|
||||
)}
|
||||
</Flex>
|
||||
<Collapsible
|
||||
isOpen={isExpanded}
|
||||
|
||||
@@ -944,18 +944,7 @@ const SelectionFieldCell: React.FC<{
|
||||
columnKey: string;
|
||||
updateValue: (taskId: string, columnKey: string, value: string) => void;
|
||||
}> = ({ selectionsList, value, task, columnKey, updateValue }) => {
|
||||
// Debug the selectionsList data
|
||||
const [loggedInfo, setLoggedInfo] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedInfo) {
|
||||
console.log('Selection column data:', {
|
||||
columnKey,
|
||||
selectionsList,
|
||||
});
|
||||
setLoggedInfo(true);
|
||||
}
|
||||
}, [columnKey, selectionsList, loggedInfo]);
|
||||
|
||||
return (
|
||||
<CustomColumnSelectionCell
|
||||
@@ -1256,19 +1245,6 @@ const renderCustomColumnContent = (
|
||||
);
|
||||
},
|
||||
selection: () => {
|
||||
// Debug the selectionsList data
|
||||
const [loggedInfo, setLoggedInfo] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!loggedInfo) {
|
||||
console.log('Selection column data:', {
|
||||
columnKey,
|
||||
selectionsList: columnObj?.selectionsList,
|
||||
});
|
||||
setLoggedInfo(true);
|
||||
}
|
||||
}, [columnKey, loggedInfo]);
|
||||
|
||||
return (
|
||||
<SelectionFieldCell
|
||||
selectionsList={columnObj?.selectionsList || []}
|
||||
@@ -1650,35 +1626,12 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
||||
|
||||
const activeTask = displayTasks.find(task => task.id === active.id);
|
||||
if (!activeTask) {
|
||||
console.error('Active task not found:', {
|
||||
activeId: active.id,
|
||||
displayTasks: displayTasks.map(t => ({ id: t.id, name: t.name })),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Found activeTask:', {
|
||||
id: activeTask.id,
|
||||
name: activeTask.name,
|
||||
status_id: activeTask.status_id,
|
||||
status: activeTask.status,
|
||||
priority: activeTask.priority,
|
||||
project_id: project?.id,
|
||||
team_id: project?.team_id,
|
||||
fullProject: project,
|
||||
});
|
||||
|
||||
// Use the tableId directly as the group ID (it should be the group ID)
|
||||
const currentGroupId = tableId;
|
||||
|
||||
console.log('Drag operation:', {
|
||||
activeId: active.id,
|
||||
overId: over.id,
|
||||
tableId,
|
||||
currentGroupId,
|
||||
displayTasksLength: displayTasks.length,
|
||||
});
|
||||
|
||||
// Check if this is a reorder within the same group
|
||||
const overTask = displayTasks.find(task => task.id === over.id);
|
||||
if (overTask) {
|
||||
@@ -1686,36 +1639,17 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
||||
const oldIndex = displayTasks.findIndex(task => task.id === active.id);
|
||||
const newIndex = displayTasks.findIndex(task => task.id === over.id);
|
||||
|
||||
console.log('Reorder details:', { oldIndex, newIndex, activeTask: activeTask.name });
|
||||
|
||||
if (oldIndex !== newIndex && oldIndex !== -1 && newIndex !== -1) {
|
||||
// Get the actual sort_order values from the tasks
|
||||
const fromSortOrder = activeTask.sort_order || oldIndex;
|
||||
const overTaskAtNewIndex = displayTasks[newIndex];
|
||||
const toSortOrder = overTaskAtNewIndex?.sort_order || newIndex;
|
||||
|
||||
console.log('Sort order details:', {
|
||||
oldIndex,
|
||||
newIndex,
|
||||
fromSortOrder,
|
||||
toSortOrder,
|
||||
activeTaskSortOrder: activeTask.sort_order,
|
||||
overTaskSortOrder: overTaskAtNewIndex?.sort_order,
|
||||
});
|
||||
|
||||
// Create updated task list with reordered tasks
|
||||
const updatedTasks = [...displayTasks];
|
||||
const [movedTask] = updatedTasks.splice(oldIndex, 1);
|
||||
updatedTasks.splice(newIndex, 0, movedTask);
|
||||
|
||||
console.log('Dispatching reorderTasks with:', {
|
||||
activeGroupId: currentGroupId,
|
||||
overGroupId: currentGroupId,
|
||||
fromIndex: oldIndex,
|
||||
toIndex: newIndex,
|
||||
taskName: activeTask.name,
|
||||
});
|
||||
|
||||
// Update local state immediately for better UX
|
||||
dispatch(
|
||||
reorderTasks({
|
||||
@@ -1758,34 +1692,10 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
||||
|
||||
// Validate required fields before sending
|
||||
if (!body.task.id) {
|
||||
console.error('Cannot send socket event: task.id is missing', { activeTask, active });
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Validated values:', {
|
||||
from_index: body.from_index,
|
||||
to_index: body.to_index,
|
||||
status: body.task.status,
|
||||
priority: body.task.priority,
|
||||
team_id: body.team_id,
|
||||
originalStatus: activeTask.status_id || activeTask.status,
|
||||
originalPriority: activeTask.priority,
|
||||
originalTeamId: project.team_id,
|
||||
sessionTeamId: currentSession?.team_id,
|
||||
finalTeamId: body.team_id,
|
||||
});
|
||||
|
||||
console.log('Sending socket event:', body);
|
||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
|
||||
} else {
|
||||
console.error('Cannot send socket event: missing required data', {
|
||||
hasSocket: !!socket,
|
||||
hasProjectId: !!project?.id,
|
||||
hasActiveId: !!active.id,
|
||||
hasActiveTaskId: !!activeTask.id,
|
||||
activeTask,
|
||||
active,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,152 @@
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Global Confirmation Modal Styles */
|
||||
/* Light mode confirmation modal styling (default) */
|
||||
.ant-modal-confirm .ant-modal-content {
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-modal-confirm .ant-modal-header {
|
||||
background-color: #ffffff;
|
||||
border-bottom: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-modal-confirm .ant-modal-body {
|
||||
background-color: #ffffff;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.ant-modal-confirm .ant-modal-footer {
|
||||
background-color: #ffffff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.ant-modal-confirm .ant-modal-confirm-title {
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
.ant-modal-confirm .ant-modal-confirm-content {
|
||||
color: #595959;
|
||||
}
|
||||
|
||||
/* Dark mode confirmation modal styling */
|
||||
.dark .ant-modal-confirm .ant-modal-content,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-content,
|
||||
html.dark .ant-modal-confirm .ant-modal-content {
|
||||
background-color: #1f1f1f !important;
|
||||
border: 1px solid #303030 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-modal-header,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-header,
|
||||
html.dark .ant-modal-confirm .ant-modal-header {
|
||||
background-color: #1f1f1f !important;
|
||||
border-bottom: 1px solid #303030 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-modal-body,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-body,
|
||||
html.dark .ant-modal-confirm .ant-modal-body {
|
||||
background-color: #1f1f1f !important;
|
||||
color: #d9d9d9 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-modal-footer,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-footer,
|
||||
html.dark .ant-modal-confirm .ant-modal-footer {
|
||||
background-color: #1f1f1f !important;
|
||||
border-top: 1px solid #303030 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-modal-confirm-title,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-title,
|
||||
html.dark .ant-modal-confirm .ant-modal-confirm-title {
|
||||
color: #d9d9d9 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-modal-confirm-content,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-modal-confirm-content,
|
||||
html.dark .ant-modal-confirm .ant-modal-confirm-content {
|
||||
color: #8c8c8c !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-default,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-default,
|
||||
html.dark .ant-modal-confirm .ant-btn-default {
|
||||
background-color: #141414 !important;
|
||||
border-color: #303030 !important;
|
||||
color: #d9d9d9 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-default:hover,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-default:hover,
|
||||
html.dark .ant-modal-confirm .ant-btn-default:hover {
|
||||
background-color: #262626 !important;
|
||||
border-color: #40a9ff !important;
|
||||
color: #d9d9d9 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-primary,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary,
|
||||
html.dark .ant-modal-confirm .ant-btn-primary {
|
||||
background-color: #1890ff !important;
|
||||
border-color: #1890ff !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-primary:hover,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-primary:hover,
|
||||
html.dark .ant-modal-confirm .ant-btn-primary:hover {
|
||||
background-color: #40a9ff !important;
|
||||
border-color: #40a9ff !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-dangerous,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous,
|
||||
html.dark .ant-modal-confirm .ant-btn-dangerous {
|
||||
background-color: #ff4d4f !important;
|
||||
border-color: #ff4d4f !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-confirm .ant-btn-dangerous:hover,
|
||||
[data-theme="dark"] .ant-modal-confirm .ant-btn-dangerous:hover,
|
||||
html.dark .ant-modal-confirm .ant-btn-dangerous:hover {
|
||||
background-color: #ff7875 !important;
|
||||
border-color: #ff7875 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
/* Error modal specific styling */
|
||||
.dark .ant-modal-error .ant-modal-content,
|
||||
[data-theme="dark"] .ant-modal-error .ant-modal-content,
|
||||
html.dark .ant-modal-error .ant-modal-content {
|
||||
background-color: #1f1f1f !important;
|
||||
border: 1px solid #303030 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-error .ant-modal-body,
|
||||
[data-theme="dark"] .ant-modal-error .ant-modal-body,
|
||||
html.dark .ant-modal-error .ant-modal-body {
|
||||
background-color: #1f1f1f !important;
|
||||
color: #d9d9d9 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-error .ant-modal-confirm-title,
|
||||
[data-theme="dark"] .ant-modal-error .ant-modal-confirm-title,
|
||||
html.dark .ant-modal-error .ant-modal-confirm-title {
|
||||
color: #d9d9d9 !important;
|
||||
}
|
||||
|
||||
.dark .ant-modal-error .ant-modal-confirm-content,
|
||||
[data-theme="dark"] .ant-modal-error .ant-modal-content,
|
||||
html.dark .ant-modal-error .ant-modal-confirm-content {
|
||||
color: #8c8c8c !important;
|
||||
}
|
||||
|
||||
.task-group {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user