feat(task-management): implement new task management features with BulkActionBar and task grouping
- Introduced BulkActionBar component for bulk actions on selected tasks, including status, priority, and assignee changes. - Added TaskGroup and TaskRow components to enhance task organization and display. - Implemented grouping functionality with GroupingSelector for improved task categorization. - Enhanced drag-and-drop capabilities for task reordering within groups. - Updated styling and responsiveness across task management components for better user experience.
This commit is contained in:
@@ -1,174 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Card, Button, Space, Typography, Dropdown, Menu, Popconfirm, message } from 'antd';
|
|
||||||
import {
|
|
||||||
DeleteOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
TagOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
CheckOutlined,
|
|
||||||
CloseOutlined,
|
|
||||||
MoreOutlined,
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
|
||||||
import { IGroupBy, bulkUpdateTasks, bulkDeleteTasks } from '@/features/tasks/tasks.slice';
|
|
||||||
import { AppDispatch, RootState } from '@/app/store';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface BulkActionBarProps {
|
|
||||||
selectedTaskIds: string[];
|
|
||||||
totalSelected: number;
|
|
||||||
currentGrouping: IGroupBy;
|
|
||||||
projectId: string;
|
|
||||||
onClearSelection?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BulkActionBar: React.FC<BulkActionBarProps> = ({
|
|
||||||
selectedTaskIds,
|
|
||||||
totalSelected,
|
|
||||||
currentGrouping,
|
|
||||||
projectId,
|
|
||||||
onClearSelection,
|
|
||||||
}) => {
|
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
|
||||||
const { statuses, priorities } = useSelector((state: RootState) => state.taskReducer);
|
|
||||||
|
|
||||||
const handleBulkStatusChange = (statusId: string) => {
|
|
||||||
// dispatch(bulkUpdateTasks({ ids: selectedTaskIds, changes: { status: statusId } }));
|
|
||||||
message.success(`Updated ${totalSelected} tasks`);
|
|
||||||
onClearSelection?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkPriorityChange = (priority: string) => {
|
|
||||||
// dispatch(bulkUpdateTasks({ ids: selectedTaskIds, changes: { priority } }));
|
|
||||||
message.success(`Updated ${totalSelected} tasks`);
|
|
||||||
onClearSelection?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBulkDelete = () => {
|
|
||||||
// dispatch(bulkDeleteTasks(selectedTaskIds));
|
|
||||||
message.success(`Deleted ${totalSelected} tasks`);
|
|
||||||
onClearSelection?.();
|
|
||||||
};
|
|
||||||
|
|
||||||
const statusMenu = (
|
|
||||||
<Menu
|
|
||||||
onClick={({ key }) => handleBulkStatusChange(key)}
|
|
||||||
items={statuses.map(status => ({
|
|
||||||
key: status.id!,
|
|
||||||
label: (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<div
|
|
||||||
className="w-3 h-3 rounded-full"
|
|
||||||
style={{ backgroundColor: status.color_code }}
|
|
||||||
/>
|
|
||||||
<span>{status.name}</span>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
}))}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const priorityMenu = (
|
|
||||||
<Menu
|
|
||||||
onClick={({ key }) => handleBulkPriorityChange(key)}
|
|
||||||
items={[
|
|
||||||
{ key: 'critical', label: 'Critical', icon: <div className="w-2 h-2 rounded-full bg-red-500" /> },
|
|
||||||
{ key: 'high', label: 'High', icon: <div className="w-2 h-2 rounded-full bg-orange-500" /> },
|
|
||||||
{ key: 'medium', label: 'Medium', icon: <div className="w-2 h-2 rounded-full bg-yellow-500" /> },
|
|
||||||
{ key: 'low', label: 'Low', icon: <div className="w-2 h-2 rounded-full bg-green-500" /> },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const moreActionsMenu = (
|
|
||||||
<Menu
|
|
||||||
items={[
|
|
||||||
{
|
|
||||||
key: 'assign',
|
|
||||||
label: 'Assign to member',
|
|
||||||
icon: <UserOutlined />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'labels',
|
|
||||||
label: 'Add labels',
|
|
||||||
icon: <TagOutlined />,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'archive',
|
|
||||||
label: 'Archive tasks',
|
|
||||||
icon: <EditOutlined />,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
size="small"
|
|
||||||
className="mb-4 bg-blue-50 border-blue-200"
|
|
||||||
styles={{ body: { padding: '8px 16px' } }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-3">
|
|
||||||
<Text strong className="text-blue-700">
|
|
||||||
{totalSelected} task{totalSelected > 1 ? 's' : ''} selected
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Space>
|
|
||||||
{/* Status Change */}
|
|
||||||
{currentGrouping !== 'status' && (
|
|
||||||
<Dropdown overlay={statusMenu} trigger={['click']}>
|
|
||||||
<Button size="small" icon={<CheckOutlined />}>
|
|
||||||
Change Status
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Priority Change */}
|
|
||||||
{currentGrouping !== 'priority' && (
|
|
||||||
<Dropdown overlay={priorityMenu} trigger={['click']}>
|
|
||||||
<Button size="small" icon={<EditOutlined />}>
|
|
||||||
Set Priority
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* More Actions */}
|
|
||||||
<Dropdown overlay={moreActionsMenu} trigger={['click']}>
|
|
||||||
<Button size="small" icon={<MoreOutlined />}>
|
|
||||||
More Actions
|
|
||||||
</Button>
|
|
||||||
</Dropdown>
|
|
||||||
|
|
||||||
{/* Delete */}
|
|
||||||
<Popconfirm
|
|
||||||
title={`Delete ${totalSelected} task${totalSelected > 1 ? 's' : ''}?`}
|
|
||||||
description="This action cannot be undone."
|
|
||||||
onConfirm={handleBulkDelete}
|
|
||||||
okText="Delete"
|
|
||||||
cancelText="Cancel"
|
|
||||||
okType="danger"
|
|
||||||
>
|
|
||||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
|
||||||
Delete
|
|
||||||
</Button>
|
|
||||||
</Popconfirm>
|
|
||||||
|
|
||||||
{/* Clear Selection */}
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={<CloseOutlined />}
|
|
||||||
onClick={onClearSelection}
|
|
||||||
title="Clear selection"
|
|
||||||
>
|
|
||||||
Clear
|
|
||||||
</Button>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BulkActionBar;
|
|
||||||
@@ -0,0 +1,590 @@
|
|||||||
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Button, Typography, Dropdown, Menu, Popconfirm, message, Tooltip, Badge, CheckboxChangeEvent, InputRef } from 'antd';
|
||||||
|
import {
|
||||||
|
DeleteOutlined,
|
||||||
|
EditOutlined,
|
||||||
|
TagOutlined,
|
||||||
|
UserOutlined,
|
||||||
|
CheckOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
MoreOutlined,
|
||||||
|
RetweetOutlined,
|
||||||
|
UserAddOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
TagsOutlined,
|
||||||
|
UsergroupAddOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { IGroupBy, fetchTaskGroups } from '@/features/tasks/tasks.slice';
|
||||||
|
import { AppDispatch, RootState } from '@/app/store';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
|
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||||
|
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
|
||||||
|
import {
|
||||||
|
evt_project_task_list_bulk_archive,
|
||||||
|
evt_project_task_list_bulk_assign_me,
|
||||||
|
evt_project_task_list_bulk_assign_members,
|
||||||
|
evt_project_task_list_bulk_change_phase,
|
||||||
|
evt_project_task_list_bulk_change_priority,
|
||||||
|
evt_project_task_list_bulk_change_status,
|
||||||
|
evt_project_task_list_bulk_delete,
|
||||||
|
evt_project_task_list_bulk_update_labels,
|
||||||
|
} from '@/shared/worklenz-analytics-events';
|
||||||
|
import {
|
||||||
|
IBulkTasksLabelsRequest,
|
||||||
|
IBulkTasksPhaseChangeRequest,
|
||||||
|
IBulkTasksPriorityChangeRequest,
|
||||||
|
IBulkTasksStatusChangeRequest,
|
||||||
|
} from '@/types/tasks/bulk-action-bar.types';
|
||||||
|
import { ITaskStatus } from '@/types/tasks/taskStatus.types';
|
||||||
|
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
||||||
|
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||||
|
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||||
|
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
||||||
|
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
||||||
|
import { ITaskAssignee } from '@/types/tasks/task.types';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||||
|
import AssigneesDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/AssigneesDropdown';
|
||||||
|
import LabelsDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown';
|
||||||
|
import { sortTeamMembers } from '@/utils/sort-team-members';
|
||||||
|
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||||
|
import alertService from '@/services/alerts/alertService';
|
||||||
|
import logger from '@/utils/errorLogger';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
interface BulkActionBarProps {
|
||||||
|
selectedTaskIds: string[];
|
||||||
|
totalSelected: number;
|
||||||
|
currentGrouping: IGroupBy;
|
||||||
|
projectId: string;
|
||||||
|
onClearSelection?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BulkActionBarContent: React.FC<BulkActionBarProps> = ({
|
||||||
|
selectedTaskIds,
|
||||||
|
totalSelected,
|
||||||
|
currentGrouping,
|
||||||
|
projectId,
|
||||||
|
onClearSelection,
|
||||||
|
}) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { t } = useTranslation('tasks/task-table-bulk-actions');
|
||||||
|
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||||
|
|
||||||
|
// Add permission hooks
|
||||||
|
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||||
|
|
||||||
|
// loading state
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [updatingLabels, setUpdatingLabels] = useState(false);
|
||||||
|
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
|
||||||
|
const [updatingAssignees, setUpdatingAssignees] = useState(false);
|
||||||
|
const [updatingArchive, setUpdatingArchive] = useState(false);
|
||||||
|
const [updatingDelete, setUpdatingDelete] = useState(false);
|
||||||
|
|
||||||
|
// Selectors
|
||||||
|
const { selectedTaskIdsList } = useAppSelector(state => state.bulkActionReducer);
|
||||||
|
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
||||||
|
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
|
||||||
|
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
|
||||||
|
const labelsList = useAppSelector(state => state.taskLabelsReducer.labels);
|
||||||
|
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
|
||||||
|
const archived = useAppSelector(state => state.taskReducer.archived);
|
||||||
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
|
|
||||||
|
const labelsInputRef = useRef<InputRef>(null);
|
||||||
|
const [createLabelText, setCreateLabelText] = useState<string>('');
|
||||||
|
const [teamMembersSorted, setTeamMembersSorted] = useState<ITeamMembersViewModel>({
|
||||||
|
data: [],
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false);
|
||||||
|
const [showDrawer, setShowDrawer] = useState(false);
|
||||||
|
const [selectedLabels, setSelectedLabels] = useState<ITaskLabel[]>([]);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleChangeStatus = async (status: ITaskStatus) => {
|
||||||
|
if (!status.id || !projectId) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
|
||||||
|
const body: IBulkTasksStatusChangeRequest = {
|
||||||
|
tasks: selectedTaskIds,
|
||||||
|
status_id: status.id,
|
||||||
|
};
|
||||||
|
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
|
||||||
|
dispatch(deselectAll());
|
||||||
|
dispatch(fetchTaskGroups(projectId));
|
||||||
|
onClearSelection?.();
|
||||||
|
}
|
||||||
|
for (const it of selectedTaskIds) {
|
||||||
|
const canContinue = await checkTaskDependencyStatus(it, status.id);
|
||||||
|
if (!canContinue) {
|
||||||
|
if (selectedTaskIds.length > 1) {
|
||||||
|
alertService.warning(
|
||||||
|
'Incomplete Dependencies!',
|
||||||
|
'Some tasks were not updated. Please ensure all dependent tasks are completed before proceeding.'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
alertService.error(
|
||||||
|
'Task is not completed',
|
||||||
|
'Please complete the task dependencies before proceeding'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error changing status:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePriority = async (priority: ITaskPriority) => {
|
||||||
|
if (!priority.id || !projectId) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const body: IBulkTasksPriorityChangeRequest = {
|
||||||
|
tasks: selectedTaskIds,
|
||||||
|
priority_id: priority.id,
|
||||||
|
};
|
||||||
|
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
|
||||||
|
dispatch(deselectAll());
|
||||||
|
dispatch(fetchTaskGroups(projectId));
|
||||||
|
onClearSelection?.();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error changing priority:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangePhase = async (phase: ITaskPhase) => {
|
||||||
|
if (!phase.id || !projectId) return;
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const body: IBulkTasksPhaseChangeRequest = {
|
||||||
|
tasks: selectedTaskIds,
|
||||||
|
phase_id: phase.id,
|
||||||
|
};
|
||||||
|
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
|
||||||
|
dispatch(deselectAll());
|
||||||
|
dispatch(fetchTaskGroups(projectId));
|
||||||
|
onClearSelection?.();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error changing phase:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAssignToMe = async () => {
|
||||||
|
if (!projectId) return;
|
||||||
|
try {
|
||||||
|
setUpdatingAssignToMe(true);
|
||||||
|
const body = {
|
||||||
|
tasks: selectedTaskIds,
|
||||||
|
project_id: projectId,
|
||||||
|
};
|
||||||
|
const res = await taskListBulkActionsApiService.assignToMe(body);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_task_list_bulk_assign_me);
|
||||||
|
dispatch(deselectAll());
|
||||||
|
dispatch(fetchTaskGroups(projectId));
|
||||||
|
onClearSelection?.();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error assigning to me:', error);
|
||||||
|
} finally {
|
||||||
|
setUpdatingAssignToMe(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleArchive = async () => {
|
||||||
|
if (!projectId) return;
|
||||||
|
try {
|
||||||
|
setUpdatingArchive(true);
|
||||||
|
const body = {
|
||||||
|
tasks: selectedTaskIds,
|
||||||
|
project_id: projectId,
|
||||||
|
};
|
||||||
|
const res = await taskListBulkActionsApiService.archiveTasks(body, archived);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_task_list_bulk_archive);
|
||||||
|
dispatch(deselectAll());
|
||||||
|
dispatch(fetchTaskGroups(projectId));
|
||||||
|
onClearSelection?.();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error archiving tasks:', error);
|
||||||
|
} finally {
|
||||||
|
setUpdatingArchive(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleChangeAssignees = async (selectedAssignees: ITeamMemberViewModel[]) => {
|
||||||
|
if (!projectId) return;
|
||||||
|
try {
|
||||||
|
setUpdatingAssignees(true);
|
||||||
|
const body = {
|
||||||
|
tasks: selectedTaskIds,
|
||||||
|
project_id: projectId,
|
||||||
|
members: selectedAssignees.map(member => ({
|
||||||
|
id: member.id,
|
||||||
|
name: member.name || member.email || 'Unknown', // Fix: Ensure name is always a string
|
||||||
|
email: member.email || '',
|
||||||
|
avatar_url: member.avatar_url,
|
||||||
|
team_member_id: member.id,
|
||||||
|
project_member_id: member.id,
|
||||||
|
})) as ITaskAssignee[],
|
||||||
|
};
|
||||||
|
const res = await taskListBulkActionsApiService.assignTasks(body);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
|
||||||
|
dispatch(deselectAll());
|
||||||
|
dispatch(fetchTaskGroups(projectId));
|
||||||
|
onClearSelection?.();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error assigning tasks:', error);
|
||||||
|
} finally {
|
||||||
|
setUpdatingAssignees(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async () => {
|
||||||
|
if (!projectId) return;
|
||||||
|
try {
|
||||||
|
setUpdatingDelete(true);
|
||||||
|
const body = {
|
||||||
|
tasks: selectedTaskIds,
|
||||||
|
project_id: projectId,
|
||||||
|
};
|
||||||
|
const res = await taskListBulkActionsApiService.deleteTasks(body, projectId);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_task_list_bulk_delete);
|
||||||
|
dispatch(deselectAll());
|
||||||
|
dispatch(fetchTaskGroups(projectId));
|
||||||
|
onClearSelection?.();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting tasks:', error);
|
||||||
|
} finally {
|
||||||
|
setUpdatingDelete(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Menu Generators
|
||||||
|
const getChangeOptionsMenu = () => [
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: t('status'),
|
||||||
|
children: statusList.map(status => ({
|
||||||
|
key: status.id,
|
||||||
|
onClick: () => handleChangeStatus(status),
|
||||||
|
label: <Badge color={status.color_code} text={status.name} />,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '2',
|
||||||
|
label: t('priority'),
|
||||||
|
children: priorityList.map(priority => ({
|
||||||
|
key: priority.id,
|
||||||
|
onClick: () => handleChangePriority(priority),
|
||||||
|
label: <Badge color={priority.color_code} text={priority.name} />,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: '3',
|
||||||
|
label: t('phase'),
|
||||||
|
children: phaseList.map(phase => ({
|
||||||
|
key: phase.id,
|
||||||
|
onClick: () => handleChangePhase(phase),
|
||||||
|
label: <Badge color={phase.color_code} text={phase.name} />,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (members?.data && assigneeDropdownOpen) {
|
||||||
|
let sortedMembers = sortTeamMembers(members.data);
|
||||||
|
setTeamMembersSorted({ data: sortedMembers, total: members.total });
|
||||||
|
}
|
||||||
|
}, [assigneeDropdownOpen, members?.data]);
|
||||||
|
|
||||||
|
const getAssigneesMenu = () => {
|
||||||
|
return (
|
||||||
|
<AssigneesDropdown
|
||||||
|
members={teamMembersSorted?.data || []}
|
||||||
|
themeMode={themeMode}
|
||||||
|
onApply={handleChangeAssignees}
|
||||||
|
onClose={() => setAssigneeDropdownOpen(false)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLabelChange = (e: CheckboxChangeEvent, label: ITaskLabel) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelectedLabels(prev => [...prev, label]);
|
||||||
|
} else {
|
||||||
|
setSelectedLabels(prev => prev.filter(l => l.id !== label.id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyLabels = async () => {
|
||||||
|
if (!projectId) return;
|
||||||
|
try {
|
||||||
|
setUpdatingLabels(true);
|
||||||
|
const body: IBulkTasksLabelsRequest = {
|
||||||
|
tasks: selectedTaskIds,
|
||||||
|
labels: selectedLabels,
|
||||||
|
text:
|
||||||
|
selectedLabels.length > 0
|
||||||
|
? null
|
||||||
|
: createLabelText.trim() !== ''
|
||||||
|
? createLabelText.trim()
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
|
||||||
|
dispatch(deselectAll());
|
||||||
|
dispatch(fetchTaskGroups(projectId));
|
||||||
|
dispatch(fetchLabels()); // Fallback: refetch all labels
|
||||||
|
setCreateLabelText('');
|
||||||
|
setSelectedLabels([]);
|
||||||
|
onClearSelection?.();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating labels:', error);
|
||||||
|
} finally {
|
||||||
|
setUpdatingLabels(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const labelsDropdownContent = (
|
||||||
|
<LabelsDropdown
|
||||||
|
labelsList={labelsList}
|
||||||
|
themeMode={themeMode}
|
||||||
|
createLabelText={createLabelText}
|
||||||
|
selectedLabels={selectedLabels}
|
||||||
|
labelsInputRef={labelsInputRef as React.RefObject<InputRef>}
|
||||||
|
onLabelChange={handleLabelChange}
|
||||||
|
onCreateLabelTextChange={value => setCreateLabelText(value)}
|
||||||
|
onApply={applyLabels}
|
||||||
|
t={t}
|
||||||
|
loading={updatingLabels}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
const onAssigneeDropdownOpenChange = (open: boolean) => {
|
||||||
|
setAssigneeDropdownOpen(open);
|
||||||
|
};
|
||||||
|
|
||||||
|
const buttonStyle = {
|
||||||
|
background: 'transparent',
|
||||||
|
color: '#fff',
|
||||||
|
border: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '4px 8px',
|
||||||
|
height: '32px',
|
||||||
|
fontSize: '16px',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
bottom: '30px',
|
||||||
|
left: '50%',
|
||||||
|
transform: 'translateX(-50%)',
|
||||||
|
zIndex: 1000,
|
||||||
|
background: '#252628',
|
||||||
|
borderRadius: '25px',
|
||||||
|
padding: '8px 16px',
|
||||||
|
boxShadow: '0 0 0 1px #434343, 0 4px 12px 0 rgba(0, 0, 0, 0.15)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: '8px',
|
||||||
|
minWidth: 'fit-content',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={{ color: '#fff', fontSize: '14px', fontWeight: 500, marginRight: '8px' }}>
|
||||||
|
{totalSelected} task{totalSelected > 1 ? 's' : ''} selected
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{/* Status/Priority/Phase Change */}
|
||||||
|
<Tooltip title="Change Status/Priority/Phase">
|
||||||
|
<Dropdown menu={{ items: getChangeOptionsMenu() }} trigger={['click']}>
|
||||||
|
<Button
|
||||||
|
icon={<RetweetOutlined />}
|
||||||
|
style={buttonStyle}
|
||||||
|
size="small"
|
||||||
|
loading={loading}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Labels */}
|
||||||
|
<Tooltip title="Add Labels">
|
||||||
|
<Dropdown
|
||||||
|
dropdownRender={() => labelsDropdownContent}
|
||||||
|
placement="top"
|
||||||
|
arrow
|
||||||
|
trigger={['click']}
|
||||||
|
onOpenChange={value => {
|
||||||
|
if (!value) {
|
||||||
|
setSelectedLabels([]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<TagsOutlined />}
|
||||||
|
style={buttonStyle}
|
||||||
|
size="small"
|
||||||
|
loading={updatingLabels}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Assign to Me */}
|
||||||
|
<Tooltip title="Assign to Me">
|
||||||
|
<Button
|
||||||
|
icon={<UserAddOutlined />}
|
||||||
|
style={buttonStyle}
|
||||||
|
size="small"
|
||||||
|
onClick={handleAssignToMe}
|
||||||
|
loading={updatingAssignToMe}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Assign Members */}
|
||||||
|
<Tooltip title="Assign Members">
|
||||||
|
<Dropdown
|
||||||
|
dropdownRender={getAssigneesMenu}
|
||||||
|
open={assigneeDropdownOpen}
|
||||||
|
onOpenChange={onAssigneeDropdownOpenChange}
|
||||||
|
placement="top"
|
||||||
|
arrow
|
||||||
|
trigger={['click']}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<UsergroupAddOutlined />}
|
||||||
|
style={buttonStyle}
|
||||||
|
size="small"
|
||||||
|
loading={updatingAssignees}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Archive */}
|
||||||
|
<Tooltip title={archived ? 'Unarchive' : 'Archive'}>
|
||||||
|
<Button
|
||||||
|
icon={<InboxOutlined />}
|
||||||
|
style={buttonStyle}
|
||||||
|
size="small"
|
||||||
|
onClick={handleArchive}
|
||||||
|
loading={updatingArchive}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Delete */}
|
||||||
|
<Tooltip title="Delete">
|
||||||
|
<Popconfirm
|
||||||
|
title={`Delete ${totalSelected} task${totalSelected > 1 ? 's' : ''}?`}
|
||||||
|
description="This action cannot be undone."
|
||||||
|
onConfirm={handleDelete}
|
||||||
|
okText="Delete"
|
||||||
|
cancelText="Cancel"
|
||||||
|
okType="danger"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<DeleteOutlined />}
|
||||||
|
style={buttonStyle}
|
||||||
|
size="small"
|
||||||
|
loading={updatingDelete}
|
||||||
|
/>
|
||||||
|
</Popconfirm>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* More Actions - Only for Owner/Admin */}
|
||||||
|
{isOwnerOrAdmin && (
|
||||||
|
<Tooltip title="More Actions">
|
||||||
|
<Dropdown
|
||||||
|
trigger={['click']}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: 'createTemplate',
|
||||||
|
label: 'Create task template',
|
||||||
|
onClick: () => setShowDrawer(true),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<MoreOutlined />}
|
||||||
|
style={buttonStyle}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Clear Selection */}
|
||||||
|
<Tooltip title="Clear Selection">
|
||||||
|
<Button
|
||||||
|
icon={<CloseOutlined />}
|
||||||
|
onClick={onClearSelection}
|
||||||
|
style={buttonStyle}
|
||||||
|
size="small"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{/* Task Template Drawer */}
|
||||||
|
{createPortal(
|
||||||
|
<TaskTemplateDrawer
|
||||||
|
showDrawer={showDrawer}
|
||||||
|
selectedTemplateId={null}
|
||||||
|
onClose={() => {
|
||||||
|
setShowDrawer(false);
|
||||||
|
dispatch(deselectAll());
|
||||||
|
onClearSelection?.();
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
'create-task-template'
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const BulkActionBar: React.FC<BulkActionBarProps> = (props) => {
|
||||||
|
// Render the bulk action bar through a portal to avoid suspense issues
|
||||||
|
return createPortal(
|
||||||
|
<BulkActionBarContent {...props} />,
|
||||||
|
document.body,
|
||||||
|
'bulk-action-bar'
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BulkActionBar;
|
||||||
@@ -8,7 +8,7 @@ import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
|||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
import { IGroupBy, COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
import { IGroupBy, COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import TaskRow from './TaskRow';
|
import TaskRow from './task-row';
|
||||||
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
|
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -126,7 +126,10 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
|
|
||||||
{/* Column Headers */}
|
{/* Column Headers */}
|
||||||
{!isCollapsed && totalTasks > 0 && (
|
{!isCollapsed && totalTasks > 0 && (
|
||||||
<div className="task-group-column-headers">
|
<div
|
||||||
|
className="task-group-column-headers"
|
||||||
|
style={{ borderLeft: `4px solid ${getGroupColor()}` }}
|
||||||
|
>
|
||||||
<div className="task-group-column-headers-row">
|
<div className="task-group-column-headers-row">
|
||||||
<div className="task-table-fixed-columns">
|
<div className="task-table-fixed-columns">
|
||||||
<div
|
<div
|
||||||
@@ -182,7 +185,10 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
|
|
||||||
{/* Tasks List */}
|
{/* Tasks List */}
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="task-group-body">
|
<div
|
||||||
|
className="task-group-body"
|
||||||
|
style={{ borderLeft: `4px solid ${getGroupColor()}` }}
|
||||||
|
>
|
||||||
{group.tasks.length === 0 ? (
|
{group.tasks.length === 0 ? (
|
||||||
<div className="task-group-empty">
|
<div className="task-group-empty">
|
||||||
<div className="task-table-fixed-columns">
|
<div className="task-table-fixed-columns">
|
||||||
@@ -262,7 +268,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 6px;
|
border-radius: 6px 6px 0 0;
|
||||||
background-color: #f0f0f0;
|
background-color: #f0f0f0;
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -24,9 +24,9 @@ import {
|
|||||||
reorderTasks,
|
reorderTasks,
|
||||||
} from '@/features/tasks/tasks.slice';
|
} from '@/features/tasks/tasks.slice';
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
import TaskGroup from './TaskGroup';
|
import TaskGroup from './task-group';
|
||||||
import TaskRow from './TaskRow';
|
import TaskRow from './task-row';
|
||||||
import BulkActionBar from './BulkActionBar';
|
import BulkActionBar from './bulk-action-bar';
|
||||||
import { AppDispatch } from '@/app/store';
|
import { AppDispatch } from '@/app/store';
|
||||||
|
|
||||||
// Import the TaskListFilters component
|
// Import the TaskListFilters component
|
||||||
@@ -242,6 +242,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
totalSelected={selectedTaskIds.length}
|
totalSelected={selectedTaskIds.length}
|
||||||
currentGrouping={groupBy}
|
currentGrouping={groupBy}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
|
onClearSelection={() => setSelectedTaskIds([])}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -247,7 +247,7 @@ const TaskListBulkActionsBar = () => {
|
|||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
members: selectedAssignees.map(member => ({
|
members: selectedAssignees.map(member => ({
|
||||||
id: member.id,
|
id: member.id,
|
||||||
name: member.name,
|
name: member.name || '',
|
||||||
email: member.email,
|
email: member.email,
|
||||||
avatar_url: member.avatar_url,
|
avatar_url: member.avatar_url,
|
||||||
})) as ITaskAssignee[],
|
})) as ITaskAssignee[],
|
||||||
@@ -437,7 +437,6 @@ const TaskListBulkActionsBar = () => {
|
|||||||
placement="top"
|
placement="top"
|
||||||
arrow
|
arrow
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
destroyOnHidden
|
|
||||||
onOpenChange={value => {
|
onOpenChange={value => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
setSelectedLabels([]);
|
setSelectedLabels([]);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useEffect } from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import { Layout, Typography, Card, Space, Alert } from 'antd';
|
import { Layout, Typography, Card, Space, Alert } from 'antd';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import TaskListBoard from '@/components/task-management/TaskListBoard';
|
import TaskListBoard from '@/components/task-management/task-list-board';
|
||||||
import { AppDispatch } from '@/app/store';
|
import { AppDispatch } from '@/app/store';
|
||||||
|
|
||||||
const { Header, Content } = Layout;
|
const { Header, Content } = Layout;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import TaskListBoard from '@/components/task-management/TaskListBoard';
|
import TaskListBoard from '@/components/task-management/task-list-board';
|
||||||
|
|
||||||
const ProjectViewEnhancedTasks: React.FC = () => {
|
const ProjectViewEnhancedTasks: React.FC = () => {
|
||||||
const { project } = useAppSelector(state => state.projectReducer);
|
const { project } = useAppSelector(state => state.projectReducer);
|
||||||
|
|||||||
Reference in New Issue
Block a user