init
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
import { Button, Flex } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { nanoid } from '@reduxjs/toolkit';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { addBoardSectionCard, fetchBoardTaskGroups, IGroupBy } from '@features/board/board-slice';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
|
||||
import { createStatus, fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
|
||||
|
||||
const BoardCreateSectionCard = () => {
|
||||
const { t } = useTranslation('kanban-board');
|
||||
|
||||
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
||||
const { projectId } = useAppSelector((state) => state.projectReducer);
|
||||
const groupBy = useAppSelector((state) => state.boardReducer.groupBy);
|
||||
const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getUniqueSectionName = (baseName: string): string => {
|
||||
// Check if the base name already exists
|
||||
const existingNames = existingStatuses.map(status => status.name?.toLowerCase());
|
||||
|
||||
if (!existingNames.includes(baseName.toLowerCase())) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
// If the base name exists, add a number suffix
|
||||
let counter = 1;
|
||||
let newName = `${baseName.trim()} (${counter})`;
|
||||
|
||||
while (existingNames.includes(newName.toLowerCase())) {
|
||||
counter++;
|
||||
newName = `${baseName.trim()} (${counter})`;
|
||||
}
|
||||
|
||||
return newName;
|
||||
};
|
||||
|
||||
const handleAddSection = async () => {
|
||||
const sectionId = nanoid();
|
||||
const baseNameSection = 'Untitled section';
|
||||
const sectionName = getUniqueSectionName(baseNameSection);
|
||||
|
||||
if (groupBy === IGroupBy.STATUS && projectId) {
|
||||
// Find the "To do" category
|
||||
const todoCategory = statusCategories.find(category =>
|
||||
category.name?.toLowerCase() === 'to do' ||
|
||||
category.name?.toLowerCase() === 'todo'
|
||||
);
|
||||
|
||||
if (todoCategory && todoCategory.id) {
|
||||
// Create a new status
|
||||
const body = {
|
||||
name: sectionName,
|
||||
project_id: projectId,
|
||||
category_id: todoCategory.id,
|
||||
};
|
||||
|
||||
try {
|
||||
// Create the status
|
||||
const response = await dispatch(createStatus({ body, currentProjectId: projectId })).unwrap();
|
||||
|
||||
if (response.done && response.body) {
|
||||
dispatch(
|
||||
addBoardSectionCard({
|
||||
id: response.body.id as string,
|
||||
name: sectionName,
|
||||
colorCode: (response.body.color_code || todoCategory.color_code || '#d8d7d8') + ALPHA_CHANNEL,
|
||||
colorCodeDark: '#989898',
|
||||
})
|
||||
);
|
||||
|
||||
// Refresh the board to show the new section
|
||||
dispatch(fetchBoardTaskGroups(projectId));
|
||||
// Refresh statuses
|
||||
dispatch(fetchStatuses(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create status:', error);
|
||||
}
|
||||
} else {
|
||||
// Fallback if "To do" category not found
|
||||
dispatch(
|
||||
addBoardSectionCard({
|
||||
id: sectionId,
|
||||
name: sectionName,
|
||||
colorCode: '#d8d7d8',
|
||||
colorCodeDark: '#989898',
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (groupBy === IGroupBy.PHASE && projectId) {
|
||||
const body = {
|
||||
name: sectionName,
|
||||
project_id: projectId,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await phasesApiService.addPhaseOption(projectId);
|
||||
if (response.done && response.body) {
|
||||
dispatch(fetchBoardTaskGroups(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create phase:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
vertical
|
||||
gap={16}
|
||||
style={{
|
||||
minWidth: 375,
|
||||
padding: 8,
|
||||
borderRadius: 12,
|
||||
}}
|
||||
className="h-[600px] max-h-[600px] overflow-y-scroll"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
height: 640,
|
||||
background: themeWiseColor(
|
||||
'linear-gradient( 180deg, #fafafa, rgba(245, 243, 243, 0))',
|
||||
'linear-gradient( 180deg, #2a2b2d, rgba(42, 43, 45, 0))',
|
||||
themeMode
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
style={{
|
||||
height: '38px',
|
||||
width: '100%',
|
||||
borderRadius: 6,
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddSection}
|
||||
>
|
||||
{t('addSectionButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardCreateSectionCard;
|
||||
@@ -0,0 +1,377 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dropdown,
|
||||
Flex,
|
||||
Input,
|
||||
InputRef,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ExclamationCircleFilled,
|
||||
LoadingOutlined,
|
||||
MoreOutlined,
|
||||
PlusOutlined,
|
||||
RetweetOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { MenuProps } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ChangeCategoryDropdown from '@/components/board/changeCategoryDropdown/ChangeCategoryDropdown';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
deleteSection,
|
||||
IGroupBy,
|
||||
setBoardGroupName,
|
||||
setEditableSection,
|
||||
} from '@features/board/board-slice';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
|
||||
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
|
||||
import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events';
|
||||
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { updateTaskGroupColor } from '@/features/tasks/tasks.slice';
|
||||
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||
import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types';
|
||||
import { update } from 'lodash';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { toggleDrawer } from '@/features/projects/status/StatusSlice';
|
||||
import { deleteStatusToggleDrawer, seletedStatusCategory } from '@/features/projects/status/DeleteStatusSlice';
|
||||
|
||||
interface BoardSectionCardHeaderProps {
|
||||
groupId: string;
|
||||
name: string;
|
||||
tasksCount: number;
|
||||
isLoading: boolean;
|
||||
setName: (newName: string) => void;
|
||||
colorCode: string;
|
||||
onHoverChange: (hovered: boolean) => void;
|
||||
setShowNewCard: (x: boolean) => void;
|
||||
categoryId: string | null;
|
||||
}
|
||||
|
||||
const BoardSectionCardHeader: React.FC<BoardSectionCardHeaderProps> = ({
|
||||
groupId,
|
||||
name,
|
||||
tasksCount,
|
||||
isLoading,
|
||||
setName,
|
||||
colorCode,
|
||||
onHoverChange,
|
||||
setShowNewCard,
|
||||
categoryId = null,
|
||||
}) => {
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||
const isProjectManager = useIsProjectManager();
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
const [editName, setEdit] = useState(name);
|
||||
const [isEllipsisActive, setIsEllipsisActive] = useState(false);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const { editableSectionId, groupBy } = useAppSelector(state => state.boardReducer);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { statusCategories, status } = useAppSelector(state => state.taskStatusReducer);
|
||||
|
||||
const { t } = useTranslation('kanban-board');
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditable && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editableSectionId === groupId && (isProjectManager || isOwnerOrAdmin)) {
|
||||
setIsEditable(true);
|
||||
dispatch(setEditableSection(null));
|
||||
}
|
||||
}, [editableSectionId, groupId, dispatch]);
|
||||
|
||||
const getUniqueSectionName = (baseName: string): string => {
|
||||
// Check if the base name already exists
|
||||
const existingNames = status.map(status => status.name?.toLowerCase());
|
||||
|
||||
if (!existingNames.includes(baseName.toLowerCase())) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
// If the base name exists, add a number suffix
|
||||
let counter = 1;
|
||||
let newName = `${baseName.trim()} (${counter})`;
|
||||
|
||||
while (existingNames.includes(newName.toLowerCase())) {
|
||||
counter++;
|
||||
newName = `${baseName.trim()} (${counter})`;
|
||||
}
|
||||
|
||||
return newName;
|
||||
};
|
||||
|
||||
const updateStatus = async (category = categoryId) => {
|
||||
if (!category || !projectId || !groupId) return;
|
||||
const sectionName = getUniqueSectionName(name);
|
||||
const body: ITaskStatusUpdateModel = {
|
||||
name: sectionName,
|
||||
project_id: projectId,
|
||||
category_id: category,
|
||||
};
|
||||
const res = await statusApiService.updateStatus(groupId, body, projectId);
|
||||
if (res.done) {
|
||||
dispatch(
|
||||
setBoardGroupName({
|
||||
groupId,
|
||||
name: sectionName ?? '',
|
||||
colorCode: res.body.color_code ?? '',
|
||||
colorCodeDark: res.body.color_code_dark ?? '',
|
||||
categoryId: category,
|
||||
})
|
||||
);
|
||||
dispatch(fetchStatuses(projectId));
|
||||
setName(sectionName);
|
||||
} else {
|
||||
setName(editName);
|
||||
logger.error('Error updating status', res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const taskName = e.target.value;
|
||||
setName(taskName);
|
||||
};
|
||||
|
||||
const handleBlur = async () => {
|
||||
if (name === 'Untitled section') {
|
||||
dispatch(deleteSection({ sectionId: groupId }));
|
||||
}
|
||||
setIsEditable(false);
|
||||
|
||||
if (!projectId || !groupId) return;
|
||||
|
||||
if (groupBy === IGroupBy.STATUS) {
|
||||
await updateStatus();
|
||||
}
|
||||
|
||||
if (groupBy === IGroupBy.PHASE) {
|
||||
const body = {
|
||||
id: groupId,
|
||||
name: name,
|
||||
};
|
||||
|
||||
const res = await phasesApiService.updateNameOfPhase(groupId, body as ITaskPhase, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' });
|
||||
// dispatch(fetchPhasesByProjectId(projectId));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePressEnter = () => {
|
||||
setShowNewCard(true);
|
||||
setIsEditable(false);
|
||||
handleBlur();
|
||||
};
|
||||
|
||||
const handleDeleteSection = async () => {
|
||||
if (!projectId || !groupId) return;
|
||||
|
||||
try {
|
||||
if (groupBy === IGroupBy.STATUS) {
|
||||
const replacingStatusId = '';
|
||||
const res = await statusApiService.deleteStatus(groupId, projectId, replacingStatusId);
|
||||
if (res.message === 'At least one status should exists under each category.') return
|
||||
if (res.done) {
|
||||
dispatch(deleteSection({ sectionId: groupId }));
|
||||
} else {
|
||||
dispatch(seletedStatusCategory({ id: groupId, name: name, category_id: categoryId ?? '', message: res.message ?? '' }));
|
||||
dispatch(deleteStatusToggleDrawer());
|
||||
}
|
||||
} else if (groupBy === IGroupBy.PHASE) {
|
||||
const res = await phasesApiService.deletePhaseOption(groupId, projectId);
|
||||
if (res.done) {
|
||||
dispatch(deleteSection({ sectionId: groupId }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting section', error);
|
||||
}
|
||||
};
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
width: '100%',
|
||||
gap: '8px',
|
||||
}}
|
||||
onClick={() => setIsEditable(true)}
|
||||
>
|
||||
<EditOutlined /> <span>{t('rename')}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
groupBy === IGroupBy.STATUS && {
|
||||
key: '2',
|
||||
icon: <RetweetOutlined />,
|
||||
label: 'Change category',
|
||||
children: statusCategories?.map(status => ({
|
||||
key: status.id,
|
||||
label: (
|
||||
<Flex
|
||||
gap={8}
|
||||
onClick={() => status.id && updateStatus(status.id)}
|
||||
style={categoryId === status.id ? { fontWeight: 700 } : {}}
|
||||
>
|
||||
<Badge color={status.color_code} />
|
||||
{status.name}
|
||||
</Flex>
|
||||
),
|
||||
})),
|
||||
},
|
||||
groupBy !== IGroupBy.PRIORITY && {
|
||||
key: '3',
|
||||
label: (
|
||||
<Popconfirm
|
||||
title={t('deleteConfirmationTitle')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText={t('deleteConfirmationOk')}
|
||||
cancelText={t('deleteConfirmationCancel')}
|
||||
onConfirm={handleDeleteSection}
|
||||
>
|
||||
<Flex gap={8} align="center" style={{ width: '100%' }}>
|
||||
<DeleteOutlined />
|
||||
{t('delete')}
|
||||
</Flex>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
].filter(Boolean) as MenuProps['items'];
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
padding: '8px',
|
||||
backgroundColor: colorCode,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
onMouseEnter={() => onHoverChange(true)}
|
||||
onMouseLeave={() => onHoverChange(false)}
|
||||
>
|
||||
<Flex
|
||||
gap={8}
|
||||
align="center"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
if ((isProjectManager || isOwnerOrAdmin) && name !== 'Unmapped') setIsEditable(true);
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{
|
||||
minWidth: 26,
|
||||
height: 26,
|
||||
borderRadius: 120,
|
||||
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
||||
}}
|
||||
>
|
||||
{tasksCount}
|
||||
</Flex>
|
||||
|
||||
{isLoading && <LoadingOutlined style={{ color: colors.darkGray }} />}
|
||||
{isEditable ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={name}
|
||||
variant="borderless"
|
||||
style={{
|
||||
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
||||
}}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onPressEnter={handlePressEnter}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip title={isEllipsisActive ? name : null}>
|
||||
<Typography.Text
|
||||
ellipsis={{
|
||||
tooltip: false,
|
||||
onEllipsis: ellipsed => setIsEllipsisActive(ellipsed),
|
||||
}}
|
||||
style={{
|
||||
minWidth: 200,
|
||||
textTransform: 'capitalize',
|
||||
color: themeMode === 'dark' ? '#383838' : '',
|
||||
display: 'inline-block',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<div style={{ display: 'flex' }}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
shape="circle"
|
||||
style={{ color: themeMode === 'dark' ? '#383838' : '' }}
|
||||
onClick={() => setShowNewCard(true)}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
|
||||
{(isOwnerOrAdmin || isProjectManager) && name !== 'Unmapped' && (
|
||||
<Dropdown
|
||||
overlayClassName="todo-threedot-dropdown"
|
||||
trigger={['click']}
|
||||
menu={{ items }}
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Button type="text" size="small" shape="circle">
|
||||
<MoreOutlined
|
||||
style={{
|
||||
rotate: '90deg',
|
||||
fontSize: '25px',
|
||||
color: themeMode === 'dark' ? '#383838' : '',
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardSectionCardHeader;
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Button, Flex } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import BoardSectionCardHeader from './board-section-card-header';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import BoardViewTaskCard from '../board-task-card/board-view-task-card';
|
||||
import BoardViewCreateTaskCard from '../board-task-card/board-view-create-task-card';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
||||
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
interface IBoardSectionCardProps {
|
||||
taskGroup: ITaskListGroup;
|
||||
}
|
||||
|
||||
const BoardSectionCard = ({ taskGroup }: IBoardSectionCardProps) => {
|
||||
const { t } = useTranslation('kanban-board');
|
||||
const scrollContainerRef = useRef<any>(null);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { team_id: teamId, id: reporterId } = useAppSelector(state => state.userReducer);
|
||||
const { socket } = useSocket();
|
||||
|
||||
const [name, setName] = useState<string>(taskGroup.name);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isHover, setIsHover] = useState<boolean>(false);
|
||||
const [showNewCardTop, setShowNewCardTop] = useState<boolean>(false);
|
||||
const [showNewCardBottom, setShowNewCardBottom] = useState<boolean>(false);
|
||||
const [creatingTempTask, setCreatingTempTask] = useState<boolean>(false);
|
||||
|
||||
const { setNodeRef: setDroppableRef } = useDroppable({
|
||||
id: taskGroup.id,
|
||||
data: {
|
||||
type: 'section',
|
||||
section: taskGroup,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef: setSortableRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: taskGroup.id,
|
||||
data: {
|
||||
type: 'section',
|
||||
section: taskGroup,
|
||||
},
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
const setRefs = (el: HTMLElement | null) => {
|
||||
setSortableRef(el);
|
||||
setDroppableRef(el);
|
||||
};
|
||||
|
||||
const getInstantTask = async ({
|
||||
task_id,
|
||||
group_id,
|
||||
task,
|
||||
}: {
|
||||
task_id: string;
|
||||
group_id: string;
|
||||
task: IProjectTask;
|
||||
}) => {
|
||||
try {
|
||||
} catch (error) {
|
||||
logger.error('Error creating instant task', error);
|
||||
}
|
||||
};
|
||||
|
||||
const createTempTask = async () => {
|
||||
if (creatingTempTask || !projectId) return;
|
||||
setCreatingTempTask(true);
|
||||
|
||||
const body: ITaskCreateRequest = {
|
||||
name: DEFAULT_TASK_NAME,
|
||||
project_id: projectId,
|
||||
team_id: teamId,
|
||||
reporter_id: reporterId,
|
||||
status_id: taskGroup.id,
|
||||
};
|
||||
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
};
|
||||
|
||||
const handleAddTaskToBottom = () => {
|
||||
// createTempTask();
|
||||
setShowNewCardBottom(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showNewCardBottom && scrollContainerRef.current) {
|
||||
const timeout = setTimeout(() => {
|
||||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [taskGroup.tasks, showNewCardBottom]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
vertical
|
||||
gap={16}
|
||||
ref={setRefs}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{
|
||||
...style,
|
||||
minWidth: 375,
|
||||
outline: isHover
|
||||
? `1px solid ${themeWiseColor('#edeae9', '#ffffff12', themeMode)}`
|
||||
: 'none',
|
||||
padding: 8,
|
||||
borderRadius: 12,
|
||||
}}
|
||||
className="h-[600px] max-h-[600px] overflow-y-scroll board-section"
|
||||
data-section-id={taskGroup.id}
|
||||
data-droppable="true"
|
||||
data-over="false"
|
||||
>
|
||||
<BoardSectionCardHeader
|
||||
groupId={taskGroup.id}
|
||||
key={taskGroup.id}
|
||||
categoryId={taskGroup.category_id ?? null}
|
||||
name={name}
|
||||
tasksCount={taskGroup?.tasks.length}
|
||||
isLoading={isLoading}
|
||||
setName={setName}
|
||||
colorCode={themeWiseColor(taskGroup?.color_code, taskGroup?.color_code_dark, themeMode)}
|
||||
onHoverChange={setIsHover}
|
||||
setShowNewCard={setShowNewCardTop}
|
||||
/>
|
||||
|
||||
<Flex
|
||||
vertical
|
||||
gap={16}
|
||||
ref={scrollContainerRef}
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
height: taskGroup?.tasks.length <= 0 ? 600 : 'auto',
|
||||
maxHeight: taskGroup?.tasks.length <= 0 ? 600 : 'auto',
|
||||
overflowY: 'scroll',
|
||||
padding: taskGroup?.tasks.length <= 0 ? 8 : 6,
|
||||
background:
|
||||
taskGroup?.tasks.length <= 0 && !showNewCardTop && !showNewCardBottom
|
||||
? themeWiseColor(
|
||||
'linear-gradient( 180deg, #fafafa, rgba(245, 243, 243, 0))',
|
||||
'linear-gradient( 180deg, #2a2b2d, rgba(42, 43, 45, 0))',
|
||||
themeMode
|
||||
)
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<SortableContext
|
||||
items={taskGroup.tasks.map(task => task.id ?? '')}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<Flex vertical gap={16} align="center">
|
||||
{showNewCardTop && (
|
||||
<BoardViewCreateTaskCard
|
||||
position="top"
|
||||
sectionId={taskGroup.id}
|
||||
setShowNewCard={setShowNewCardTop}
|
||||
/>
|
||||
)}
|
||||
|
||||
{taskGroup.tasks.map((task: any) => (
|
||||
<BoardViewTaskCard key={task.id} sectionId={taskGroup.id} task={task} />
|
||||
))}
|
||||
|
||||
{showNewCardBottom && (
|
||||
<BoardViewCreateTaskCard
|
||||
position="bottom"
|
||||
sectionId={taskGroup.id}
|
||||
setShowNewCard={setShowNewCardBottom}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</SortableContext>
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
style={{
|
||||
height: '38px',
|
||||
width: '100%',
|
||||
borderRadius: 6,
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddTaskToBottom}
|
||||
>
|
||||
{t('addTask')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardSectionCard;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Flex } from 'antd';
|
||||
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import BoardSectionCard from './board-section-card/board-section-card';
|
||||
import BoardCreateSectionCard from './board-section-card/board-create-section-card';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { useEffect } from 'react';
|
||||
import { setTaskAssignee, setTaskEndDate } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { fetchTaskAssignees } from '@/features/taskAttributes/taskMemberSlice';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { updateTaskAssignees, updateTaskEndDate } from '@/features/board/board-slice';
|
||||
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
||||
|
||||
const BoardSectionCardContainer = ({
|
||||
datasource,
|
||||
group,
|
||||
}: {
|
||||
datasource: ITaskListGroup[];
|
||||
group: 'status' | 'priority' | 'phases' | 'members';
|
||||
}) => {
|
||||
const { socket } = useSocket();
|
||||
const dispatch = useAppDispatch();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const { taskGroups } = useAppSelector(state => state.boardReducer);
|
||||
const { loadingAssignees } = useAppSelector(state => state.taskReducer);
|
||||
const isOwnerorAdmin = useAuthService().isOwnerOrAdmin();
|
||||
const isProjectManager = useIsProjectManager();
|
||||
|
||||
// Socket handler for assignee updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => {
|
||||
if (!data) return;
|
||||
|
||||
const updatedAssignees = data.assignees.map(assignee => ({
|
||||
...assignee,
|
||||
selected: true,
|
||||
}));
|
||||
|
||||
// Find the group that contains the task or its subtasks
|
||||
const groupId = taskGroups.find(group =>
|
||||
group.tasks.some(
|
||||
task =>
|
||||
task.id === data.id ||
|
||||
(task.sub_tasks && task.sub_tasks.some(subtask => subtask.id === data.id))
|
||||
)
|
||||
)?.id;
|
||||
|
||||
if (groupId) {
|
||||
dispatch(
|
||||
updateTaskAssignees({
|
||||
groupId,
|
||||
taskId: data.id,
|
||||
assignees: updatedAssignees,
|
||||
names: data.names,
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(setTaskAssignee(data));
|
||||
|
||||
if (currentSession?.team_id && !loadingAssignees) {
|
||||
dispatch(fetchTaskAssignees(currentSession.team_id));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
|
||||
return () => {
|
||||
socket.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
|
||||
};
|
||||
}, [socket, currentSession?.team_id, loadingAssignees, taskGroups, dispatch]);
|
||||
|
||||
// Socket handler for due date updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleEndDateChange = (task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
end_date: string;
|
||||
}) => {
|
||||
dispatch(updateTaskEndDate({ task }));
|
||||
dispatch(setTaskEndDate(task));
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap={16}
|
||||
align="flex-start"
|
||||
className="max-w-screen max-h-[620px] min-h-[620px] overflow-x-scroll p-[1px]"
|
||||
>
|
||||
<SortableContext
|
||||
items={datasource?.map((section: any) => section.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{datasource?.map((data: any) => <BoardSectionCard key={data.id} taskGroup={data} />)}
|
||||
</SortableContext>
|
||||
|
||||
{(group !== 'priority' && (isOwnerorAdmin || isProjectManager)) && <BoardCreateSectionCard />}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardSectionCardContainer;
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Flex, Input, InputRef } from 'antd';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
addSubtask,
|
||||
GROUP_BY_PHASE_VALUE,
|
||||
GROUP_BY_PRIORITY_VALUE,
|
||||
GROUP_BY_STATUS_VALUE,
|
||||
updateSubtask,
|
||||
updateTaskProgress,
|
||||
} from '@features/board/board-slice';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { getCurrentGroup } from '@/features/tasks/tasks.slice';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
type BoardCreateSubtaskCardProps = {
|
||||
sectionId: string;
|
||||
parentTaskId: string;
|
||||
setShowNewSubtaskCard: (x: boolean) => void;
|
||||
};
|
||||
|
||||
const BoardCreateSubtaskCard = ({
|
||||
sectionId,
|
||||
parentTaskId,
|
||||
setShowNewSubtaskCard,
|
||||
}: BoardCreateSubtaskCardProps) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [creatingTask, setCreatingTask] = useState<boolean>(false);
|
||||
const [newSubtaskName, setNewSubtaskName] = useState<string>('');
|
||||
const [isEnterKeyPressed, setIsEnterKeyPressed] = useState<boolean>(false);
|
||||
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const { t } = useTranslation('kanban-board');
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { projectId } = useParams();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
const createRequestBody = (): ITaskCreateRequest | null => {
|
||||
if (!projectId || !currentSession) return null;
|
||||
const body: ITaskCreateRequest = {
|
||||
project_id: projectId,
|
||||
name: newSubtaskName,
|
||||
reporter_id: currentSession.id,
|
||||
team_id: currentSession.team_id,
|
||||
};
|
||||
|
||||
const groupBy = getCurrentGroup();
|
||||
if (groupBy.value === GROUP_BY_STATUS_VALUE) {
|
||||
body.status_id = sectionId || undefined;
|
||||
} else if (groupBy.value === GROUP_BY_PRIORITY_VALUE) {
|
||||
body.priority_id = sectionId || undefined;
|
||||
} else if (groupBy.value === GROUP_BY_PHASE_VALUE) {
|
||||
body.phase_id = sectionId || undefined;
|
||||
}
|
||||
|
||||
if (parentTaskId) {
|
||||
body.parent_task_id = parentTaskId;
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
const handleAddSubtask = () => {
|
||||
if (creatingTask || !projectId || !currentSession || newSubtaskName.trim() === '' || !connected)
|
||||
return;
|
||||
|
||||
try {
|
||||
setCreatingTask(true);
|
||||
const body = createRequestBody();
|
||||
if (!body) return;
|
||||
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => {
|
||||
if (!task) return;
|
||||
|
||||
dispatch(updateSubtask({ sectionId, subtask: task, mode: 'add' }));
|
||||
setCreatingTask(false);
|
||||
// Clear the input field after successful task creation
|
||||
setNewSubtaskName('');
|
||||
// Focus back to the input field for adding another subtask
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
if (task.parent_task_id) {
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
|
||||
socket?.once(SocketEvents.GET_TASK_PROGRESS.toString(), (data: {
|
||||
id: string;
|
||||
complete_ratio: number;
|
||||
completed_count: number;
|
||||
total_tasks_count: number;
|
||||
parent_task: string;
|
||||
}) => {
|
||||
if (!data.parent_task) data.parent_task = task.parent_task_id || '';
|
||||
dispatch(updateTaskProgress(data));
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error adding task:', error);
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
setIsEnterKeyPressed(true);
|
||||
handleAddSubtask();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
if (!isEnterKeyPressed && newSubtaskName.length > 0) {
|
||||
handleAddSubtask();
|
||||
}
|
||||
setIsEnterKeyPressed(false);
|
||||
};
|
||||
|
||||
const handleCancelNewCard = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (cardRef.current && !cardRef.current.contains(e.relatedTarget)) {
|
||||
setNewSubtaskName('');
|
||||
setShowNewSubtaskCard(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={cardRef}
|
||||
vertical
|
||||
gap={12}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: 12,
|
||||
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline`}
|
||||
onBlur={handleCancelNewCard}
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
value={newSubtaskName}
|
||||
onChange={e => setNewSubtaskName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder={t('newSubtaskNamePlaceholder')}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardCreateSubtaskCard;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { Col, Flex, Typography, List } from 'antd';
|
||||
import CustomAvatarGroup from '@/components/board/custom-avatar-group';
|
||||
import CustomDueDatePicker from '@/components/board/custom-due-date-picker';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
||||
|
||||
interface IBoardSubTaskCardProps {
|
||||
subtask: IProjectTask;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
const BoardSubTaskCard = ({ subtask, sectionId }: IBoardSubTaskCardProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [subtaskDueDate, setSubtaskDueDate] = useState<Dayjs | null>(
|
||||
subtask?.end_date ? dayjs(subtask?.end_date) : null
|
||||
);
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent, id: string) => {
|
||||
// Prevent the event from propagating to parent elements
|
||||
e.stopPropagation();
|
||||
|
||||
// Add a small delay to ensure it's a click and not the start of a drag
|
||||
const clickTimeout = setTimeout(() => {
|
||||
dispatch(setSelectedTaskId(id));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(clickTimeout);
|
||||
};
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
key={subtask.id}
|
||||
className="group"
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
onClick={e => handleCardClick(e, subtask.id || '')}
|
||||
>
|
||||
<Col span={10}>
|
||||
<Typography.Text
|
||||
style={{ fontWeight: 500, fontSize: 14 }}
|
||||
delete={subtask.status === 'done'}
|
||||
ellipsis={{ expanded: false }}
|
||||
>
|
||||
{subtask.name}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
|
||||
<Flex gap={8} justify="end" style={{ width: '100%' }}>
|
||||
<CustomAvatarGroup task={subtask} sectionId={sectionId} />
|
||||
|
||||
<CustomDueDatePicker task={subtask} onDateChange={setSubtaskDueDate} />
|
||||
</Flex>
|
||||
</List.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardSubTaskCard;
|
||||
@@ -0,0 +1,248 @@
|
||||
import { Button, Flex, Input, InputRef } from 'antd';
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Dayjs } from 'dayjs';
|
||||
import { nanoid } from '@reduxjs/toolkit';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
addTaskCardToTheBottom,
|
||||
addTaskCardToTheTop,
|
||||
getCurrentGroupBoard,
|
||||
GROUP_BY_STATUS_VALUE,
|
||||
GROUP_BY_PRIORITY_VALUE,
|
||||
GROUP_BY_PHASE_VALUE,
|
||||
} from '@features/board/board-slice';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import CustomDueDatePicker from '@/components/board/custom-due-date-picker';
|
||||
import AddMembersDropdown from '@/components/add-members-dropdown-v2/add-members-dropdown';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
|
||||
|
||||
type BoardViewCreateTaskCardProps = {
|
||||
position: 'top' | 'bottom';
|
||||
sectionId: string;
|
||||
setShowNewCard: (x: boolean) => void;
|
||||
};
|
||||
|
||||
const BoardViewCreateTaskCard = ({
|
||||
position,
|
||||
sectionId,
|
||||
setShowNewCard,
|
||||
}: BoardViewCreateTaskCardProps) => {
|
||||
const { t } = useTranslation('kanban-board');
|
||||
const dispatch = useAppDispatch();
|
||||
const { socket } = useSocket();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
const [newTaskName, setNewTaskName] = useState<string>('');
|
||||
const [dueDate, setDueDate] = useState<Dayjs | null>(null);
|
||||
const [creatingTask, setCreatingTask] = useState<boolean>(false);
|
||||
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const focusInput = () => {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Focus when component mounts or when showNewCard becomes true
|
||||
useEffect(() => {
|
||||
focusInput();
|
||||
}, []);
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
|
||||
const createRequestBody = (): ITaskCreateRequest | null => {
|
||||
if (!projectId || !currentSession) return null;
|
||||
|
||||
const body: ITaskCreateRequest = {
|
||||
project_id: projectId,
|
||||
name: newTaskName.trim(),
|
||||
reporter_id: currentSession.id,
|
||||
team_id: currentSession.team_id,
|
||||
};
|
||||
|
||||
// Set end date if provided
|
||||
if (dueDate) {
|
||||
body.end_date = dueDate.toISOString();
|
||||
}
|
||||
|
||||
// Set the appropriate group ID based on the current grouping
|
||||
const groupBy = getCurrentGroupBoard();
|
||||
if (groupBy.value === GROUP_BY_STATUS_VALUE) {
|
||||
body.status_id = sectionId;
|
||||
} else if (groupBy.value === GROUP_BY_PRIORITY_VALUE) {
|
||||
body.priority_id = sectionId;
|
||||
} else if (groupBy.value === GROUP_BY_PHASE_VALUE) {
|
||||
body.phase_id = sectionId;
|
||||
}
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setNewTaskName('');
|
||||
setDueDate(null);
|
||||
setCreatingTask(false);
|
||||
setShowNewCard(true);
|
||||
focusInput();
|
||||
};
|
||||
|
||||
const handleAddTaskToTheTop = async () => {
|
||||
if (creatingTask || !projectId || !currentSession || newTaskName.trim() === '') return;
|
||||
|
||||
try {
|
||||
setCreatingTask(true);
|
||||
const body = createRequestBody();
|
||||
if (!body) return;
|
||||
|
||||
// Create a unique event handler for this specific task creation
|
||||
const eventHandler = (task: IProjectTask) => {
|
||||
// Set creating task to false
|
||||
setCreatingTask(false);
|
||||
|
||||
// Add the task to the state at the top of the section
|
||||
dispatch(
|
||||
addTaskCardToTheTop({
|
||||
sectionId: sectionId,
|
||||
task: {
|
||||
...task,
|
||||
id: task.id || nanoid(),
|
||||
name: task.name || newTaskName.trim(),
|
||||
end_date: task.end_date || dueDate,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Remove the event listener to prevent memory leaks
|
||||
socket?.off(SocketEvents.QUICK_TASK.toString(), eventHandler);
|
||||
|
||||
// Reset the form
|
||||
resetForm();
|
||||
};
|
||||
|
||||
// Register the event handler before emitting
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), eventHandler);
|
||||
|
||||
// Emit the event
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
} catch (error) {
|
||||
console.error('Error adding task:', error);
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTaskToTheBottom = async () => {
|
||||
if (creatingTask || !projectId || !currentSession || newTaskName.trim() === '') return;
|
||||
|
||||
try {
|
||||
setCreatingTask(true);
|
||||
const body = createRequestBody();
|
||||
if (!body) return;
|
||||
|
||||
// Create a unique event handler for this specific task creation
|
||||
const eventHandler = (task: IProjectTask) => {
|
||||
// Set creating task to false
|
||||
setCreatingTask(false);
|
||||
|
||||
// Add the task to the state at the bottom of the section
|
||||
dispatch(
|
||||
addTaskCardToTheBottom({
|
||||
sectionId: sectionId,
|
||||
task: {
|
||||
...task,
|
||||
id: task.id || nanoid(),
|
||||
name: task.name || newTaskName.trim(),
|
||||
end_date: task.end_date || dueDate,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Remove the event listener to prevent memory leaks
|
||||
socket?.off(SocketEvents.QUICK_TASK.toString(), eventHandler);
|
||||
|
||||
// Reset the form
|
||||
resetForm();
|
||||
};
|
||||
|
||||
// Register the event handler before emitting
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), eventHandler);
|
||||
|
||||
// Emit the event
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
} catch (error) {
|
||||
console.error('Error adding task:', error);
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelNewCard = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (cardRef.current && !cardRef.current.contains(e.relatedTarget)) {
|
||||
// Only reset the form without creating a task
|
||||
setNewTaskName('');
|
||||
setShowNewCard(false);
|
||||
setDueDate(null);
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={cardRef}
|
||||
vertical
|
||||
gap={12}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: 12,
|
||||
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline`}
|
||||
onBlur={handleCancelNewCard}
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={newTaskName}
|
||||
onChange={e => setNewTaskName(e.target.value)}
|
||||
onPressEnter={position === 'bottom' ? handleAddTaskToTheBottom : handleAddTaskToTheTop}
|
||||
placeholder={t('newTaskNamePlaceholder')}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
}}
|
||||
disabled={creatingTask}
|
||||
/>
|
||||
{newTaskName.trim() && (
|
||||
<Flex gap={8} justify="flex-end">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setShowNewCard(false)}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={position === 'bottom' ? handleAddTaskToTheBottom : handleAddTaskToTheTop}
|
||||
loading={creatingTask}
|
||||
>
|
||||
{t('addTask')}
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardViewCreateTaskCard;
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Tooltip,
|
||||
Tag,
|
||||
Progress,
|
||||
Typography,
|
||||
Dropdown,
|
||||
MenuProps,
|
||||
Button,
|
||||
Flex,
|
||||
List,
|
||||
Divider,
|
||||
Popconfirm,
|
||||
Skeleton,
|
||||
} from 'antd';
|
||||
import {
|
||||
DoubleRightOutlined,
|
||||
PauseOutlined,
|
||||
UserAddOutlined,
|
||||
InboxOutlined,
|
||||
DeleteOutlined,
|
||||
MinusOutlined,
|
||||
ForkOutlined,
|
||||
CaretRightFilled,
|
||||
CaretDownFilled,
|
||||
ExclamationCircleFilled,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import BoardSubTaskCard from '../board-sub-task-card/board-sub-task-card';
|
||||
import CustomAvatarGroup from '@/components/board/custom-avatar-group';
|
||||
import CustomDueDatePicker from '@/components/board/custom-due-date-picker';
|
||||
import { colors } from '@/styles/colors';
|
||||
import {
|
||||
deleteBoardTask,
|
||||
fetchBoardSubTasks,
|
||||
updateBoardTaskAssignee,
|
||||
} from '@features/board/board-slice';
|
||||
import BoardCreateSubtaskCard from '../board-sub-task-card/board-create-sub-task-card';
|
||||
import { setShowTaskDrawer, setSelectedTaskId } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { IBulkAssignRequest } from '@/types/tasks/bulk-action-bar.types';
|
||||
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
|
||||
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 logger from '@/utils/errorLogger';
|
||||
|
||||
interface IBoardViewTaskCardProps {
|
||||
task: IProjectTask;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('kanban-board');
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
const [isSubTaskShow, setIsSubTaskShow] = useState(false);
|
||||
const [showNewSubtaskCard, setShowNewSubtaskCard] = useState(false);
|
||||
const [dueDate, setDueDate] = useState<Dayjs | null>(
|
||||
task?.end_date ? dayjs(task?.end_date) : null
|
||||
);
|
||||
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: task.id || '',
|
||||
data: {
|
||||
type: 'task',
|
||||
task,
|
||||
sectionId,
|
||||
},
|
||||
});
|
||||
|
||||
const style = useMemo(() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}), [transform, transition, isDragging]);
|
||||
|
||||
const handleCardClick = useCallback((e: React.MouseEvent, id: string) => {
|
||||
// Prevent the event from propagating to parent elements
|
||||
e.stopPropagation();
|
||||
|
||||
// Don't handle click if we're dragging
|
||||
if (isDragging) return;
|
||||
|
||||
// Add a small delay to ensure it's a click and not the start of a drag
|
||||
const clickTimeout = setTimeout(() => {
|
||||
dispatch(setSelectedTaskId(id));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(clickTimeout);
|
||||
}, [dispatch, isDragging]);
|
||||
|
||||
const handleAssignToMe = useCallback(async () => {
|
||||
if (!projectId || !task.id || updatingAssignToMe) return;
|
||||
|
||||
try {
|
||||
setUpdatingAssignToMe(true);
|
||||
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);
|
||||
dispatch(
|
||||
updateBoardTaskAssignee({
|
||||
body: res.body,
|
||||
sectionId,
|
||||
taskId: task.id,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error assigning task to me:', error);
|
||||
} finally {
|
||||
setUpdatingAssignToMe(false);
|
||||
}
|
||||
}, [projectId, task.id, updatingAssignToMe, dispatch, trackMixpanelEvent, sectionId]);
|
||||
|
||||
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(deleteBoardTask({ sectionId, taskId: task.id }));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error archiving task:', error);
|
||||
}
|
||||
}, [projectId, task.id, dispatch, trackMixpanelEvent, sectionId]);
|
||||
|
||||
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(deleteBoardTask({ sectionId, taskId: task.id }));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting task:', error);
|
||||
}
|
||||
}, [projectId, task.id, dispatch, trackMixpanelEvent, sectionId]);
|
||||
|
||||
const handleSubTaskExpand = useCallback(() => {
|
||||
if (task && task.id && projectId) {
|
||||
if (task.show_sub_tasks) {
|
||||
// If subtasks are already loaded, just toggle visibility
|
||||
setIsSubTaskShow(prev => !prev);
|
||||
} else {
|
||||
// If subtasks need to be fetched, show the section first with loading state
|
||||
setIsSubTaskShow(true);
|
||||
dispatch(fetchBoardSubTasks({ taskId: task.id, projectId }));
|
||||
}
|
||||
}
|
||||
}, [task, projectId, dispatch]);
|
||||
|
||||
const handleAddSubtaskClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowNewSubtaskCard(true);
|
||||
}, []);
|
||||
|
||||
const handleSubtaskButtonClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
handleSubTaskExpand();
|
||||
}, [handleSubTaskExpand]);
|
||||
|
||||
const items: MenuProps['items'] = useMemo(() => [
|
||||
{
|
||||
label: (
|
||||
<span>
|
||||
<UserAddOutlined />
|
||||
|
||||
<Typography.Text>{t('assignToMe')}</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
key: '1',
|
||||
onClick: handleAssignToMe,
|
||||
disabled: updatingAssignToMe,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span>
|
||||
<InboxOutlined />
|
||||
|
||||
<Typography.Text>{t('archive')}</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
key: '2',
|
||||
onClick: handleArchive,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Popconfirm
|
||||
title={t('deleteConfirmationTitle')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText={t('deleteConfirmationOk')}
|
||||
cancelText={t('deleteConfirmationCancel')}
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
|
||||
{t('delete')}
|
||||
</Popconfirm>
|
||||
),
|
||||
key: '3',
|
||||
},
|
||||
], [t, handleAssignToMe, handleArchive, handleDelete, updatingAssignToMe]);
|
||||
|
||||
const priorityIcon = useMemo(() => {
|
||||
if (task.priority_value === 0) {
|
||||
return (
|
||||
<MinusOutlined
|
||||
style={{
|
||||
color: '#52c41a',
|
||||
marginRight: '0.25rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (task.priority_value === 1) {
|
||||
return (
|
||||
<PauseOutlined
|
||||
style={{
|
||||
color: '#faad14',
|
||||
transform: 'rotate(90deg)',
|
||||
marginRight: '0.25rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<DoubleRightOutlined
|
||||
style={{
|
||||
color: '#f5222d',
|
||||
transform: 'rotate(-90deg)',
|
||||
marginRight: '0.25rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [task.priority_value]);
|
||||
|
||||
const renderLabels = useMemo(() => {
|
||||
if (!task?.labels?.length) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{task.labels.slice(0, 2).map((label: any) => (
|
||||
<Tag key={label.id} style={{ marginRight: '4px' }} color={label?.color_code}>
|
||||
<span style={{ color: themeMode === 'dark' ? '#383838' : '' }}>
|
||||
{label.name}
|
||||
</span>
|
||||
</Tag>
|
||||
))}
|
||||
{task.labels.length > 2 && <Tag>+ {task.labels.length - 2}</Tag>}
|
||||
</>
|
||||
);
|
||||
}, [task.labels, themeMode]);
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items }} trigger={['contextMenu']}>
|
||||
<Flex
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
vertical
|
||||
gap={12}
|
||||
style={{
|
||||
...style,
|
||||
width: '100%',
|
||||
padding: 12,
|
||||
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
|
||||
borderRadius: 6,
|
||||
cursor: 'grab',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
className={`group outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline board-task-card`}
|
||||
onClick={e => handleCardClick(e, task.id || '')}
|
||||
data-id={task.id}
|
||||
data-dragging={isDragging ? "true" : "false"}
|
||||
>
|
||||
{/* Labels and Progress */}
|
||||
<Flex align="center" justify="space-between">
|
||||
<Flex>
|
||||
{renderLabels}
|
||||
</Flex>
|
||||
|
||||
<Tooltip title={` ${task?.completed_count} / ${task?.total_tasks_count}`}>
|
||||
<Progress type="circle" percent={task?.complete_ratio } size={26} strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7} />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
{/* Action Icons */}
|
||||
<Flex gap={4}>
|
||||
{priorityIcon}
|
||||
<Typography.Text
|
||||
style={{ fontWeight: 500 }}
|
||||
ellipsis={{ tooltip: task.name }}
|
||||
>
|
||||
{task.name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
|
||||
<Flex vertical gap={8}>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
marginBlock: 8,
|
||||
}}
|
||||
>
|
||||
{task && <CustomAvatarGroup task={task} sectionId={sectionId} />}
|
||||
|
||||
<Flex gap={4} align="center">
|
||||
<CustomDueDatePicker task={task} onDateChange={setDueDate} />
|
||||
|
||||
{/* Subtask Section */}
|
||||
<Button
|
||||
onClick={handleSubtaskButtonClick}
|
||||
size="small"
|
||||
style={{
|
||||
padding: 0,
|
||||
}}
|
||||
type="text"
|
||||
>
|
||||
<Tag
|
||||
bordered={false}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
margin: 0,
|
||||
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
||||
}}
|
||||
>
|
||||
<ForkOutlined rotate={90} />
|
||||
<span>{task.sub_tasks_count}</span>
|
||||
{isSubTaskShow ? <CaretDownFilled /> : <CaretRightFilled />}
|
||||
</Tag>
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{isSubTaskShow && (
|
||||
<Flex vertical>
|
||||
<Divider style={{ marginBlock: 0 }} />
|
||||
<List>
|
||||
{task.sub_tasks_loading && (
|
||||
<List.Item>
|
||||
<Skeleton active paragraph={{ rows: 2 }} title={false} style={{ marginTop: 8 }} />
|
||||
</List.Item>
|
||||
)}
|
||||
|
||||
{!task.sub_tasks_loading && task?.sub_tasks &&
|
||||
task?.sub_tasks.map((subtask: any) => (
|
||||
<BoardSubTaskCard key={subtask.id} subtask={subtask} sectionId={sectionId} />
|
||||
))}
|
||||
|
||||
{showNewSubtaskCard && (
|
||||
<BoardCreateSubtaskCard
|
||||
sectionId={sectionId}
|
||||
parentTaskId={task.id || ''}
|
||||
setShowNewSubtaskCard={setShowNewSubtaskCard}
|
||||
/>
|
||||
)}
|
||||
</List>
|
||||
<Button
|
||||
type="text"
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 6,
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddSubtaskClick}
|
||||
>
|
||||
{t('addSubtask', 'Add Subtask')}
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardViewTaskCard;
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import TaskListFilters from '../taskList/task-list-filters/task-list-filters';
|
||||
import { Flex, Skeleton } from 'antd';
|
||||
import BoardSectionCardContainer from './board-section/board-section-container';
|
||||
import {
|
||||
fetchBoardTaskGroups,
|
||||
reorderTaskGroups,
|
||||
moveTaskBetweenGroups,
|
||||
IGroupBy,
|
||||
updateTaskProgress,
|
||||
} from '@features/board/board-slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
closestCorners,
|
||||
DragOverlay,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import BoardViewTaskCard from './board-section/board-task-card/board-view-task-card';
|
||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import alertService from '@/services/alerts/alertService';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { evt_project_board_visit, evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics-events';
|
||||
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { tasksApiService } from '@/api/tasks/tasks.api.service';
|
||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||
|
||||
const ProjectViewBoard = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { projectView } = useTabSearchParam();
|
||||
const { socket } = useSocket();
|
||||
const authService = useAuthService();
|
||||
const currentSession = authService.getCurrentSession();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const [ currentTaskIndex, setCurrentTaskIndex] = useState(-1);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer);
|
||||
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
|
||||
state => state.taskStatusReducer
|
||||
);
|
||||
const [activeItem, setActiveItem] = useState<any>(null);
|
||||
|
||||
// Store the original source group ID when drag starts
|
||||
const originalSourceGroupIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId && groupBy && projectView === 'kanban') {
|
||||
if (!loadingGroups) {
|
||||
dispatch(fetchBoardTaskGroups(projectId));
|
||||
}
|
||||
}
|
||||
}, [dispatch, projectId, groupBy, projectView, search, archived]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
// Require the mouse to move by 10 pixels before activating
|
||||
activationConstraint: {
|
||||
distance: 10,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
// Press delay of 250ms, with tolerance of 5px of movement
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const handleTaskProgress = (data: {
|
||||
id: string;
|
||||
status: string;
|
||||
complete_ratio: number;
|
||||
completed_count: number;
|
||||
total_tasks_count: number;
|
||||
parent_task: string;
|
||||
}) => {
|
||||
dispatch(updateTaskProgress(data));
|
||||
};
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
setActiveItem(active.data.current);
|
||||
setCurrentTaskIndex(active.data.current?.sortable.index);
|
||||
// Store the original source group ID when drag starts
|
||||
if (active.data.current?.type === 'task') {
|
||||
originalSourceGroupIdRef.current = active.data.current.sectionId;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id;
|
||||
const overId = over.id;
|
||||
|
||||
if (activeId === overId) return;
|
||||
|
||||
const isActiveTask = active.data.current?.type === 'task';
|
||||
const isOverTask = over.data.current?.type === 'task';
|
||||
const isOverSection = over.data.current?.type === 'section';
|
||||
|
||||
// Handle task movement between sections
|
||||
if (isActiveTask && (isOverTask || isOverSection)) {
|
||||
// If we're over a task, we want to insert at that position
|
||||
// If we're over a section, we want to append to the end
|
||||
const activeTaskId = active.data.current?.task.id;
|
||||
|
||||
// Use the original source group ID from ref instead of the potentially modified one
|
||||
const sourceGroupId = originalSourceGroupIdRef.current || active.data.current?.sectionId;
|
||||
|
||||
// Fix: Ensure we correctly identify the target group ID
|
||||
let targetGroupId;
|
||||
if (isOverTask) {
|
||||
// If over a task, get its section ID
|
||||
targetGroupId = over.data.current?.sectionId;
|
||||
} else if (isOverSection) {
|
||||
// If over a section directly
|
||||
targetGroupId = over.id;
|
||||
} else {
|
||||
// Fallback
|
||||
targetGroupId = over.id;
|
||||
}
|
||||
|
||||
// Find the target index
|
||||
let targetIndex = -1;
|
||||
if (isOverTask) {
|
||||
const overTaskId = over.data.current?.task.id;
|
||||
const targetGroup = taskGroups.find(group => group.id === targetGroupId);
|
||||
if (targetGroup) {
|
||||
targetIndex = targetGroup.tasks.findIndex(task => task.id === overTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch the action to move the task
|
||||
dispatch(
|
||||
moveTaskBetweenGroups({
|
||||
taskId: activeTaskId,
|
||||
sourceGroupId,
|
||||
targetGroupId,
|
||||
targetIndex,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || !projectId) {
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||
return;
|
||||
}
|
||||
|
||||
const isActiveTask = active.data.current?.type === 'task';
|
||||
const isActiveSection = active.data.current?.type === 'section';
|
||||
|
||||
// Handle task dragging between columns
|
||||
if (isActiveTask) {
|
||||
const task = active.data.current?.task;
|
||||
|
||||
// Use the original source group ID from ref instead of the potentially modified one
|
||||
const sourceGroupId = originalSourceGroupIdRef.current || active.data.current?.sectionId;
|
||||
|
||||
// Fix: Ensure we correctly identify the target group ID
|
||||
let targetGroupId;
|
||||
if (over.data.current?.type === 'task') {
|
||||
// If dropping on a task, get its section ID
|
||||
targetGroupId = over.data.current?.sectionId;
|
||||
} else if (over.data.current?.type === 'section') {
|
||||
// If dropping directly on a section
|
||||
targetGroupId = over.id;
|
||||
} else {
|
||||
// Fallback to the over ID if type is not specified
|
||||
targetGroupId = over.id;
|
||||
}
|
||||
|
||||
// Find source and target groups
|
||||
const sourceGroup = taskGroups.find(group => group.id === sourceGroupId);
|
||||
const targetGroup = taskGroups.find(group => group.id === targetGroupId);
|
||||
|
||||
if (!sourceGroup || !targetGroup || !task) {
|
||||
logger.error('Could not find source or target group, or task is undefined');
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||
return;
|
||||
}
|
||||
if (targetGroupId !== sourceGroupId) {
|
||||
const canContinue = await checkTaskDependencyStatus(task.id, targetGroupId);
|
||||
if (!canContinue) {
|
||||
alertService.error(
|
||||
'Task is not completed',
|
||||
'Please complete the task dependencies before proceeding'
|
||||
);
|
||||
dispatch(
|
||||
moveTaskBetweenGroups({
|
||||
taskId: task.id,
|
||||
sourceGroupId: targetGroupId, // Current group (where it was moved optimistically)
|
||||
targetGroupId: sourceGroupId, // Move it back to the original source group
|
||||
targetIndex: currentTaskIndex !== -1 ? currentTaskIndex : 0, // Original position or append to end
|
||||
})
|
||||
);
|
||||
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Find indices
|
||||
let fromIndex = sourceGroup.tasks.findIndex(t => t.id === task.id);
|
||||
|
||||
// Handle case where task is not found in source group (might have been moved already in UI)
|
||||
if (fromIndex === -1) {
|
||||
logger.info('Task not found in source group. Using task sort_order from task object.');
|
||||
|
||||
// Use the sort_order from the task object itself
|
||||
const fromSortOrder = task.sort_order;
|
||||
|
||||
// Calculate target index and position
|
||||
let toIndex = -1;
|
||||
if (over.data.current?.type === 'task') {
|
||||
const overTaskId = over.data.current?.task.id;
|
||||
toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
|
||||
} else {
|
||||
// If dropping on a section, append to the end
|
||||
toIndex = targetGroup.tasks.length;
|
||||
}
|
||||
|
||||
// Calculate toPos similar to Angular implementation
|
||||
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
|
||||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
||||
-1;
|
||||
|
||||
// Prepare socket event payload
|
||||
const body = {
|
||||
project_id: projectId,
|
||||
from_index: fromSortOrder,
|
||||
to_index: toPos,
|
||||
to_last_index: !toPos,
|
||||
from_group: sourceGroupId,
|
||||
to_group: targetGroupId,
|
||||
group_by: groupBy || 'status',
|
||||
task,
|
||||
team_id: currentSession?.team_id
|
||||
};
|
||||
|
||||
logger.error('Emitting socket event with payload (task not found in source):', body);
|
||||
|
||||
// Emit socket event
|
||||
if (socket) {
|
||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
|
||||
|
||||
// Set up listener for task progress update
|
||||
socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => {
|
||||
if (task.is_sub_task) {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
|
||||
} else {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track analytics event
|
||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate target index and position
|
||||
let toIndex = -1;
|
||||
if (over.data.current?.type === 'task') {
|
||||
const overTaskId = over.data.current?.task.id;
|
||||
toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
|
||||
} else {
|
||||
// If dropping on a section, append to the end
|
||||
toIndex = targetGroup.tasks.length;
|
||||
}
|
||||
|
||||
// Calculate toPos similar to Angular implementation
|
||||
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
|
||||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
||||
-1;
|
||||
|
||||
// Prepare socket event payload
|
||||
const body = {
|
||||
project_id: projectId,
|
||||
from_index: sourceGroup.tasks[fromIndex].sort_order,
|
||||
to_index: toPos,
|
||||
to_last_index: !toPos,
|
||||
from_group: sourceGroupId, // Use the direct IDs instead of group objects
|
||||
to_group: targetGroupId, // Use the direct IDs instead of group objects
|
||||
group_by: groupBy || 'status', // Use the current groupBy value
|
||||
task,
|
||||
team_id: currentSession?.team_id
|
||||
};
|
||||
|
||||
// Emit socket event
|
||||
if (socket) {
|
||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
|
||||
|
||||
// Set up listener for task progress update
|
||||
socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => {
|
||||
if (task.is_sub_task) {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
|
||||
} else {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track analytics event
|
||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||
}
|
||||
// Handle column reordering
|
||||
else if (isActiveSection) {
|
||||
// Don't allow reordering if groupBy is phases
|
||||
if (groupBy === IGroupBy.PHASE) {
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionId = active.id;
|
||||
const fromIndex = taskGroups.findIndex(group => group.id === sectionId);
|
||||
const toIndex = taskGroups.findIndex(group => group.id === over.id);
|
||||
|
||||
if (fromIndex !== -1 && toIndex !== -1) {
|
||||
// Create a new array with the reordered groups
|
||||
const reorderedGroups = [...taskGroups];
|
||||
const [movedGroup] = reorderedGroups.splice(fromIndex, 1);
|
||||
reorderedGroups.splice(toIndex, 0, movedGroup);
|
||||
|
||||
// Dispatch action to reorder columns with the new array
|
||||
dispatch(reorderTaskGroups(reorderedGroups));
|
||||
|
||||
// Prepare column order for API
|
||||
const columnOrder = reorderedGroups.map(group => group.id);
|
||||
|
||||
// Call API to update status order
|
||||
try {
|
||||
// Use the correct API endpoint based on the Angular code
|
||||
const requestBody: ITaskStatusCreateRequest = {
|
||||
status_order: columnOrder
|
||||
};
|
||||
|
||||
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
|
||||
if (!response.done) {
|
||||
const revertedGroups = [...reorderedGroups];
|
||||
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
|
||||
revertedGroups.splice(fromIndex, 0, movedBackGroup);
|
||||
dispatch(reorderTaskGroups(revertedGroups));
|
||||
alertService.error('Failed to update column order', 'Please try again');
|
||||
}
|
||||
} catch (error) {
|
||||
// Revert the change if API call fails
|
||||
const revertedGroups = [...reorderedGroups];
|
||||
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
|
||||
revertedGroups.splice(fromIndex, 0, movedBackGroup);
|
||||
dispatch(reorderTaskGroups(revertedGroups));
|
||||
alertService.error('Failed to update column order', 'Please try again');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (socket) {
|
||||
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
||||
}
|
||||
|
||||
return () => {
|
||||
socket?.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_project_board_visit);
|
||||
if (!statusCategories.length && projectId) {
|
||||
dispatch(fetchStatusesCategories());
|
||||
}
|
||||
}, [dispatch, projectId]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16}>
|
||||
<TaskListFilters position={'board'} />
|
||||
|
||||
<Skeleton active loading={loadingGroups} className='mt-4 p-4'>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<BoardSectionCardContainer
|
||||
datasource={taskGroups}
|
||||
group={groupBy as 'status' | 'priority' | 'phases'}
|
||||
/>
|
||||
<DragOverlay>
|
||||
{activeItem?.type === 'task' && (
|
||||
<BoardViewTaskCard task={activeItem.task} sectionId={activeItem.sectionId} />
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</Skeleton>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewBoard;
|
||||
Reference in New Issue
Block a user