This commit is contained in:
chamikaJ
2025-04-17 18:28:54 +05:30
parent f583291d8a
commit 8825b0410a
2837 changed files with 241385 additions and 127578 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 />
&nbsp;
<Typography.Text>{t('assignToMe')}</Typography.Text>
</span>
),
key: '1',
onClick: handleAssignToMe,
disabled: updatingAssignToMe,
},
{
label: (
<span>
<InboxOutlined />
&nbsp;
<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 />
&nbsp;
{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;

View File

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

View File

@@ -0,0 +1,271 @@
import {
Button,
Card,
Flex,
Popconfirm,
Segmented,
Table,
TableProps,
Tooltip,
Typography,
} from 'antd';
import { useEffect, useState } from 'react';
import { colors } from '@/styles/colors';
import {
AppstoreOutlined,
BarsOutlined,
CloudDownloadOutlined,
DeleteOutlined,
ExclamationCircleFilled,
ExclamationCircleOutlined,
} from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { durationDateFormat } from '@utils/durationDateFormat';
import { DEFAULT_PAGE_SIZE, IconsMap } from '@/shared/constants';
import {
IProjectAttachmentsViewModel,
ITaskAttachmentViewModel,
} from '@/types/tasks/task-attachment-view-model';
import { useAppSelector } from '@/hooks/useAppSelector';
import { attachmentsApiService } from '@/api/attachments/attachments.api.service';
import logger from '@/utils/errorLogger';
import { evt_project_files_visit } from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
const ProjectViewFiles = () => {
const { t } = useTranslation('project-view-files');
const { trackMixpanelEvent } = useMixpanelTracking();
const { projectId, refreshTimestamp } = useAppSelector(state => state.projectReducer);
const [attachments, setAttachments] = useState<IProjectAttachmentsViewModel>({});
const [loading, setLoading] = useState(false);
const [downloading, setDownloading] = useState(false);
const [paginationConfig, setPaginationConfig] = useState({
total: 0,
pageIndex: 1,
showSizeChanger: true,
defaultPageSize: DEFAULT_PAGE_SIZE,
});
const fetchAttachments = async () => {
if (!projectId) return;
try {
setLoading(true);
const response = await attachmentsApiService.getProjectAttachments(
projectId,
paginationConfig.pageIndex,
paginationConfig.defaultPageSize
);
if (response.done) {
setAttachments(response.body || {});
setPaginationConfig(prev => ({ ...prev, total: response.body?.total || 0 }));
}
} catch (error) {
logger.error('Error fetching project attachments', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchAttachments();
}, [refreshTimestamp]);
const getFileTypeIcon = (type: string | undefined) => {
if (!type) return IconsMap['search'];
return IconsMap[type as string] || IconsMap['search'];
};
const downloadAttachment = async (id: string | undefined, filename: string | undefined) => {
if (!id || !filename) return;
try {
setDownloading(true);
const response = await attachmentsApiService.downloadAttachment(id, filename);
if (response.done) {
const link = document.createElement('a');
link.href = response.body || '';
link.download = filename;
link.click();
link.remove();
}
} catch (error) {
logger.error('Error downloading attachment', error);
} finally {
setDownloading(false);
}
};
const deleteAttachment = async (id: string | undefined) => {
if (!id) return;
try {
const response = await attachmentsApiService.deleteAttachment(id);
if (response.done) {
fetchAttachments();
}
} catch (error) {
logger.error('Error deleting attachment', error);
}
};
const openAttachment = (url: string | undefined) => {
if (!url) return;
const a = document.createElement('a');
a.href = url;
a.target = '_blank';
a.style.display = 'none';
a.click();
};
useEffect(() => {
trackMixpanelEvent(evt_project_files_visit);
fetchAttachments();
}, [paginationConfig.pageIndex, projectId]);
const columns: TableProps<ITaskAttachmentViewModel>['columns'] = [
{
key: 'fileName',
title: t('nameColumn'),
render: (record: ITaskAttachmentViewModel) => (
<Flex
gap={4}
align="center"
style={{ cursor: 'pointer' }}
onClick={() => openAttachment(record.url)}
>
<img
src={`/file-types/${getFileTypeIcon(record.type)}`}
alt={t('fileIconAlt')}
style={{ width: '100%', maxWidth: 25 }}
/>
<Typography.Text>
[{record.task_key}] {record.name}
</Typography.Text>
</Flex>
),
},
{
key: 'attachedTask',
title: t('attachedTaskColumn'),
render: (record: ITaskAttachmentViewModel) => (
<Typography.Text style={{ cursor: 'pointer' }} onClick={() => openAttachment(record.url)}>
{record.task_name}
</Typography.Text>
),
},
{
key: 'size',
title: t('sizeColumn'),
render: (record: ITaskAttachmentViewModel) => (
<Typography.Text style={{ cursor: 'pointer' }} onClick={() => openAttachment(record.url)}>
{record.size}
</Typography.Text>
),
},
{
key: 'uploadedBy',
title: t('uploadedByColumn'),
render: (record: ITaskAttachmentViewModel) => (
<Typography.Text style={{ cursor: 'pointer' }} onClick={() => openAttachment(record.url)}>
{record.uploader_name}
</Typography.Text>
),
},
{
key: 'uploadedAt',
title: t('uploadedAtColumn'),
render: (record: ITaskAttachmentViewModel) => (
<Typography.Text style={{ cursor: 'pointer' }} onClick={() => openAttachment(record.url)}>
<Tooltip title={record.created_at}>{durationDateFormat(record.created_at)}</Tooltip>
</Typography.Text>
),
},
{
key: 'actionBtns',
width: 80,
render: (record: ITaskAttachmentViewModel) => (
<Flex gap={8} style={{ padding: 0 }}>
<Popconfirm
title={t('deleteConfirmationTitle')}
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText={t('deleteConfirmationOk')}
cancelText={t('deleteConfirmationCancel')}
onConfirm={() => deleteAttachment(record.id)}
>
<Tooltip title="Delete">
<Button shape="default" icon={<DeleteOutlined />} size="small" />
</Tooltip>
</Popconfirm>
<Tooltip title="Download">
<Button
size="small"
icon={<CloudDownloadOutlined />}
onClick={() => downloadAttachment(record.id, record.name)}
loading={downloading}
/>
</Tooltip>
</Flex>
),
},
];
return (
<Card
style={{ width: '100%' }}
title={
<Flex justify="space-between">
<Typography.Text
style={{
display: 'flex',
gap: 4,
alignItems: 'center',
color: colors.lightGray,
fontSize: 13,
lineHeight: 1,
}}
>
<ExclamationCircleOutlined />
{t('titleDescriptionText')}
</Typography.Text>
<Tooltip title={t('segmentedTooltip')}>
<Segmented
options={[
{ value: 'listView', icon: <BarsOutlined /> },
{ value: 'thumbnailView', icon: <AppstoreOutlined /> },
]}
defaultValue={'listView'}
disabled={true}
/>
</Tooltip>
</Flex>
}
>
<Table<ITaskAttachmentViewModel>
className="custom-two-colors-row-table"
dataSource={attachments.data}
columns={columns}
rowKey={record => record.id || ''}
loading={loading}
pagination={{
showSizeChanger: paginationConfig.showSizeChanger,
defaultPageSize: paginationConfig.defaultPageSize,
total: paginationConfig.total,
current: paginationConfig.pageIndex,
onChange: (page, pageSize) =>
setPaginationConfig(prev => ({
...prev,
pageIndex: page,
defaultPageSize: pageSize,
})),
}}
/>
</Card>
);
};
export default ProjectViewFiles;

View File

@@ -0,0 +1,27 @@
import { Card, Flex, Typography } from 'antd';
import TaskByMembersTable from './tables/tasks-by-members';
import MemberStats from '../member-stats/member-stats';
import { TFunction } from 'i18next';
const InsightsMembers = ({ t }: { t: TFunction }) => {
return (
<Flex vertical gap={24}>
<MemberStats />
<Card
className="custom-insights-card"
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('members.tasksByMembers')}
</Typography.Text>
}
style={{ width: '100%' }}
>
<TaskByMembersTable />
</Card>
</Flex>
);
};
export default InsightsMembers;

View File

@@ -0,0 +1,141 @@
import React, { useEffect, useState } from 'react';
import { Flex, Tooltip, Typography } from 'antd';
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IInsightTasks } from '@/types/project/projectInsights.types';
import { colors } from '@/styles/colors';
import { simpleDateFormat } from '@/utils/simpleDateFormat';
import { themeWiseColor } from '@/utils/themeWiseColor';
interface AssignedTasksListTableProps {
memberId: string;
projectId: string;
archived: boolean;
}
const columnsList = [
{ key: 'name', columnHeader: 'Name', width: 280 },
{ key: 'status', columnHeader: 'Status', width: 100 },
{ key: 'dueDate', columnHeader: 'Due Date', width: 150 },
{ key: 'overdue', columnHeader: 'Days Overdue', width: 150 },
{ key: 'completedDate', columnHeader: 'Completed Date', width: 150 },
{ key: 'totalAllocation', columnHeader: 'Total Allocation', width: 150 },
{ key: 'overLoggedTime', columnHeader: 'Over Logged Time', width: 150 },
];
const AssignedTasksListTable: React.FC<AssignedTasksListTableProps> = ({
memberId,
projectId,
archived,
}) => {
const [memberTasks, setMemberTasks] = useState<IInsightTasks[]>([]);
const [loading, setLoading] = useState(true);
const themeMode = useAppSelector(state => state.themeReducer.mode);
useEffect(() => {
const getTasksByMemberId = async () => {
setLoading(true);
try {
const res = await projectInsightsApiService.getMemberTasks({
member_id: memberId,
project_id: projectId,
archived,
});
if (res.done) {
setMemberTasks(res.body);
}
} catch (error) {
console.error('Error fetching member tasks:', error);
} finally {
setLoading(false);
}
};
getTasksByMemberId();
}, [memberId, projectId, archived]);
const renderColumnContent = (key: string, task: IInsightTasks) => {
switch (key) {
case 'name':
return (
<Tooltip title={task.name}>
<Typography.Text>{task.name}</Typography.Text>
</Tooltip>
);
case 'status':
return (
<Flex
gap={4}
style={{
width: 'fit-content',
borderRadius: 24,
paddingInline: 6,
backgroundColor: task.status_color,
color: colors.darkGray,
cursor: 'pointer',
}}
>
<Typography.Text
ellipsis={{ expanded: false }}
style={{
color: colors.darkGray,
fontSize: 13,
}}
>
{task.status}
</Typography.Text>
</Flex>
);
case 'dueDate':
return task.end_date ? simpleDateFormat(task.end_date) : 'N/A';
case 'overdue':
return task.days_overdue ?? 'N/A';
case 'completedDate':
return task.completed_at ? simpleDateFormat(task.completed_at) : 'N/A';
case 'totalAllocation':
return task.total_minutes ?? 'N/A';
case 'overLoggedTime':
return task.overlogged_time ?? 'N/A';
default:
return null;
}
};
return (
<div
className="min-h-0 max-w-full overflow-x-auto py-2 pl-12 pr-4"
style={{ backgroundColor: themeWiseColor('#f0f2f5', '#000', themeMode) }}
>
<table className="w-full min-w-max border-collapse">
<thead>
<tr>
{columnsList.map(column => (
<th
key={column.key}
className="p-2 text-left"
style={{ width: column.width, fontWeight: 500 }}
>
{column.columnHeader}
</th>
))}
</tr>
</thead>
<tbody>
{memberTasks.map(task => (
<tr key={task.id} className="h-[42px] border-t">
{columnsList.map(column => (
<td key={column.key} className="p-2" style={{ width: column.width }}>
{renderColumnContent(column.key, task)}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
};
export default AssignedTasksListTable;

View File

@@ -0,0 +1,161 @@
import { useEffect, useState } from 'react';
import { Flex, Progress } from 'antd';
import { colors } from '@/styles/colors';
import { useAppSelector } from '@/hooks/useAppSelector';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { DownOutlined, ExclamationCircleOutlined, RightOutlined } from '@ant-design/icons';
import logger from '@/utils/errorLogger';
import { projectsApiService } from '@/api/projects/projects.api.service';
import { useTranslation } from 'react-i18next';
import { IProjectOverviewStats } from '@/types/project/projectsViewModel.types';
import { ITeamMemberOverviewGetResponse } from '@/types/project/project-insights.types';
import React from 'react';
import AssignedTasksListTable from './assigned-tasks-list';
const TaskByMembersTable = () => {
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
const [expandedRows, setExpandedRows] = useState<string[]>([]);
const [memberList, setMemberList] = useState<ITeamMemberOverviewGetResponse[]>([]);
const [loading, setLoading] = useState(false);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const { t } = useTranslation('project-view-insights');
const themeMode = useAppSelector(state => state.themeReducer.mode);
const getProjectOverviewMembers = async () => {
if (!projectId) return;
try {
const res = await projectsApiService.getOverViewMembersById(projectId);
if (res.done) {
setMemberList(res.body);
}
} catch (error) {
logger.error('Error fetching member tasks:', error);
} finally {
setLoading(false);
}
setLoading(true);
};
useEffect(() => {
getProjectOverviewMembers();
}, [projectId,refreshTimestamp]);
// toggle members row expansions
const toggleRowExpansion = (memberId: string) => {
setExpandedRows(prev =>
prev.includes(memberId) ? prev.filter(id => id !== memberId) : [...prev, memberId]
);
};
// columns list
const columnsList = [
{ key: 'name', columnHeader: t('members.name'), width: 200 },
{ key: 'taskCount', columnHeader: t('members.taskCount'), width: 100 },
{ key: 'contribution', columnHeader: t('members.contribution'), width: 120 },
{ key: 'completed', columnHeader: t('members.completed'), width: 100 },
{ key: 'incomplete', columnHeader: t('members.incomplete'), width: 100 },
{ key: 'overdue', columnHeader: t('members.overdue'), width: 100 },
{ key: 'progress', columnHeader: t('members.progress'), width: 150 },
];
// render content, based on column type
const renderColumnContent = (key: string, member: ITeamMemberOverviewGetResponse) => {
switch (key) {
case 'name':
return (
<Flex gap={8} align="center">
{member?.task_count && (
<button onClick={() => toggleRowExpansion(member.id)}>
{expandedRows.includes(member.id) ? <DownOutlined /> : <RightOutlined />}
</button>
)}
{member.overdue_task_count ? (
<ExclamationCircleOutlined style={{ color: colors.vibrantOrange }} />
) : (
<div style={{ width: 14, height: 14 }}></div>
)}
{member.name}
</Flex>
);
case 'taskCount':
return member.task_count;
case 'contribution':
return `${member.contribution}%`;
case 'completed':
return member.done_task_count;
case 'incomplete':
return member.pending_task_count;
case 'overdue':
return member.overdue_task_count;
case 'progress':
return (
<Progress
percent={Math.floor(((member.done_task_count ?? 0) / (member.task_count ?? 1)) * 100)}
/>
);
default:
return null;
}
};
return (
<div className="memberList-container min-h-0 max-w-full overflow-x-auto">
<table className="w-full min-w-max border-collapse rounded">
<thead
style={{
height: 42,
backgroundColor: themeWiseColor('#f8f7f9', '#1d1d1d', themeMode),
}}
>
<tr>
{columnsList.map(column => (
<th
key={column.key}
className={`p-2`}
style={{ width: column.width, fontWeight: 500 }}
>
{column.columnHeader}
</th>
))}
</tr>
</thead>
<tbody>
{memberList?.map(member => (
<React.Fragment key={member.id}>
<tr key={member.id} className="h-[42px] cursor-pointer">
{columnsList.map(column => (
<td
key={column.key}
className={`border-t p-2 text-center`}
style={{
width: column.width,
}}
>
{renderColumnContent(column.key, member)}
</td>
))}
</tr>
{expandedRows.includes(member.id) && (
<tr>
<td colSpan={columnsList.length}>
<AssignedTasksListTable
memberId={member.id}
projectId={projectId}
archived={includeArchivedTasks}
/>
</td>
</tr>
)}
</React.Fragment>
))}
</tbody>
</table>
</div>
);
};
export default TaskByMembersTable;

View File

@@ -0,0 +1,119 @@
import { Bar } from 'react-chartjs-2';
import { Chart, ArcElement, Tooltip, CategoryScale, LinearScale, BarElement } from 'chart.js';
import { ChartOptions } from 'chart.js';
import { Flex } from 'antd';
import { ITaskPriorityCounts } from '@/types/project/project-insights.types';
import { useEffect, useState } from 'react';
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
import { useAppSelector } from '@/hooks/useAppSelector';
import { Spin } from 'antd/lib';
Chart.register(ArcElement, Tooltip, CategoryScale, LinearScale, BarElement);
const PriorityOverview = () => {
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
const [stats, setStats] = useState<ITaskPriorityCounts[]>([]);
const [loading, setLoading] = useState(false);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getTaskPriorityCounts = async () => {
if (!projectId) return;
setLoading(true);
try {
const res = await projectInsightsApiService.getPriorityOverview(
projectId,
includeArchivedTasks
);
if (res.done) {
setStats(res.body);
}
} catch (error) {
console.error('Error fetching task priority counts:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
getTaskPriorityCounts();
}, [projectId, includeArchivedTasks, refreshTimestamp]);
const options: ChartOptions<'bar'> = {
responsive: true,
maintainAspectRatio: false,
scales: {
x: {
title: {
display: true,
text: 'Priority',
align: 'end',
},
grid: {
color: 'rgba(200, 200, 200, 0.5)',
},
},
y: {
title: {
display: true,
text: 'Task Count',
align: 'end',
},
grid: {
color: 'rgba(200, 200, 200, 0.5)',
},
beginAtZero: true,
},
},
plugins: {
legend: {
display: false,
position: 'top' as const,
},
datalabels: {
display: false,
},
},
};
const data = {
labels: stats.map(stat => stat.name),
datasets: [
{
label: 'Tasks',
data: stats.map(stat => stat.data),
backgroundColor: stats.map(stat => stat.color),
},
],
};
const mockPriorityData = {
labels: ['Low', 'Medium', 'High'],
datasets: [
{
label: 'Tasks',
data: [6, 12, 2],
backgroundColor: ['#75c997', '#fbc84c', '#f37070'],
hoverBackgroundColor: ['#46d980', '#ffc227', '#ff4141'],
},
],
};
if (loading) {
return (
<Flex justify="center" align="center" style={{ height: 350 }}>
<Spin size="large" />
</Flex>
);
}
return (
<Flex justify="center">
{loading && <Spin />}
<Bar options={options} data={data} className="h-[350px] w-full md:max-w-[580px]" />
</Flex>
);
};
export default PriorityOverview;

View File

@@ -0,0 +1,107 @@
import React, { useEffect, useState } from 'react';
import { Doughnut } from 'react-chartjs-2';
import { Chart, ArcElement } from 'chart.js';
import { Badge, Flex, Tooltip, Typography, Spin } from 'antd';
import { ChartOptions } from 'chart.js';
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
import { ITaskStatusCounts } from '@/types/project/project-insights.types';
import { useAppSelector } from '@/hooks/useAppSelector';
Chart.register(ArcElement);
const StatusOverview = () => {
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
const [stats, setStats] = useState<ITaskStatusCounts[]>([]);
const [loading, setLoading] = useState(false);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getTaskStatusCounts = async () => {
if (!projectId) return;
setLoading(true);
try {
const res = await projectInsightsApiService.getTaskStatusCounts(
projectId,
includeArchivedTasks
);
if (res.done) {
setStats(res.body);
}
} catch (error) {
console.error('Error fetching task status counts:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
getTaskStatusCounts();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
const options: ChartOptions<'doughnut'> = {
responsive: true,
maintainAspectRatio: false,
plugins: {
datalabels: {
display: false,
},
legend: {
display: false,
},
tooltip: {
callbacks: {
label: context => {
const value = context.raw as number;
return `${context.label}: ${value} task${value !== 1 ? 's' : ''}`;
},
},
},
},
};
const data = {
labels: stats.map(status => status.name),
datasets: [
{
label: 'Tasks',
data: stats.map(status => status.y),
backgroundColor: stats.map(status => status.color),
hoverBackgroundColor: stats.map(status => status.color + '90'),
borderWidth: 1,
},
],
};
if (loading) {
return (
<Flex justify="center" align="center" style={{ height: 350 }}>
<Spin size="large" />
</Flex>
);
}
return (
<Flex gap={24} wrap="wrap-reverse" justify="center">
{loading && <Spin />}
<div style={{ position: 'relative', height: 350, width: '100%', maxWidth: 350 }}>
<Doughnut options={options} data={data} />
</div>
<Flex gap={12} style={{ marginBlockStart: 12 }} wrap="wrap" className="flex-row xl:flex-col">
{stats.map(status => (
<Flex key={status.name} gap={8} align="center">
<Badge color={status.color} />
<Typography.Text>
{status.name}
<span style={{ marginLeft: 4 }}>({status.y})</span>
</Typography.Text>
</Flex>
))}
</Flex>
</Flex>
);
};
export default StatusOverview;

View File

@@ -0,0 +1,60 @@
import { Button, Card, Flex, Typography } from 'antd';
import StatusOverview from './graphs/status-overview';
import PriorityOverview from './graphs/priority-overview';
import LastUpdatedTasks from './tables/last-updated-tasks';
import ProjectDeadline from './tables/project-deadline';
import ProjectStats from '../project-stats/project-stats';
import { TFunction } from 'i18next';
const InsightsOverview = ({ t }: { t: TFunction }) => {
return (
<Flex vertical gap={24}>
<ProjectStats t={t} />
<Flex gap={24} className="grid md:grid-cols-2">
<Card
className="custom-insights-card"
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('overview.statusOverview')}
</Typography.Text>
}
style={{ width: '100%' }}
>
<StatusOverview />
</Card>
<Card
className="custom-insights-card"
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('overview.priorityOverview')}
</Typography.Text>
}
style={{ width: '100%' }}
>
<PriorityOverview />
</Card>
</Flex>
<Flex gap={24} className="grid lg:grid-cols-2">
<Card
className="custom-insights-card"
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('overview.lastUpdatedTasks')}
</Typography.Text>
}
extra={<Button type="link">{t('common.seeAll')}</Button>}
style={{ width: '100%' }}
>
<LastUpdatedTasks />
</Card>
<ProjectDeadline />
</Flex>
</Flex>
);
};
export default InsightsOverview;

View File

@@ -0,0 +1,128 @@
import { Flex, Table, Tooltip, Typography } from 'antd';
import { useEffect, useState } from 'react';
import { colors } from '@/styles/colors';
import { TableProps } from 'antd/lib';
import { simpleDateFormat } from '@/utils/simpleDateFormat';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IInsightTasks } from '@/types/project/projectInsights.types';
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
import logger from '@/utils/errorLogger';
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
const LastUpdatedTasks = () => {
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
const [data, setData] = useState<IInsightTasks[]>([]);
const [loading, setLoading] = useState(false);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getLastUpdatedTasks = async () => {
if (!projectId) return;
setLoading(true);
try {
const res = await projectInsightsApiService.getLastUpdatedTasks(
projectId,
includeArchivedTasks
);
if (res.done) {
setData(res.body);
}
} catch (error) {
logger.error('getLastUpdatedTasks', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
getLastUpdatedTasks();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
// table columns
const columns: TableProps['columns'] = [
{
key: 'name',
title: 'Name',
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
},
{
key: 'status',
title: 'Status',
render: (record: IInsightTasks) => (
<Flex
gap={4}
style={{
width: 'fit-content',
borderRadius: 24,
paddingInline: 6,
backgroundColor: record.status_color,
color: colors.darkGray,
cursor: 'pointer',
}}
>
<Typography.Text
ellipsis={{ expanded: false }}
style={{
color: colors.darkGray,
fontSize: 13,
}}
>
{record.status}
</Typography.Text>
</Flex>
),
},
{
key: 'dueDate',
title: 'Due Date',
render: (record: IInsightTasks) => (
<Typography.Text>
{record.end_date ? simpleDateFormat(record.end_date) : 'N/A'}
</Typography.Text>
),
},
{
key: 'lastUpdated',
title: 'Last Updated',
render: (record: IInsightTasks) => (
<Tooltip title={record.updated_at ? formatDateTimeWithLocale(record.updated_at) : 'N/A'}>
<Typography.Text>
{record.updated_at ? calculateTimeDifference(record.updated_at) : 'N/A'}
</Typography.Text>
</Tooltip>
),
},
];
const dataSource = data.map(record => ({
...record,
key: record.id,
}));
return (
<Table
className="custom-two-colors-row-table"
dataSource={dataSource}
columns={columns}
rowKey={record => record.id}
pagination={{
showSizeChanger: true,
defaultPageSize: 20,
}}
loading={loading}
onRow={() => {
return {
style: {
cursor: 'pointer',
height: 36,
},
};
}}
/>
);
};
export default LastUpdatedTasks;

View File

@@ -0,0 +1,137 @@
import { Card, Flex, Skeleton, Table, Typography } from 'antd';
import { useEffect, useState } from 'react';
import { colors } from '@/styles/colors';
import { TableProps } from 'antd/lib';
import { simpleDateFormat } from '@/utils/simpleDateFormat';
import logger from '@/utils/errorLogger';
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
import { IDeadlineTaskStats, IInsightTasks } from '@/types/project/project-insights.types';
import ProjectStatsCard from '@/components/projects/project-stats-card';
import warningIcon from '@assets/icons/insightsIcons/warning.png';
import { useAppSelector } from '@/hooks/useAppSelector';
const ProjectDeadline = () => {
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
const [loading, setLoading] = useState(false);
const [data, setData] = useState<IDeadlineTaskStats | null>(null);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getProjectDeadline = async () => {
if (!projectId) return;
try {
setLoading(true);
const res = await projectInsightsApiService.getProjectDeadlineStats(
projectId,
includeArchivedTasks
);
if (res.done) {
setData(res.body);
}
} catch {
logger.error('Error fetching project deadline stats', { projectId, includeArchivedTasks });
} finally {
setLoading(false);
}
};
useEffect(() => {
getProjectDeadline();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
// table columns
const columns: TableProps['columns'] = [
{
key: 'name',
title: 'Name',
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
},
{
key: 'status',
title: 'Status',
render: (record: IInsightTasks) => (
<Flex
gap={4}
style={{
width: 'fit-content',
borderRadius: 24,
paddingInline: 6,
backgroundColor: record.status_color,
color: colors.darkGray,
cursor: 'pointer',
}}
>
<Typography.Text
ellipsis={{ expanded: false }}
style={{
color: colors.darkGray,
fontSize: 13,
}}
>
{record.status}
</Typography.Text>
</Flex>
),
},
{
key: 'dueDate',
title: 'Due Date',
render: (record: IInsightTasks) => (
<Typography.Text>
{record.end_date ? simpleDateFormat(record.end_date) : 'N/A'}
</Typography.Text>
),
},
];
return (
<Card
className="custom-insights-card"
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
Project Deadline <span style={{ color: colors.lightGray }}>{data?.project_end_date}</span>
</Typography.Text>
}
style={{ width: '100%' }}
>
<Flex vertical gap={24}>
<Flex gap={12} style={{ width: '100%' }}>
<Skeleton active loading={loading}>
<ProjectStatsCard
icon={warningIcon}
title="Overdue tasks (hours)"
tooltip={'Tasks that has time logged past the end date of the project'}
children={data?.deadline_logged_hours_string || 'N/A'}
/>
<ProjectStatsCard
icon={warningIcon}
title="Overdue tasks"
tooltip={'Tasks that are past the end date of the project'}
children={data?.deadline_tasks_count || 'N/A'}
/>
</Skeleton>
</Flex>
<Table
className="custom-two-colors-row-table"
dataSource={data?.tasks}
columns={columns}
rowKey={record => record.taskId}
pagination={{
showSizeChanger: true,
defaultPageSize: 20,
}}
onRow={record => {
return {
style: {
cursor: 'pointer',
height: 36,
},
};
}}
/>
</Flex>
</Card>
);
};
export default ProjectDeadline;

View File

@@ -0,0 +1,100 @@
import { Button, Card, Flex, Tooltip, Typography } from 'antd';
import { ExclamationCircleOutlined } from '@ant-design/icons';
import { colors } from '@/styles/colors';
import OverdueTasksTable from './tables/overdue-tasks-table';
import OverLoggedTasksTable from './tables/over-logged-tasks-table';
import TaskCompletedEarlyTable from './tables/task-completed-early-table';
import TaskCompletedLateTable from './tables/task-completed-late-table';
import ProjectStats from '../project-stats/project-stats';
import { TFunction } from 'i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
const InsightsTasks = ({ t }: { t: TFunction }) => {
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
return (
<Flex vertical gap={24}>
<ProjectStats t={t} />
<Flex gap={24} className="grid lg:grid-cols-2">
<Card
className="custom-insights-card"
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('tasks.overdueTasks')}
<Tooltip title={t('tasks.overdueTasksTooltip')}>
<ExclamationCircleOutlined
style={{
color: colors.skyBlue,
fontSize: 13,
marginInlineStart: 4,
}}
/>
</Tooltip>
</Typography.Text>
}
extra={<Button type="link">{t('common.seeAll')}</Button>}
style={{ width: '100%' }}
>
<OverdueTasksTable projectId={projectId} includeArchivedTasks={includeArchivedTasks} />
</Card>
<Card
className="custom-insights-card"
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('tasks.overLoggedTasks')}
<Tooltip title={t('tasks.overLoggedTasksTooltip')}>
<ExclamationCircleOutlined
style={{
color: colors.skyBlue,
fontSize: 13,
marginInlineStart: 4,
}}
/>
</Tooltip>
</Typography.Text>
}
extra={<Button type="link">{t('common.seeAll')}</Button>}
style={{ width: '100%' }}
>
<OverLoggedTasksTable projectId={projectId} includeArchivedTasks={includeArchivedTasks} />
</Card>
<Card
className="custom-insights-card"
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('tasks.tasksCompletedEarly')}
</Typography.Text>
}
extra={<Button type="link">{t('common.seeAll')}</Button>}
style={{ width: '100%' }}
>
<TaskCompletedEarlyTable
projectId={projectId}
includeArchivedTasks={includeArchivedTasks}
/>
</Card>
<Card
className="custom-insights-card"
title={
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{t('tasks.tasksCompletedLate')}
</Typography.Text>
}
extra={<Button type="link">{t('common.seeAll')}</Button>}
style={{ width: '100%' }}
>
<TaskCompletedLateTable
projectId={projectId}
includeArchivedTasks={includeArchivedTasks}
/>
</Card>
</Flex>
</Flex>
);
};
export default InsightsTasks;

View File

@@ -0,0 +1,137 @@
import { Avatar, Button, Flex, Table, Typography } from 'antd';
import { useState, useEffect } from 'react';
import { colors } from '@/styles/colors';
import { TableProps } from 'antd/lib';
import { PlusOutlined } from '@ant-design/icons';
import { IInsightTasks } from '@/types/project/projectInsights.types';
import logger from '@/utils/errorLogger';
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
import { useAppSelector } from '@/hooks/useAppSelector';
const OverLoggedTasksTable = () => {
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
const [overLoggedTaskList, setOverLoggedTaskList] = useState<IInsightTasks[]>([]);
const [loading, setLoading] = useState(true);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getOverLoggedTasks = async () => {
try {
setLoading(true);
const res = await projectInsightsApiService.getOverloggedTasks(
projectId,
includeArchivedTasks
);
if (res.done) {
setOverLoggedTaskList(res.body);
}
} catch (error) {
logger.error('Error fetching over logged tasks', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
getOverLoggedTasks();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
// table columns
const columns: TableProps['columns'] = [
{
key: 'name',
title: 'Name',
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
},
{
key: 'status',
title: 'Status',
render: (record: IInsightTasks) => (
<Flex
gap={4}
style={{
width: 'fit-content',
borderRadius: 24,
paddingInline: 6,
backgroundColor: record.status_color,
color: colors.darkGray,
cursor: 'pointer',
}}
>
<Typography.Text
ellipsis={{ expanded: false }}
style={{
color: colors.darkGray,
fontSize: 13,
}}
>
{record.status_name}
</Typography.Text>
</Flex>
),
},
{
key: 'members',
title: 'Members',
render: (record: IInsightTasks) =>
record.status_name ? (
<Avatar.Group>
{/* {record.names.map((member) => (
<CustomAvatar avatarName={member.memberName} size={26} />
))} */}
</Avatar.Group>
) : (
<Button
disabled
type="dashed"
shape="circle"
size="small"
icon={
<PlusOutlined
style={{
fontSize: 12,
width: 22,
height: 22,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
}
/>
),
},
{
key: 'overLoggedTime',
title: 'Over Logged Time',
render: (record: IInsightTasks) => (
<Typography.Text>{record.overlogged_time}</Typography.Text>
),
},
];
return (
<Table
className="custom-two-colors-row-table"
dataSource={overLoggedTaskList}
columns={columns}
rowKey={record => record.taskId}
pagination={{
showSizeChanger: false,
defaultPageSize: 10,
}}
loading={loading}
onRow={record => {
return {
style: {
cursor: 'pointer',
height: 36,
},
};
}}
/>
);
};
export default OverLoggedTasksTable;

View File

@@ -0,0 +1,113 @@
import { Flex, Table, Typography } from 'antd';
import { useEffect, useState } from 'react';
import { colors } from '@/styles/colors';
import { TableProps } from 'antd/lib';
import { simpleDateFormat } from '@/utils/simpleDateFormat';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IInsightTasks } from '@/types/project/projectInsights.types';
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
const OverdueTasksTable = ({
projectId,
includeArchivedTasks,
}: {
projectId: string;
includeArchivedTasks: boolean;
}) => {
const [overdueTaskList, setOverdueTaskList] = useState<IInsightTasks[]>([]);
const [loading, setLoading] = useState(true);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getOverdueTasks = async () => {
setLoading(true);
try {
const res = await projectInsightsApiService.getOverdueTasks(projectId, includeArchivedTasks);
if (res.done) {
setOverdueTaskList(res.body);
}
} catch (error) {
console.error('Error fetching overdue tasks:', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
getOverdueTasks();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
// table columns
const columns: TableProps['columns'] = [
{
key: 'name',
title: 'Name',
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
},
{
key: 'status',
title: 'Status',
render: (record: IInsightTasks) => (
<Flex
gap={4}
style={{
width: 'fit-content',
borderRadius: 24,
paddingInline: 6,
backgroundColor: record.status_color,
color: colors.darkGray,
cursor: 'pointer',
}}
>
<Typography.Text
ellipsis={{ expanded: false }}
style={{
color: colors.darkGray,
fontSize: 13,
}}
>
{record.status_name}
</Typography.Text>
</Flex>
),
},
{
key: 'dueDate',
title: 'End Date',
render: (record: IInsightTasks) => (
<Typography.Text>
{record.end_date ? simpleDateFormat(record.end_date) : 'N/A'}
</Typography.Text>
),
},
{
key: 'daysOverdue',
title: 'Days overdue',
render: (record: IInsightTasks) => <Typography.Text>{record.days_overdue}</Typography.Text>,
},
];
return (
<Table
className="custom-two-colors-row-table"
dataSource={overdueTaskList}
columns={columns}
rowKey={record => record.taskId}
pagination={{
showSizeChanger: false,
defaultPageSize: 10,
}}
loading={loading}
onRow={record => {
return {
style: {
cursor: 'pointer',
height: 36,
},
};
}}
/>
);
};
export default OverdueTasksTable;

View File

@@ -0,0 +1,117 @@
import { Flex, Table, Typography } from 'antd';
import { TableProps } from 'antd/lib';
import { useEffect, useState } from 'react';
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
import { IInsightTasks } from '@/types/project/projectInsights.types';
import { colors } from '@/styles/colors';
import { simpleDateFormat } from '@/utils/simpleDateFormat';
import logger from '@/utils/errorLogger';
import { useAppSelector } from '@/hooks/useAppSelector';
const TaskCompletedEarlyTable = ({
projectId,
includeArchivedTasks,
}: {
projectId: string;
includeArchivedTasks: boolean;
}) => {
const [earlyCompletedTaskList, setEarlyCompletedTaskList] = useState<IInsightTasks[]>([]);
const [loading, setLoading] = useState(true);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getEarlyCompletedTasks = async () => {
try {
setLoading(true);
const res = await projectInsightsApiService.getTasksCompletedEarly(
projectId,
includeArchivedTasks
);
if (res.done) {
setEarlyCompletedTaskList(res.body);
}
} catch (error) {
logger.error('Error fetching early completed tasks', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
getEarlyCompletedTasks();
}, [projectId, includeArchivedTasks, refreshTimestamp]);
const columns: TableProps['columns'] = [
{
key: 'name',
title: 'Name',
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
},
{
key: 'status',
title: 'Status',
render: (record: IInsightTasks) => (
<Flex
gap={4}
style={{
width: 'fit-content',
borderRadius: 24,
paddingInline: 6,
backgroundColor: record.status_color,
color: colors.darkGray,
cursor: 'pointer',
}}
>
<Typography.Text
ellipsis={{ expanded: false }}
style={{
color: colors.darkGray,
fontSize: 13,
}}
>
{record.status_name}
</Typography.Text>
</Flex>
),
},
{
key: 'dueDate',
title: 'End Date',
render: (record: IInsightTasks) => (
<Typography.Text>
{record.end_date ? simpleDateFormat(record.end_date) : 'N/A'}
</Typography.Text>
),
},
{
key: 'completedDate',
title: 'Completed At',
render: (record: IInsightTasks) => (
<Typography.Text>{simpleDateFormat(record.completed_at || null)}</Typography.Text>
),
},
];
return (
<Table
className="custom-two-colors-row-table"
dataSource={earlyCompletedTaskList}
columns={columns}
rowKey={record => record.taskId}
loading={loading}
pagination={{
showSizeChanger: false,
defaultPageSize: 10,
}}
onRow={record => ({
style: {
cursor: 'pointer',
height: 36,
},
})}
/>
);
};
export default TaskCompletedEarlyTable;

View File

@@ -0,0 +1,118 @@
import { Flex, Table, Typography } from 'antd';
import { useEffect, useState } from 'react';
import { colors } from '@/styles/colors';
import { TableProps } from 'antd/lib';
import { simpleDateFormat } from '@/utils/simpleDateFormat';
import { IInsightTasks } from '@/types/project/projectInsights.types';
import logger from '@/utils/errorLogger';
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
import { useAppSelector } from '@/hooks/useAppSelector';
const TaskCompletedLateTable = ({
projectId,
includeArchivedTasks,
}: {
projectId: string;
includeArchivedTasks: boolean;
}) => {
const [lateCompletedTaskList, setLateCompletedTaskList] = useState<IInsightTasks[]>([]);
const [loading, setLoading] = useState(true);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getLateCompletedTasks = async () => {
try {
setLoading(true);
const res = await projectInsightsApiService.getTasksCompletedLate(
projectId,
includeArchivedTasks
);
if (res.done) {
setLateCompletedTaskList(res.body);
}
} catch (error) {
logger.error('Error fetching late completed tasks', error);
} finally {
setLoading(false);
}
};
useEffect(() => {
getLateCompletedTasks();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
// table columns
const columns: TableProps['columns'] = [
{
key: 'name',
title: 'Name',
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
},
{
key: 'status',
title: 'Status',
render: (record: IInsightTasks) => (
<Flex
gap={4}
style={{
width: 'fit-content',
borderRadius: 24,
paddingInline: 6,
backgroundColor: record.status_color,
color: colors.darkGray,
cursor: 'pointer',
}}
>
<Typography.Text
ellipsis={{ expanded: false }}
style={{
color: colors.darkGray,
fontSize: 13,
}}
>
{record.status_name}
</Typography.Text>
</Flex>
),
},
{
key: 'dueDate',
title: 'End Date',
render: (record: IInsightTasks) => (
<Typography.Text>{simpleDateFormat(record.end_date || null)}</Typography.Text>
),
},
{
key: 'completedDate',
title: 'Completed At',
render: (record: IInsightTasks) => (
<Typography.Text>{simpleDateFormat(record.completed_at || null)}</Typography.Text>
),
},
];
return (
<Table
className="custom-two-colors-row-table"
dataSource={lateCompletedTaskList}
columns={columns}
rowKey={record => record.taskId}
pagination={{
showSizeChanger: false,
defaultPageSize: 10,
}}
loading={loading}
size="small"
onRow={record => {
return {
style: {
cursor: 'pointer',
height: 36,
},
};
}}
/>
);
};
export default TaskCompletedLateTable;

View File

@@ -0,0 +1,64 @@
import ProjectStatsCard from '@/components/projects/project-stats-card';
import { Flex } from 'antd';
import groupIcon from '@/assets/icons/insightsIcons/group.png';
import warningIcon from '@/assets/icons/insightsIcons/warning.png';
import unassignedIcon from '@/assets/icons/insightsIcons/block-user.png';
import { useEffect, useState } from 'react';
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
import { IProjectMemberStats } from '@/types/project/project-insights.types';
import logger from '@/utils/errorLogger';
import { useAppSelector } from '@/hooks/useAppSelector';
const MemberStats = () => {
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
const [memberStats, setMemberStats] = useState<IProjectMemberStats | null>(null);
const [loadingStats, setLoadingStats] = useState(false);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const fetchMemberStats = async () => {
setLoadingStats(true);
try {
const res = await projectInsightsApiService.getMemberInsightAStats(
projectId,
includeArchivedTasks
);
if (res.done) {
setMemberStats(res.body);
}
} catch (error) {
logger.error('Error fetching member stats:', error);
} finally {
setLoadingStats(false);
}
};
useEffect(() => {
fetchMemberStats();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
return (
<Flex gap={24} className="grid sm:grid-cols-2 sm:grid-rows-2 lg:grid-cols-3 lg:grid-rows-1">
<ProjectStatsCard
icon={groupIcon}
title="Project Members"
children={memberStats?.total_members_count}
loading={loadingStats}
/>
<ProjectStatsCard
icon={warningIcon}
title="Assignees with overdue tasks"
children={memberStats?.overdue_members}
loading={loadingStats}
/>
<ProjectStatsCard
icon={unassignedIcon}
title="Unassigned Members"
children={memberStats?.unassigned_members}
loading={loadingStats}
/>
</Flex>
);
};
export default MemberStats;

View File

@@ -0,0 +1,96 @@
import ProjectStatsCard from '@/components/projects/project-stats-card';
import { Flex, Tooltip } from 'antd';
import checkIcon from '@assets/icons/insightsIcons/insights-check.png';
import clipboardIcon from '@assets/icons/insightsIcons/clipboard.png';
import clockIcon from '@assets/icons/insightsIcons/clock-green.png';
import warningIcon from '@assets/icons/insightsIcons/warning.png';
import { useEffect, useState } from 'react';
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
import { IProjectInsightsGetRequest } from '@/types/project/projectInsights.types';
import logger from '@/utils/errorLogger';
import { TFunction } from 'i18next';
import { useParams } from 'react-router-dom';
import { useAppSelector } from '@/hooks/useAppSelector';
const ProjectStats = ({ t }: { t: TFunction }) => {
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
const [stats, setStats] = useState<IProjectInsightsGetRequest>({});
const [loading, setLoading] = useState(false);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getProjectStats = async () => {
if (!projectId) return;
setLoading(true);
try {
const res = await projectInsightsApiService.getProjectOverviewData(
projectId,
includeArchivedTasks
);
if (res.done) {
setStats(res.body);
}
} catch (err) {
logger.error('Error fetching project stats:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
getProjectStats();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
const tooltipTable = (
<table>
<tbody>
<tr style={{ display: 'flex', gap: 12 }}>
<td style={{ width: 120 }}>{t('common.totalEstimation')}</td>
<td>{stats.total_estimated_hours_string || '0h'}</td>
</tr>
<tr style={{ display: 'flex', gap: 12 }}>
<td style={{ width: 120 }}>{t('common.totalLogged')}</td>
<td>{stats.total_logged_hours_string || '0h'}</td>
</tr>
</tbody>
</table>
);
return (
<Flex gap={24} className="grid sm:grid-cols-2 sm:grid-rows-2 lg:grid-cols-4 lg:grid-rows-1">
<ProjectStatsCard
icon={checkIcon}
title={t('common.completedTasks')}
loading={loading}
children={stats.completed_tasks_count ?? 0}
/>
<ProjectStatsCard
icon={clipboardIcon}
title={t('common.incompleteTasks')}
loading={loading}
children={stats.todo_tasks_count ?? 0}
/>
<ProjectStatsCard
icon={warningIcon}
title={t('common.overdueTasks')}
tooltip={t('common.overdueTasksTooltip')}
loading={loading}
children={stats.overdue_count ?? 0}
/>
<ProjectStatsCard
icon={clockIcon}
title={t('common.totalLoggedHours')}
tooltip={t('common.totalLoggedHoursTooltip')}
loading={loading}
children={
<Tooltip title={tooltipTable} trigger={'hover'}>
{stats.total_logged_hours_string || '0h'}
</Tooltip>
}
/>
</Flex>
);
};
export default ProjectStats;

View File

@@ -0,0 +1,179 @@
import { DownloadOutlined } from '@ant-design/icons';
import { Badge, Button, Checkbox, Flex, Segmented } from 'antd';
import { useEffect, useRef, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { colors } from '@/styles/colors';
import InsightsMembers from './insights-members/insights-members';
import InsightsOverview from './insights-overview/insights-overview';
import InsightsTasks from './insights-tasks/insights-tasks';
import {
setActiveSegment,
setIncludeArchivedTasks,
setProjectId,
} from '@/features/projects/insights/project-insights.slice';
import { format } from 'date-fns';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import logo from '@/assets/images/logo.png';
import { evt_project_insights_members_visit, evt_project_insights_overview_visit, evt_project_insights_tasks_visit } from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
type SegmentType = 'Overview' | 'Members' | 'Tasks';
const ProjectViewInsights = () => {
const { projectId } = useParams();
const { t } = useTranslation('project-view-insights');
const { trackMixpanelEvent } = useMixpanelTracking();
const exportRef = useRef<HTMLDivElement>(null);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const dispatch = useAppDispatch();
const [exportLoading, setExportLoading] = useState(false);
const { activeSegment, includeArchivedTasks } = useAppSelector(
state => state.projectInsightsReducer
);
const {
project: selectedProject,
} = useAppSelector(state => state.projectReducer);
const handleSegmentChange = (value: SegmentType) => {
dispatch(setActiveSegment(value));
};
const toggleArchivedTasks = () => {
dispatch(setIncludeArchivedTasks(!includeArchivedTasks));
};
useEffect(() => {
if (projectId) {
dispatch(setProjectId(projectId));
}
}, [projectId]);
const renderSegmentContent = () => {
if (!projectId) return null;
switch (activeSegment) {
case 'Overview':
trackMixpanelEvent(evt_project_insights_overview_visit);
return <InsightsOverview t={t} />;
case 'Members':
trackMixpanelEvent(evt_project_insights_members_visit);
return <InsightsMembers t={t} />;
case 'Tasks':
trackMixpanelEvent(evt_project_insights_tasks_visit);
return <InsightsTasks t={t} />;
}
};
const handleExport = async () => {
if (!projectId) return;
try {
setExportLoading(true);
await dispatch(setActiveSegment(activeSegment));
await exportPdf(selectedProject?.name || '', activeSegment);
} catch (error) {
console.error(error);
} finally {
setExportLoading(false);
}
};
const exportPdf = async (projectName: string | null, activeSegment: string | '') => {
if (!exportRef.current) return;
const element = exportRef.current;
const canvas = await html2canvas(element);
const imgData = canvas.toDataURL('image/png');
const pdf = new jsPDF('p', 'mm', 'a4');
const bufferX = 5;
const bufferY = 28;
const imgProps = pdf.getImageProperties(imgData);
const pdfWidth = pdf.internal.pageSize.getWidth() - 2 * bufferX;
const pdfHeight = (imgProps.height * pdfWidth) / imgProps.width;
const logoImg = new Image();
logoImg.src = logo;
logoImg.onload = () => {
pdf.addImage(logoImg, 'PNG', pdf.internal.pageSize.getWidth() / 2 - 12, 5, 30, 6.5);
pdf.setFontSize(14);
pdf.setTextColor(0, 0, 0, 0.85);
pdf.text(
[`Insights - ${projectName} - ${activeSegment}`, format(new Date(), 'yyyy-MM-dd')],
pdf.internal.pageSize.getWidth() / 2,
17,
{ align: 'center' }
);
pdf.addImage(imgData, 'PNG', bufferX, bufferY, pdfWidth, pdfHeight);
pdf.save(`${activeSegment} ${format(new Date(), 'yyyy-MM-dd')}.pdf`);
};
logoImg.onerror = (error) => {
pdf.setFontSize(14);
pdf.setTextColor(0, 0, 0, 0.85);
pdf.text(
[`Insights - ${projectName} - ${activeSegment}`, format(new Date(), 'yyyy-MM-dd')],
pdf.internal.pageSize.getWidth() / 2,
17,
{ align: 'center' }
);
pdf.addImage(imgData, 'PNG', bufferX, bufferY, pdfWidth, pdfHeight);
pdf.save(`${activeSegment} ${format(new Date(), 'yyyy-MM-dd')}.pdf`);
};
};
useEffect(()=>{
if(projectId){
dispatch(setActiveSegment('Overview'));
}
},[refreshTimestamp])
return (
<Flex vertical gap={24}>
<Flex align="center" justify="space-between">
<Segmented
options={['Overview', 'Members', 'Tasks']}
defaultValue={activeSegment}
value={activeSegment}
onChange={handleSegmentChange}
/>
<Flex gap={8}>
<Flex
gap={8}
align="center"
style={{
backgroundColor: themeMode === 'dark' ? '#141414' : '#f5f5f5',
padding: '6px 15px',
borderRadius: 4,
}}
>
<Checkbox checked={includeArchivedTasks} onClick={toggleArchivedTasks} />
<Badge color={includeArchivedTasks ? colors.limeGreen : colors.vibrantOrange} dot>
{t('common.includeArchivedTasks')}
</Badge>
</Flex>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={handleExport}
loading={exportLoading}
>
{t('common.export')}
</Button>
</Flex>
</Flex>
<div ref={exportRef}>
{renderSegmentContent()}
</div>
</Flex>
);
};
export default ProjectViewInsights;

View File

@@ -0,0 +1,295 @@
// Ant Design Components
import {
Avatar,
Button,
Card,
Flex,
Popconfirm,
Progress,
Skeleton,
Table,
TableProps,
Tooltip,
Typography,
} from 'antd';
// Icons
import { DeleteOutlined, ExclamationCircleFilled, SyncOutlined } from '@ant-design/icons';
// React & Router
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
// Services & API
import { projectsApiService } from '@/api/projects/projects.api.service';
import { projectMembersApiService } from '@/api/project-members/project-members.api.service';
import { useAuthService } from '@/hooks/useAuth';
// Types
import { IProjectMembersViewModel, IProjectMemberViewModel } from '@/types/projectMember.types';
// Constants & Utils
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
import { colors } from '../../../../styles/colors';
import logger from '@/utils/errorLogger';
// Components
import EmptyListPlaceholder from '../../../../components/EmptyListPlaceholder';
import { useAppSelector } from '@/hooks/useAppSelector';
import { evt_project_members_visit } from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
interface PaginationType {
current: number;
pageSize: number;
field: string;
order: string;
total: number;
pageSizeOptions: string[];
size: 'small' | 'default';
}
const ProjectViewMembers = () => {
// Hooks
const { projectId } = useParams();
const { t } = useTranslation('project-view-members');
const auth = useAuthService();
const user = auth.getCurrentSession();
const isOwnerOrAdmin = auth.isOwnerOrAdmin();
const { trackMixpanelEvent } = useMixpanelTracking();
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
// State
const [isLoading, setIsLoading] = useState(false);
const [members, setMembers] = useState<IProjectMembersViewModel>();
const [pagination, setPagination] = useState<PaginationType>({
current: 1,
pageSize: DEFAULT_PAGE_SIZE,
field: 'name',
order: 'ascend',
total: 0,
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
size: 'small',
});
// API Functions
const getProjectMembers = async () => {
if (!projectId) return;
setIsLoading(true);
try {
const res = await projectsApiService.getMembers(
projectId,
pagination.current,
pagination.pageSize,
pagination.field,
pagination.order,
null
);
if (res.done) {
setMembers(res.body);
}
} catch (error) {
logger.error('Error fetching members:', error);
} finally {
setIsLoading(false);
}
};
const deleteMember = async (memberId: string | undefined) => {
if (!memberId || !projectId) return;
try {
const res = await projectMembersApiService.deleteProjectMember(memberId, projectId);
if (res.done) {
void getProjectMembers();
}
} catch (error) {
logger.error('Error deleting member:', error);
}
};
// Helper Functions
const checkDisabled = (record: IProjectMemberViewModel): boolean => {
if (!isOwnerOrAdmin) return true;
if (user?.team_member_id === record.team_member_id) return true;
return false;
};
const calculateProgressPercent = (completed: number = 0, total: number = 0): number => {
if (total === 0) return 0;
return Math.floor((completed / total) * 100);
};
const handleTableChange = (pagination: any, filters: any, sorter: any) => {
setPagination({
current: pagination.current,
pageSize: pagination.pageSize,
field: sorter.field || pagination.field,
order: sorter.order || pagination.order,
total: pagination.total,
pageSizeOptions: pagination.pageSizeOptions,
size: pagination.size,
});
};
// Effects
useEffect(() => {
void getProjectMembers();
}, [refreshTimestamp, projectId, pagination.current, pagination.pageSize, pagination.field, pagination.order]);
useEffect(() => {
trackMixpanelEvent(evt_project_members_visit);
}, []);
// Table Configuration
const columns: TableProps['columns'] = [
{
key: 'memberName',
title: t('nameColumn'),
dataIndex: 'name',
sorter: true,
sortOrder: pagination.order === 'ascend' && pagination.field === 'name' ? 'ascend' :
pagination.order === 'descend' && pagination.field === 'name' ? 'descend' : null,
render: (_,record: IProjectMemberViewModel) => (
<Flex gap={8} align="center">
<Avatar size={28} src={record.avatar_url}>
{record.name?.charAt(0)}
</Avatar>
<Typography.Text>{record.name}</Typography.Text>
</Flex>
),
},
{
key: 'jobTitle',
title: t('jobTitleColumn'),
dataIndex: 'job_title',
sorter: true,
sortOrder: pagination.order === 'ascend' && pagination.field === 'job_title' ? 'ascend' :
pagination.order === 'descend' && pagination.field === 'job_title' ? 'descend' : null,
render: (_, record: IProjectMemberViewModel) => (
<Typography.Text style={{ marginInlineStart: 12 }}>
{record?.job_title || '-'}
</Typography.Text>
),
},
{
key: 'email',
title: t('emailColumn'),
dataIndex: 'email',
sorter: true,
sortOrder: pagination.order === 'ascend' && pagination.field === 'email' ? 'ascend' :
pagination.order === 'descend' && pagination.field === 'email' ? 'descend' : null,
render: (_, record: IProjectMemberViewModel) => (
<Typography.Text>{record.email}</Typography.Text>
),
},
{
key: 'tasks',
title: t('tasksColumn'),
width: 90,
render: (_, record: IProjectMemberViewModel) => (
<Typography.Text style={{ marginInlineStart: 12 }}>
{`${record.completed_tasks_count}/${record.all_tasks_count}`}
</Typography.Text>
),
},
{
key: 'taskProgress',
title: t('taskProgressColumn'),
render: (_, record: IProjectMemberViewModel) => (
<Progress
percent={calculateProgressPercent(record.completed_tasks_count, record.all_tasks_count)}
/>
),
},
{
key: 'access',
title: t('accessColumn'),
dataIndex: 'access',
sorter: true,
sortOrder: pagination.order === 'ascend' && pagination.field === 'access' ? 'ascend' :
pagination.order === 'descend' && pagination.field === 'access' ? 'descend' : null,
render: (_, record: IProjectMemberViewModel) => (
<Typography.Text style={{ textTransform: 'capitalize' }}>{record.access}</Typography.Text>
),
},
{
key: 'actionBtns',
width: 80,
render: (record: IProjectMemberViewModel) => (
<Flex gap={8} style={{ padding: 0 }} className="action-buttons">
<Popconfirm
title={t('deleteConfirmationTitle')}
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText={t('deleteConfirmationOk')}
cancelText={t('deleteConfirmationCancel')}
onConfirm={() => deleteMember(record.id)}
>
<Tooltip title={t('deleteButtonTooltip')}>
<Button
disabled={checkDisabled(record)}
shape="default"
icon={<DeleteOutlined />}
size="small"
/>
</Tooltip>
</Popconfirm>
</Flex>
),
},
];
return (
<Card
style={{ width: '100%' }}
title={
<Flex justify="space-between">
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{members?.total} {members?.total !== 1 ? t('membersCountPlural') : t('memberCount')}
</Typography.Text>
<Tooltip title={t('refreshButtonTooltip')}>
<Button
shape="circle"
icon={<SyncOutlined />}
onClick={() => void getProjectMembers()}
/>
</Tooltip>
</Flex>
}
>
{members?.total === 0 ? (
<EmptyListPlaceholder
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
imageHeight={120}
text={t('emptyText')}
/>
) : isLoading ? (
<Skeleton />
) : (
<Table
className="custom-two-colors-row-table"
dataSource={members?.data}
columns={columns}
rowKey={record => record.id}
pagination={{
showSizeChanger: true,
defaultPageSize: 20,
}}
onChange={handleTableChange}
onRow={record => ({
style: {
cursor: 'pointer',
height: 42,
},
})}
/>
)}
</Card>
);
};
export default ProjectViewMembers;

View File

@@ -0,0 +1,309 @@
import {
ArrowLeftOutlined,
BellFilled,
BellOutlined,
CalendarOutlined,
DownOutlined,
EditOutlined,
ImportOutlined,
SaveOutlined,
SettingOutlined,
SyncOutlined,
UsergroupAddOutlined,
} from '@ant-design/icons';
import { PageHeader } from '@ant-design/pro-components';
import { Button, Dropdown, Flex, Tag, Tooltip, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { colors } from '@/styles/colors';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { SocketEvents } from '@/shared/socket-events';
import { useAuthService } from '@/hooks/useAuth';
import { useSocket } from '@/socket/socketContext';
import { setProject, setImportTaskTemplateDrawerOpen, setRefreshTimestamp } from '@features/project/project.slice';
import { addTask, fetchTaskGroups, fetchTaskListColumns, IGroupBy } from '@features/tasks/tasks.slice';
import ProjectStatusIcon from '@/components/common/project-status-icon/project-status-icon';
import { formatDate } from '@/utils/timeUtils';
import { toggleSaveAsTemplateDrawer } from '@/features/projects/projectsSlice';
import SaveProjectAsTemplate from '@/components/save-project-as-template/save-project-as-template';
import {
fetchProjectData,
toggleProjectDrawer,
setProjectId,
} from '@/features/project/project-drawer.slice';
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import { useState } from 'react';
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
import { DEFAULT_TASK_NAME, UNMAPPED } from '@/shared/constants';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { getGroupIdByGroupedColumn } from '@/services/task-list/taskList.service';
import logger from '@/utils/errorLogger';
import { createPortal } from 'react-dom';
import ImportTaskTemplate from '@/components/task-templates/import-task-template';
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
import useIsProjectManager from '@/hooks/useIsProjectManager';
import useTabSearchParam from '@/hooks/useTabSearchParam';
import { addTaskCardToTheTop, fetchBoardTaskGroups } from '@/features/board/board-slice';
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
const ProjectViewHeader = () => {
const navigate = useNavigate();
const { t } = useTranslation('project-view/project-view-header');
const dispatch = useAppDispatch();
const currentSession = useAuthService().getCurrentSession();
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const isProjectManager = useIsProjectManager();
const { tab } = useTabSearchParam();
const { socket } = useSocket();
const {
project: selectedProject,
projectId,
} = useAppSelector(state => state.projectReducer);
const { loadingGroups, groupBy } = useAppSelector(state => state.taskReducer);
const [creatingTask, setCreatingTask] = useState(false);
const handleRefresh = () => {
if (!projectId) return;
switch (tab) {
case 'tasks-list':
dispatch(fetchTaskListColumns(projectId));
dispatch(fetchPhasesByProjectId(projectId))
dispatch(fetchTaskGroups(projectId));
break;
case 'board':
dispatch(fetchBoardTaskGroups(projectId));
break;
case 'project-insights-member-overview':
dispatch(setRefreshTimestamp());
break;
case 'all-attachments':
dispatch(setRefreshTimestamp());
break;
case 'members':
dispatch(setRefreshTimestamp());
break;
case 'updates':
dispatch(setRefreshTimestamp());
break;
default:
break;
}
};
const handleSubscribe = () => {
if (selectedProject?.id) {
const newSubscriptionState = !selectedProject.subscribed;
dispatch(setProject({ ...selectedProject, subscribed: newSubscriptionState }));
socket?.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), {
project_id: selectedProject.id,
user_id: currentSession?.id,
team_member_id: currentSession?.team_member_id,
mode: newSubscriptionState ? 1 : 0,
});
}
};
const handleSettingsClick = () => {
if (selectedProject?.id) {
dispatch(setProjectId(selectedProject.id));
dispatch(fetchProjectData(selectedProject.id));
dispatch(toggleProjectDrawer());
}
};
const handleCreateTask = () => {
try {
setCreatingTask(true);
const body: ITaskCreateRequest = {
name: DEFAULT_TASK_NAME,
project_id: selectedProject?.id,
reporter_id: currentSession?.id,
team_id: currentSession?.team_id,
};
socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => {
if (task.id) {
dispatch(setSelectedTaskId(task.id));
dispatch(setShowTaskDrawer(true));
const groupId = groupBy === IGroupBy.PHASE ? UNMAPPED : getGroupIdByGroupedColumn(task);
if (groupId) {
if (tab === 'board') {
dispatch(addTaskCardToTheTop({ sectionId: groupId, task }));
} else {
dispatch(addTask({ task, groupId }));
}
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
}
}
});
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
} catch (error) {
logger.error('Error creating task', error);
} finally {
setCreatingTask(false);
}
};
const handleImportTaskTemplate = () => {
dispatch(setImportTaskTemplateDrawerOpen(true));
};
const dropdownItems = [
{
key: 'import',
label: (
<div style={{ width: '100%', margin: 0, padding: 0 }} onClick={handleImportTaskTemplate}>
<ImportOutlined /> Import task
</div>
),
},
];
const renderProjectAttributes = () => (
<Flex gap={8} align="center">
{selectedProject?.category_id && (
<Tag color={colors.vibrantOrange} style={{ borderRadius: 24, paddingInline: 8, margin: 0 }}>
{selectedProject.category_name}
</Tag>
)}
{selectedProject?.status && (
<Tooltip title={selectedProject.status}>
<ProjectStatusIcon
iconName={selectedProject.status_icon || ''}
color={selectedProject.status_color || ''}
/>
</Tooltip>
)}
{(selectedProject?.start_date || selectedProject?.end_date) && (
<Tooltip
title={
<Typography.Text style={{ color: colors.white }}>
{selectedProject?.start_date &&
`${t('startDate')}: ${formatDate(new Date(selectedProject.start_date))}`}
{selectedProject?.end_date && (
<>
<br />
{`${t('endDate')}: ${formatDate(new Date(selectedProject.end_date))}`}
</>
)}
</Typography.Text>
}
>
<CalendarOutlined style={{ fontSize: 16 }} />
</Tooltip>
)}
{selectedProject?.notes && (
<Typography.Text type="secondary">{selectedProject.notes}</Typography.Text>
)}
</Flex>
);
const renderHeaderActions = () => (
<Flex gap={8} align="center">
<Tooltip title="Refresh project">
<Button
shape="circle"
icon={<SyncOutlined spin={loadingGroups} />}
onClick={handleRefresh}
/>
</Tooltip>
{(isOwnerOrAdmin) && (
<Tooltip title="Save as template">
<Button
shape="circle"
icon={<SaveOutlined />}
onClick={() => dispatch(toggleSaveAsTemplateDrawer())}
/>
</Tooltip>
)}
<Tooltip title="Project settings">
<Button shape="circle" icon={<SettingOutlined />} onClick={handleSettingsClick} />
</Tooltip>
<Tooltip title={t('subscribe')}>
<Button
shape="round"
icon={selectedProject?.subscribed ? <BellFilled /> : <BellOutlined />}
onClick={handleSubscribe}
>
{selectedProject?.subscribed ? t('unsubscribe') : t('subscribe')}
</Button>
</Tooltip>
{(isOwnerOrAdmin || isProjectManager) && (
<Button
type="primary"
icon={<UsergroupAddOutlined />}
onClick={() => dispatch(toggleProjectMemberDrawer())}
>
Invite
</Button>
)}
{isOwnerOrAdmin ? (
<Dropdown.Button
loading={creatingTask}
type="primary"
icon={<DownOutlined />}
menu={{ items: dropdownItems }}
trigger={['click']}
onClick={handleCreateTask}
>
<EditOutlined /> {t('createTask')}
</Dropdown.Button>
) : (
<Button
loading={creatingTask}
type="primary"
icon={<EditOutlined />}
onClick={handleCreateTask}
>
{t('createTask')}
</Button>
)}
</Flex>
);
return (
<>
<PageHeader
className="site-page-header"
title={
<Flex gap={8} align="center">
<ArrowLeftOutlined
style={{ fontSize: 16 }}
onClick={() => navigate('/worklenz/projects')}
/>
<Typography.Title level={4} style={{ marginBlockEnd: 0, marginInlineStart: 12 }}>
{selectedProject?.name}
</Typography.Title>
{renderProjectAttributes()}
</Flex>
}
style={{ paddingInline: 0, marginBlockEnd: 12 }}
extra={renderHeaderActions()}
/>
{createPortal(<ProjectDrawer onClose={() => { }} />, document.body, 'project-drawer')}
{createPortal(<ImportTaskTemplate />, document.body, 'import-task-template')}
{createPortal(<SaveProjectAsTemplate />, document.body, 'save-project-as-template')}
</>
);
};
export default ProjectViewHeader;

View File

@@ -0,0 +1,6 @@
.profile-badge.ant-badge.ant-badge-status .ant-badge-status-success {
box-shadow: rgb(255, 255, 255) 0px 0px 0px 1px;
background-color: #52c41a;
height: 8px;
width: 8px;
}

View File

@@ -0,0 +1,202 @@
import React, { useEffect, useState } from 'react';
import { PushpinFilled, PushpinOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { Badge, Button, ConfigProvider, Flex, Tabs, TabsProps, Tooltip } from 'antd';
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { createPortal } from 'react-dom';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { getProject, setProjectId, setProjectView } from '@/features/project/project.slice';
import { fetchStatuses, resetStatuses } from '@/features/taskAttributes/taskStatusSlice';
import { projectsApiService } from '@/api/projects/projects.api.service';
import { colors } from '@/styles/colors';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import ProjectViewHeader from './project-view-header';
import './project-view.css';
import { resetTaskListData } from '@/features/tasks/tasks.slice';
import { resetBoardData } from '@/features/board/board-slice';
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
import { tabItems } from '@/lib/project/project-view-constants';
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
const DeleteStatusDrawer = React.lazy(() => import('@/components/project-task-filters/delete-status-drawer/delete-status-drawer'));
const PhaseDrawer = React.lazy(() => import('@features/projects/singleProject/phase/PhaseDrawer'));
const StatusDrawer = React.lazy(
() => import('@/components/project-task-filters/create-status-drawer/create-status-drawer')
);
const ProjectMemberDrawer = React.lazy(
() => import('@/components/projects/project-member-invite-drawer/project-member-invite-drawer')
);
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
const ProjectView = () => {
const location = useLocation();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [searchParams] = useSearchParams();
const { projectId } = useParams();
const selectedProject = useAppSelector(state => state.projectReducer.project);
useDocumentTitle(selectedProject?.name || 'Project View');
const [activeTab, setActiveTab] = useState<string>(searchParams.get('tab') || tabItems[0].key);
const [pinnedTab, setPinnedTab] = useState<string>(searchParams.get('pinned_tab') || '');
const [taskid, setTaskId] = useState<string>(searchParams.get('task') || '');
useEffect(() => {
if (projectId) {
dispatch(setProjectId(projectId));
dispatch(getProject(projectId)).then((res: any) => {
if (!res.payload) {
navigate('/worklenz/projects');
return;
}
dispatch(fetchStatuses(projectId));
dispatch(fetchLabels());
});
}
if (taskid) {
dispatch(setSelectedTaskId(taskid || ''));
dispatch(setShowTaskDrawer(true));
}
}, [dispatch, navigate, projectId, taskid]);
const pinToDefaultTab = async (itemKey: string) => {
if (!itemKey || !projectId) return;
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
const res = await projectsApiService.updateDefaultTab({
project_id: projectId,
default_view: defaultView,
});
if (res.done) {
setPinnedTab(itemKey);
tabItems.forEach(item => {
if (item.key === itemKey) {
item.isPinned = true;
} else {
item.isPinned = false;
}
});
navigate({
pathname: `/worklenz/projects/${projectId}`,
search: new URLSearchParams({
tab: activeTab,
pinned_tab: itemKey
}).toString(),
});
}
};
const handleTabChange = (key: string) => {
setActiveTab(key);
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
navigate({
pathname: location.pathname,
search: new URLSearchParams({
tab: key,
pinned_tab: pinnedTab,
}).toString(),
});
};
const tabMenuItems = tabItems.map(item => ({
key: item.key,
label: (
<Flex align="center" style={{ color: colors.skyBlue }}>
{item.label}
{item.key === 'tasks-list' || item.key === 'board' ? (
<ConfigProvider wave={{ disabled: true }}>
<Button
className="borderless-icon-btn"
style={{
backgroundColor: colors.transparent,
boxShadow: 'none',
}}
icon={
item.key === pinnedTab ? (
<PushpinFilled
size={20}
style={{
color: colors.skyBlue,
rotate: '-45deg',
transition: 'transform ease-in 300ms',
}}
/>
) : (
<PushpinOutlined
size={20}
style={{
color: colors.skyBlue,
}}
/>
)
}
onClick={e => {
e.stopPropagation();
pinToDefaultTab(item.key);
}}
/>
</ConfigProvider>
) : null}
</Flex>
),
children: item.element,
}));
const resetProjectData = () => {
dispatch(setProjectId(null));
dispatch(resetStatuses());
dispatch(deselectAll());
dispatch(resetTaskListData());
dispatch(resetBoardData());
};
useEffect(() => {
return () => {
resetProjectData();
};
}, []);
return (
<div style={{ marginBlockStart: 80, marginBlockEnd: 24, minHeight: '80vh' }}>
<ProjectViewHeader />
<Tabs
activeKey={activeTab}
onChange={handleTabChange}
items={tabMenuItems}
tabBarStyle={{ paddingInline: 0 }}
destroyInactiveTabPane={true}
// tabBarExtraContent={
// <div>
// <span style={{ position: 'relative', top: '-10px' }}>
// <Tooltip title="Members who are active on this project will be displayed here.">
// <QuestionCircleOutlined />
// </Tooltip>
// </span>
// <span
// style={{
// position: 'relative',
// right: '20px',
// top: '10px',
// }}
// >
// <Badge status="success" dot className="profile-badge" />
// </span>
// </div>
// }
/>
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
</div>
);
};
export default ProjectView;

View File

@@ -0,0 +1,245 @@
import { useState, useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import Flex from 'antd/es/flex';
import Badge from 'antd/es/badge';
import Button from 'antd/es/button';
import ConfigProvider from 'antd/es/config-provider';
import Dropdown from 'antd/es/dropdown';
import Input from 'antd/es/input';
import Typography from 'antd/es/typography';
import { MenuProps } from 'antd/es/menu';
import {
DndContext,
closestCenter,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragStartEvent,
} from '@dnd-kit/core';
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
import { colors } from '@/styles/colors';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
import { fetchTaskAssignees, updateTaskAssignees } from '@/features/tasks/tasks.slice';
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
import { SocketEvents } from '@/shared/socket-events';
import logger from '@/utils/errorLogger';
import TaskListTable from '../task-list-table/task-list-table';
import Collapsible from '@/components/collapsible/collapsible';
import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar';
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
import { createPortal } from 'react-dom';
interface TaskGroupListProps {
taskGroups: ITaskListGroup[];
groupBy: string;
}
const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
const [groups, setGroups] = useState(taskGroups);
const [activeId, setActiveId] = useState<string | null>(null);
const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>({});
const [renamingGroup, setRenamingGroup] = useState<string | null>(null);
const [groupNames, setGroupNames] = useState<Record<string, string>>({});
const dispatch = useAppDispatch();
const { socket } = useSocket();
const currentSession = useAuthService().getCurrentSession();
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees);
const { t } = useTranslation('task-list-table');
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
// Initialize expanded state and names for new groups
useEffect(() => {
const newExpandedState = { ...expandedGroups };
const newNames = { ...groupNames };
taskGroups.forEach(group => {
if (!(group.id in newExpandedState)) {
newExpandedState[group.id] = true;
}
if (!(group.id in newNames)) {
newNames[group.id] = group.name;
}
});
setExpandedGroups(newExpandedState);
setGroupNames(newNames);
setGroups(taskGroups);
}, [taskGroups]);
// Socket listener for assignee updates
useEffect(() => {
if (!socket) return;
const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => {
logger.info('change assignees response:- ', data);
if (data) {
const updatedAssignees = data.assignees.map(assignee => ({
...assignee,
selected: true,
}));
const groupId = groups.find(group =>
group.tasks.some(task => task.id === data.id)
)?.id;
if (groupId) {
dispatch(
updateTaskAssignees({
groupId,
taskId: data.id,
assignees: updatedAssignees,
})
);
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, groups]);
const handleDragStart = (event: DragStartEvent) => {
setActiveId(event.active.id as string);
};
const handleDragEnd = (event: DragEndEvent) => {
setActiveId(null);
const { active, over } = event;
if (!over) return;
const activeGroupId = active.data.current?.groupId;
const overGroupId = over.data.current?.groupId;
const activeTaskId = active.id;
const overTaskId = over.id;
setGroups(prevGroups => {
// ... existing drag end logic ...
});
};
const getDropdownItems = (groupId: string): MenuProps['items'] => ([
{
key: '1',
icon: <EditOutlined />,
label: 'Rename',
onClick: () => setRenamingGroup(groupId),
},
{
key: '2',
icon: <RetweetOutlined />,
label: 'Change category',
children: statusCategories?.map(status => ({
key: status.id,
label: (
<Flex gap={8}>
<Badge color={status.color_code} />
{status.name}
</Flex>
),
type: 'group',
})),
},
]);
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<ConfigProvider
wave={{ disabled: true }}
theme={{
components: {
Select: {
colorBorder: colors.transparent,
},
},
}}
>
<Flex gap={24} vertical>
{groups.map(group => (
<div key={group.id}>
<Flex vertical>
<Flex style={{ transform: 'translateY(6px)' }}>
<Button
className="custom-collapse-button"
style={{
backgroundColor: themeMode === 'dark' ? group.color_code_dark : group.color_code,
border: 'none',
borderBottomLeftRadius: expandedGroups[group.id] ? 0 : 4,
borderBottomRightRadius: expandedGroups[group.id] ? 0 : 4,
color: colors.darkGray,
}}
icon={<RightOutlined rotate={expandedGroups[group.id] ? 90 : 0} />}
onClick={() => setExpandedGroups(prev => ({ ...prev, [group.id]: !prev[group.id] }))}
>
{renamingGroup === group.id ? (
<Input
size="small"
value={groupNames[group.id]}
onChange={e => setGroupNames(prev => ({ ...prev, [group.id]: e.target.value }))}
onBlur={() => setRenamingGroup(null)}
onPressEnter={() => setRenamingGroup(null)}
onClick={e => e.stopPropagation()}
autoFocus
/>
) : (
<Typography.Text style={{ fontSize: 14, fontWeight: 600 }}>
{t(groupNames[group.id])} ({group.tasks.length})
</Typography.Text>
)}
</Button>
{groupBy === 'status' && !renamingGroup && (
<Dropdown menu={{ items: getDropdownItems(group.id) }}>
<Button icon={<EllipsisOutlined />} className="borderless-icon-btn" />
</Dropdown>
)}
</Flex>
<Collapsible
isOpen={expandedGroups[group.id]}
className="border-l-[3px] relative after:content after:absolute after:h-full after:w-1 after:z-10 after:top-0 after:left-0 mt-1"
color={themeMode === 'dark' ? group.color_code_dark : group.color_code}
>
<TaskListTable
taskList={group.tasks}
tableId={group.id}
activeId={activeId}
/>
</Collapsible>
</Flex>
</div>
))}
</Flex>
</ConfigProvider>
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
{createPortal(
<TaskTemplateDrawer showDrawer={false} selectedTemplateId={''} onClose={() => {}} />,
document.body,
'task-template-drawer'
)}
</DndContext>
);
};
export default TaskGroupList;

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { TaskPriorityType, TaskType } from '../../../../../../types/task.types';
import { Flex } from 'antd';
import TaskListTableWrapper from '../../task-list-table/task-list-table-wrapper/task-list-table-wrapper';
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
import { getPriorityColor } from '../../../../../../utils/getPriorityColors';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
const PriorityGroupTables = ({ datasource }: { datasource: IProjectTask[] }) => {
const priorityList: { id: string; name: string }[] = [
{
id: 'high',
name: 'high',
},
{
id: 'medium',
name: 'medium',
},
{
id: 'low',
name: 'low',
},
];
const themeMode = useAppSelector(state => state.themeReducer.mode);
return (
<Flex gap={24} vertical>
{priorityList.map((priority, index) => (
<TaskListTableWrapper
key={index}
taskList={datasource.filter(task => task.priority === priority.name)}
tableId={priority.id}
name={priority.name}
groupBy="priority"
priorityCategory={priority.name}
color={getPriorityColor(priority.name as TaskPriorityType, themeMode)}
/>
))}
</Flex>
);
};
export default PriorityGroupTables;

View File

@@ -0,0 +1,68 @@
import { useEffect } from 'react';
import Flex from 'antd/es/flex';
import Skeleton from 'antd/es/skeleton';
import { useSearchParams } from 'react-router-dom';
import TaskListFilters from './task-list-filters/task-list-filters';
import TaskGroupWrapper from './task-list-table/task-group-wrapper/task-group-wrapper';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { fetchTaskGroups, fetchTaskListColumns } from '@/features/tasks/tasks.slice';
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
import { Empty } from 'antd';
import useTabSearchParam from '@/hooks/useTabSearchParam';
const ProjectViewTaskList = () => {
const dispatch = useAppDispatch();
const { projectView } = useTabSearchParam();
const [searchParams, setSearchParams] = useSearchParams();
const { projectId } = useAppSelector(state => state.projectReducer);
const { taskGroups, loadingGroups, groupBy, archived, fields, search } = useAppSelector(
state => state.taskReducer
);
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
state => state.taskStatusReducer
);
const { loadingPhases } = useAppSelector(state => state.phaseReducer);
const { loadingColumns } = useAppSelector(state => state.taskReducer);
useEffect(() => {
// Set default view to list if projectView is not list or board
if (projectView !== 'list' && projectView !== 'board') {
searchParams.set('tab', 'tasks-list');
searchParams.set('pinned_tab', 'tasks-list');
setSearchParams(searchParams);
}
}, [projectView, searchParams, setSearchParams]);
useEffect(() => {
if (projectId && groupBy) {
if (!loadingColumns) dispatch(fetchTaskListColumns(projectId));
if (!loadingPhases) dispatch(fetchPhasesByProjectId(projectId));
if (!loadingGroups && projectView === 'list') {
dispatch(fetchTaskGroups(projectId));
}
}
if (!statusCategories.length) {
dispatch(fetchStatusesCategories());
}
}, [dispatch, projectId, groupBy, fields, search, archived]);
return (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
<TaskListFilters position="list" />
{(taskGroups.length === 0 && !loadingGroups) ? (
<Empty description="No tasks group found" />
) : (
<Skeleton active loading={loadingGroups} className='mt-4 p-4'>
<TaskGroupWrapper taskGroups={taskGroups} groupBy={groupBy} />
</Skeleton>
)}
</Flex>
);
};
export default ProjectViewTaskList;

View File

@@ -0,0 +1,85 @@
import React, { useEffect } from 'react';
import Flex from 'antd/es/flex';
import Checkbox from 'antd/es/checkbox';
import Typography from 'antd/es/typography';
import { useTranslation } from 'react-i18next';
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
fetchLabelsByProject,
fetchTaskAssignees,
toggleArchived,
} from '@/features/tasks/tasks.slice';
import { getTeamMembers } from '@/features/team-members/team-members.slice';
import useTabSearchParam from '@/hooks/useTabSearchParam';
const SearchDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/search-dropdown'));
const SortFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/sort-filter-dropdown'));
const LabelsFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/labels-filter-dropdown'));
const MembersFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/members-filter-dropdown'));
const GroupByFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/group-by-filter-dropdown'));
const ShowFieldsFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/show-fields-filter-dropdown'));
const PriorityFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/priority-filter-dropdown'));
interface TaskListFiltersProps {
position: 'board' | 'list';
}
const TaskListFilters: React.FC<TaskListFiltersProps> = ({ position }) => {
const { t } = useTranslation('task-list-filters');
const dispatch = useAppDispatch();
const { projectView } = useTabSearchParam();
const priorities = useAppSelector(state => state.priorityReducer.priorities);
const projectId = useAppSelector(state => state.projectReducer.projectId);
const archived = useAppSelector(state => state.taskReducer.archived);
const handleShowArchivedChange = () => dispatch(toggleArchived());
useEffect(() => {
const fetchInitialData = async () => {
if (!priorities.length) await dispatch(fetchPriorities());
if (projectId) {
await dispatch(fetchLabelsByProject(projectId));
await dispatch(fetchTaskAssignees(projectId));
}
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
};
fetchInitialData();
}, [dispatch, priorities.length, projectId]);
return (
<Flex gap={8} align="center" justify="space-between">
<Flex gap={8} wrap={'wrap'}>
<SearchDropdown />
{projectView === 'list' && <SortFilterDropdown />}
<PriorityFilterDropdown priorities={priorities} />
<LabelsFilterDropdown />
<MembersFilterDropdown />
<GroupByFilterDropdown />
</Flex>
{position === 'list' && (
<Flex gap={12} wrap={'wrap'}>
<Flex
gap={4}
align="center"
style={{ cursor: 'pointer' }}
onClick={handleShowArchivedChange}
>
<Checkbox checked={archived} />
<Typography.Text>{t('showArchivedText')}</Typography.Text>
</Flex>
{/* show fields dropdown */}
<ShowFieldsFilterDropdown />
</Flex>
)}
</Flex>
);
};
export default TaskListFilters;

View File

@@ -0,0 +1,321 @@
import {
DeleteOutlined,
DoubleRightOutlined,
InboxOutlined,
LoadingOutlined,
RetweetOutlined,
UserAddOutlined,
} from '@ant-design/icons';
import { Badge, Dropdown, Flex, Typography, Modal } from 'antd';
import { MenuProps } from 'antd/lib';
import { useState } from 'react';
import { TFunction } from 'i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
import { IBulkAssignRequest } from '@/types/tasks/bulk-action-bar.types';
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 {
deleteTask,
fetchTaskAssignees,
fetchTaskGroups,
IGroupBy,
setConvertToSubtaskDrawerOpen,
updateTaskAssignees,
} from '@/features/tasks/tasks.slice';
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
import { useAuthService } from '@/hooks/useAuth';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import logger from '@/utils/errorLogger';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { tasksApiService } from '@/api/tasks/tasks.api.service';
type TaskContextMenuProps = {
visible: boolean;
position: { x: number; y: number };
selectedTask: IProjectTask;
onClose: () => void;
t: TFunction;
};
const TaskContextMenu = ({ visible, position, selectedTask, onClose, t }: TaskContextMenuProps) => {
const statusList = useAppSelector(state => state.taskStatusReducer.status);
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
const { socket } = useSocket();
const dispatch = useAppDispatch();
const { trackMixpanelEvent } = useMixpanelTracking();
const currentSession = useAuthService().getCurrentSession();
const { projectId } = useAppSelector(state => state.projectReducer);
const { taskGroups, archived, groupBy } = useAppSelector(state => state.taskReducer);
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
const handleAssignToMe = async () => {
if (!projectId || !selectedTask.id) return;
try {
setUpdatingAssignToMe(true);
const body: IBulkAssignRequest = {
tasks: [selectedTask.id],
project_id: projectId,
};
const res = await taskListBulkActionsApiService.assignToMe(body);
if (res.done) {
const { id: taskId, assignees } = res.body;
trackMixpanelEvent(evt_project_task_list_context_menu_assign_me);
const groupId = taskGroups.find(group =>
group.tasks.some(task =>
task.id === taskId ||
task.sub_tasks?.some(subtask => subtask.id === taskId)
)
)?.id;
if (groupId) {
dispatch(
updateTaskAssignees({
groupId,
taskId,
assignees,
})
);
if (currentSession?.team_id) {
dispatch(fetchTaskAssignees(currentSession.team_id));
}
}
}
} catch (error) {
console.error(error);
} finally {
setUpdatingAssignToMe(false);
}
};
const handleArchive = async () => {
if (!projectId || !selectedTask.id) return;
try {
const res = await taskListBulkActionsApiService.archiveTasks(
{
tasks: [selectedTask.id],
project_id: projectId,
},
archived ? true : false
);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_context_menu_archive);
dispatch(deleteTask({ taskId: selectedTask.id }));
dispatch(deselectAll());
if (selectedTask.parent_task_id) socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask.parent_task_id);
}
} catch (error) {
console.error(error);
}
};
const handleDelete = async () => {
if (!projectId || !selectedTask.id) return;
try {
const res = await taskListBulkActionsApiService.deleteTasks(
{ tasks: [selectedTask.id] },
projectId
);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_context_menu_delete);
dispatch(deleteTask({ taskId: selectedTask.id }));
dispatch(deselectAll());
if (selectedTask.parent_task_id) socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask.parent_task_id);
}
} catch (error) {
console.error(error);
}
};
const handleStatusMoveTo = async (targetId: string | undefined) => {
if (!projectId || !selectedTask.id || !targetId) return;
try {
socket?.emit(
SocketEvents.TASK_STATUS_CHANGE.toString(),
JSON.stringify({
task_id: selectedTask.id,
status_id: targetId,
parent_task: selectedTask.parent_task_id || null,
team_id: currentSession?.team_id,
})
);
} catch (error) {
logger.error('Error moving status', error);
}
};
const handlePriorityMoveTo = async (targetId: string | undefined) => {
if (!projectId || !selectedTask.id || !targetId) return;
try {
socket?.emit(
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
JSON.stringify({
task_id: selectedTask.id,
priority_id: targetId,
parent_task: selectedTask.parent_task_id || null,
team_id: currentSession?.team_id,
})
);
} catch (error) {
logger.error('Error moving priority', error);
}
};
const handlePhaseMoveTo = async (targetId: string | undefined) => {
if (!projectId || !selectedTask.id || !targetId) return;
try {
socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
task_id: selectedTask.id,
phase_id: targetId,
parent_task: selectedTask.parent_task_id || null,
team_id: currentSession?.team_id,
});
} catch (error) {
logger.error('Error moving phase', error);
}
};
const getMoveToOptions = () => {
if (groupBy === IGroupBy.STATUS) {
return statusList?.map(status => ({
key: status.id,
label: (
<Flex align="center" gap={8}>
<Badge color={status.color_code} />
<Typography.Text>{status.name}</Typography.Text>
</Flex>
),
onClick: () => handleStatusMoveTo(status.id),
}));
}
if (groupBy === IGroupBy.PRIORITY) {
return priorityList?.map(priority => ({
key: priority.id,
label: (
<Flex align="center" gap={8}>
<Badge color={priority.color_code} />
<Typography.Text>{priority.name}</Typography.Text>
</Flex>
),
onClick: () => handlePriorityMoveTo(priority.id),
}));
}
if (groupBy === IGroupBy.PHASE) {
return phaseList?.map(phase => ({
key: phase.id,
label: (
<Flex align="center" gap={8}>
<Badge color={phase.color_code} />
<Typography.Text>{phase.name}</Typography.Text>
</Flex>
),
onClick: () => handlePhaseMoveTo(phase.id),
}));
}
return [];
};
const handleConvertToTask = async () => {
if (!selectedTask?.id || !projectId) return;
try {
const res = await tasksApiService.convertToTask(
selectedTask.id as string,
projectId as string
);
if (res.done) {
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
}
} catch (error) {
logger.error('Error converting to task', error);
}
};
const items: MenuProps['items'] = [
{
key: '1',
icon: updatingAssignToMe ? <LoadingOutlined /> : <UserAddOutlined />,
label: t('contextMenu.assignToMe'),
onClick: handleAssignToMe,
disabled: updatingAssignToMe,
},
{
key: '2',
icon: <RetweetOutlined />,
label: t('contextMenu.moveTo'),
children: getMoveToOptions(),
},
...(!selectedTask?.parent_task_id ? [
{
key: '3',
icon: <InboxOutlined />,
label: archived ? t('contextMenu.unarchive' ) : t('contextMenu.archive'),
onClick: handleArchive,
},
] : []),
...(selectedTask?.sub_tasks_count === 0 && !selectedTask?.parent_task_id
? [
{
key: '4',
icon: <DoubleRightOutlined />,
label: t('contextMenu.convertToSubTask'),
onClick: () => dispatch(setConvertToSubtaskDrawerOpen(true)),
},
]
: []),
...(selectedTask?.parent_task_id
? [
{
key: '5',
icon: <DoubleRightOutlined />,
label: t('contextMenu.convertToTask'),
onClick: () => {
handleConvertToTask();
},
},
]
: []),
{
key: '6',
icon: <DeleteOutlined />,
label: t('contextMenu.delete'),
onClick: handleDelete,
},
];
return visible ? (
<Dropdown menu={{ items }} trigger={['contextMenu']} open={visible} onOpenChange={onClose}>
<div
style={{
position: 'fixed',
top: position.y,
left: position.x,
zIndex: 1000,
width: 1,
height: 1,
}}
></div>
</Dropdown>
) : null;
};
export default TaskContextMenu;

View File

@@ -0,0 +1,19 @@
.custom-column-label-dropdown .ant-dropdown-menu {
padding: 0 !important;
margin-top: 8px !important;
overflow: hidden;
}
.custom-column-label-dropdown .ant-dropdown-menu-item {
padding: 0 !important;
}
.custom-column-label-dropdown-card .ant-card-body {
padding: 0 !important;
}
.custom-column-label-menu .ant-menu-item {
display: flex;
align-items: center;
height: 32px;
}

View File

@@ -0,0 +1,127 @@
import { Badge, Card, Dropdown, Empty, Flex, Menu, MenuProps, Typography } from 'antd';
import React, { useState, useEffect } from 'react';
import { DownOutlined } from '@ant-design/icons';
// custom css file
import './custom-column-label-cell.css';
import { useTranslation } from 'react-i18next';
import { colors } from '../../../../../../../../styles/colors';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
const CustomColumnLabelCell = ({
labelsList,
selectedLabels = [],
onChange
}: {
labelsList: ITaskLabel[],
selectedLabels?: string[],
onChange?: (labels: string[]) => void
}) => {
const [currentLabelOption, setCurrentLabelOption] = useState<ITaskLabel | null>(null);
// localization
const { t } = useTranslation('task-list-table');
// Set initial selection based on selectedLabels prop
useEffect(() => {
if (selectedLabels && selectedLabels.length > 0 && labelsList.length > 0) {
const selectedLabel = labelsList.find(label => label.id && selectedLabels.includes(label.id));
if (selectedLabel) {
setCurrentLabelOption(selectedLabel);
}
}
}, [selectedLabels, labelsList]);
// ensure labelsList is an array and has valid data
const labelMenuItems: MenuProps['items'] =
Array.isArray(labelsList) && labelsList.length > 0
? labelsList
.filter(label => label.id) // Filter out items without an id
.map(label => ({
key: label.id as string, // Assert that id is a string
label: (
<Flex gap={4}>
<Badge color={label.color_code} /> {label.name}
</Flex>
),
type: 'item' as const,
}))
: [
{
key: 'noLabels',
label: <Empty />,
},
];
// handle label selection
const handleLabelOptionSelect: MenuProps['onClick'] = e => {
const selectedOption = labelsList.find(option => option.id === e.key);
if (selectedOption && selectedOption.id) {
setCurrentLabelOption(selectedOption);
// Call the onChange callback if provided
if (onChange) {
onChange([selectedOption.id]);
}
}
};
// dropdown items
const customColumnLabelCellItems: MenuProps['items'] = [
{
key: '1',
label: (
<Card className="custom-column-label-dropdown-card" bordered={false}>
<Menu
className="custom-column-label-menu"
items={labelMenuItems}
onClick={handleLabelOptionSelect}
/>
</Card>
),
},
];
return (
<Dropdown
overlayClassName="custom-column-label-dropdown"
menu={{ items: customColumnLabelCellItems }}
placement="bottomRight"
trigger={['click']}
>
<Flex
gap={6}
align="center"
justify="space-between"
style={{
width: 'fit-content',
borderRadius: 24,
paddingInline: 8,
height: 22,
fontSize: 13,
backgroundColor: currentLabelOption?.color_code || colors.transparent,
color: colors.darkGray,
cursor: 'pointer',
}}
>
{currentLabelOption ? (
<Typography.Text
ellipsis={{ expanded: false }}
style={{
textTransform: 'capitalize',
fontSize: 13,
}}
>
{currentLabelOption?.name}
</Typography.Text>
) : (
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{t('selectText')}
</Typography.Text>
)}
<DownOutlined style={{ fontSize: 12 }} />
</Flex>
</Dropdown>
);
};
export default CustomColumnLabelCell;

View File

@@ -0,0 +1,19 @@
.custom-column-selection-dropdown .ant-dropdown-menu {
padding: 0 !important;
margin-top: 8px !important;
overflow: hidden;
}
.custom-column-selection-dropdown .ant-dropdown-menu-item {
padding: 0 !important;
}
.custom-column-selection-dropdown-card .ant-card-body {
padding: 0 !important;
}
.custom-column-selection-menu .ant-menu-item {
display: flex;
align-items: center;
height: 32px;
}

View File

@@ -0,0 +1,135 @@
import { Badge, Card, Dropdown, Empty, Flex, Menu, MenuProps, Typography } from 'antd';
import React, { useState, useEffect } from 'react';
import { DownOutlined } from '@ant-design/icons';
// custom css file
import './custom-column-selection-cell.css';
import { useTranslation } from 'react-i18next';
import { colors } from '../../../../../../../../styles/colors';
import { SelectionType } from '../../custom-column-modal/selection-type-column/selection-type-column';
import { ALPHA_CHANNEL } from '@/shared/constants';
const CustomColumnSelectionCell = ({
selectionsList,
value,
onChange
}: {
selectionsList: SelectionType[],
value?: string,
onChange?: (value: string) => void
}) => {
const [currentSelectionOption, setCurrentSelectionOption] = useState<SelectionType | null>(null);
// localization
const { t } = useTranslation('task-list-table');
// Debug the selectionsList and value
console.log('CustomColumnSelectionCell props:', {
selectionsList,
value,
selectionsCount: selectionsList?.length || 0
});
// Set initial selection based on value prop
useEffect(() => {
if (value && Array.isArray(selectionsList) && selectionsList.length > 0) {
const selectedOption = selectionsList.find(option => option.selection_id === value);
console.log('Found selected option:', selectedOption);
if (selectedOption) {
setCurrentSelectionOption(selectedOption);
}
}
}, [value, selectionsList]);
// ensure selectionsList is an array and has valid data
const selectionMenuItems: MenuProps['items'] =
Array.isArray(selectionsList) && selectionsList.length > 0
? selectionsList.map(selection => ({
key: selection.selection_id,
label: (
<Flex gap={4}>
<Badge color={selection.selection_color + ALPHA_CHANNEL} /> {selection.selection_name}
</Flex>
),
}))
: [
{
key: 'noSelections',
label: <Empty description="No selections available" />,
},
];
// handle selection selection
const handleSelectionOptionSelect: MenuProps['onClick'] = e => {
if (e.key === 'noSelections') return;
const selectedOption = selectionsList.find(option => option.selection_id === e.key);
if (selectedOption) {
setCurrentSelectionOption(selectedOption);
// Call the onChange callback if provided
if (onChange) {
onChange(selectedOption.selection_id);
}
}
};
// dropdown items
const customColumnSelectionCellItems: MenuProps['items'] = [
{
key: '1',
label: (
<Card className="custom-column-selection-dropdown-card" variant="borderless">
<Menu
className="custom-column-selection-menu"
items={selectionMenuItems}
onClick={handleSelectionOptionSelect}
/>
</Card>
),
},
];
return (
<Dropdown
overlayClassName="custom-column-selection-dropdown"
menu={{ items: customColumnSelectionCellItems }}
placement="bottomRight"
trigger={['click']}
>
<Flex
gap={6}
align="center"
justify="space-between"
style={{
width: 'fit-content',
borderRadius: 24,
paddingInline: 8,
height: 22,
fontSize: 13,
backgroundColor: currentSelectionOption?.selection_color + ALPHA_CHANNEL || colors.transparent,
color: colors.darkGray,
cursor: 'pointer',
}}
>
{currentSelectionOption ? (
<Typography.Text
ellipsis={{ expanded: false }}
style={{
textTransform: 'capitalize',
fontSize: 13,
}}
>
{currentSelectionOption?.selection_name}
</Typography.Text>
) : (
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{t('selectText')}
</Typography.Text>
)}
<DownOutlined style={{ fontSize: 12 }} />
</Flex>
</Dropdown>
);
};
export default CustomColumnSelectionCell;

View File

@@ -0,0 +1,54 @@
import { SettingOutlined } from '@ant-design/icons';
import { Button, Flex, Tooltip, Typography } from 'antd';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import CustomColumnModal from '../custom-column-modal/custom-column-modal';
type CustomColumnHeaderProps = {
columnKey: string;
columnName: string;
};
const CustomColumnHeader = ({ columnKey, columnName }: CustomColumnHeaderProps) => {
const [isModalOpen, setIsModalOpen] = useState<boolean>(false);
// localization
const { t } = useTranslation('task-list-table');
// function to open modal
const handleModalOpen = () => {
setIsModalOpen(true);
};
// fuction to handle cancel
const handleCancel = () => {
setIsModalOpen(false);
};
return (
<Flex gap={8} align="center" justify="space-between">
<Typography.Text ellipsis={{ expanded: false }}>{columnName}</Typography.Text>
<Tooltip title={t('editTooltip')}>
<Button
icon={<SettingOutlined />}
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
fontSize: 12,
}}
onClick={handleModalOpen}
/>
</Tooltip>
{/* <CustomColumnModal
modalType="edit"
isModalOpen={isModalOpen}
handleCancel={handleCancel}
columnId={columnKey}
/> */}
</Flex>
);
};
export default CustomColumnHeader;

View File

@@ -0,0 +1,34 @@
import { PlusOutlined } from '@ant-design/icons';
import { Button, Tooltip } from 'antd';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
setCustomColumnModalAttributes,
toggleCustomColumnModalOpen,
} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
const AddCustomColumnButton = () => {
const dispatch = useAppDispatch();
const handleModalOpen = () => {
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
dispatch(toggleCustomColumnModalOpen(true));
};
return (
<>
<Tooltip title={'Add a custom column'}>
<Button
icon={<PlusOutlined />}
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
}}
onClick={handleModalOpen}
/>
</Tooltip>
</>
);
};
export default AddCustomColumnButton;

View File

@@ -0,0 +1,497 @@
import { Button, Divider, Flex, Form, Input, message, Modal, Select, Typography, Popconfirm } from 'antd';
import SelectionTypeColumn from './selection-type-column/selection-type-column';
import NumberTypeColumn from './number-type-column/number-type-column';
import LabelTypeColumn from './label-type-column/label-type-column';
import FormulaTypeColumn from './formula-type-column/formula-type-column';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
CustomFieldNumberTypes,
CustomFieldsTypes,
setCustomColumnModalAttributes,
setCustomFieldType,
toggleCustomColumnModalOpen,
setCustomFieldNumberType,
setDecimals,
setLabel,
setLabelPosition,
setExpression,
setFirstNumericColumn,
setSecondNumericColumn,
setSelectionsList,
setLabelsList,
} from '@features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
import CustomColumnHeader from '../custom-column-header/custom-column-header';
import { nanoid } from '@reduxjs/toolkit';
import {
CustomTableColumnsType,
deleteCustomColumn as deleteCustomColumnFromColumns,
} from '@features/projects/singleProject/taskListColumns/taskColumnsSlice';
import { themeWiseColor } from '@/utils/themeWiseColor';
import KeyTypeColumn from './key-type-column/key-type-column';
import logger from '@/utils/errorLogger';
import { addCustomColumn, deleteCustomColumn as deleteCustomColumnFromTasks } from '@/features/tasks/tasks.slice';
import { useParams } from 'react-router-dom';
import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service';
import { ExclamationCircleFilled } from '@ant-design/icons';
const CustomColumnModal = () => {
const [mainForm] = Form.useForm();
const { projectId } = useParams();
// get theme details from theme reducer
const themeMode = useAppSelector(state => state.themeReducer.mode);
const dispatch = useAppDispatch();
const {
customColumnId,
customColumnModalType,
isCustomColumnModalOpen,
decimals,
label,
labelPosition,
previewValue,
expression,
firstNumericColumn,
secondNumericColumn,
labelsList,
selectionsList,
customFieldType,
} = useAppSelector(state => state.taskListCustomColumnsReducer);
// get initial data from task list custom column slice
const fieldType: CustomFieldsTypes = useAppSelector(
state => state.taskListCustomColumnsReducer.customFieldType
);
// number column initial data
const numberType: CustomFieldNumberTypes = useAppSelector(
state => state.taskListCustomColumnsReducer.customFieldNumberType
);
// if it is already created column get the column data
const openedColumn = useAppSelector(state => state.taskReducer.customColumns).find(
col => col.id === customColumnId
);
// Function to handle deleting a custom column
const handleDeleteColumn = async () => {
if (!customColumnId) return;
try {
// Make API request to delete the custom column using the service
await tasksCustomColumnsService.deleteCustomColumn(openedColumn?.id || customColumnId);
// Dispatch actions to update the Redux store
dispatch(deleteCustomColumnFromTasks(customColumnId));
dispatch(deleteCustomColumnFromColumns(customColumnId));
// Close the modal
dispatch(toggleCustomColumnModalOpen(false));
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
// Show success message
message.success('Custom column deleted successfully');
// Reload the page to reflect the changes
window.location.reload();
} catch (error) {
logger.error('Error deleting custom column:', error);
message.error('Failed to delete custom column');
}
};
const fieldTypesOptions = [
{
key: 'people',
value: 'people',
label: 'People',
disabled: false,
},
{
key: 'number',
value: 'number',
label: 'Number',
disabled: false,
},
{
key: 'date',
value: 'date',
label: 'Date',
disabled: false,
},
{
key: 'selection',
value: 'selection',
label: 'Selection',
disabled: false,
},
{
key: 'checkbox',
value: 'checkbox',
label: 'Checkbox',
disabled: true,
},
{
key: 'labels',
value: 'labels',
label: 'Labels',
disabled: true,
},
{
key: 'key',
value: 'key',
label: 'Key',
disabled: true,
},
{
key: 'formula',
value: 'formula',
label: 'Formula',
disabled: true,
},
];
// function to handle form submit
const handleFormSubmit = async (value: any) => {
try {
if (customColumnModalType === 'create') {
const columnKey = nanoid(); // this id is random and unique, generated by redux
const newColumn: CustomTableColumnsType = {
key: columnKey,
name: value.fieldTitle,
columnHeader: <CustomColumnHeader columnKey={columnKey} columnName={value.fieldTitle} />,
width: 120,
isVisible: true,
custom_column: true,
custom_column_obj: {
...value,
labelsList: value.fieldType === 'labels' ? labelsList : [],
selectionsList: value.fieldType === 'selection' ? selectionsList : [],
},
};
// Prepare the configuration object
const configuration = {
field_title: value.fieldTitle,
field_type: value.fieldType,
number_type: value.numberType,
decimals: value.decimals,
label: value.label,
label_position: value.labelPosition,
preview_value: value.previewValue,
expression: value.expression,
first_numeric_column_key: value.firstNumericColumn?.key,
second_numeric_column_key: value.secondNumericColumn?.key,
selections_list:
value.fieldType === 'selection'
? selectionsList.map((selection, index) => ({
selection_id: selection.selection_id,
selection_name: selection.selection_name,
selection_color: selection.selection_color,
selection_order: index,
}))
: [],
labels_list:
value.fieldType === 'labels'
? labelsList.map((label, index) => ({
label_id: label.label_id,
label_name: label.label_name,
label_color: label.label_color,
label_order: index,
}))
: [],
};
// Make API request to create custom column using the service
try {
const res = await tasksCustomColumnsService.createCustomColumn(projectId || '', {
name: value.fieldTitle,
key: columnKey,
field_type: value.fieldType,
width: 120,
is_visible: true,
configuration
});
if (res.done) {
if (res.body.id) newColumn.id = res.body.id;
dispatch(addCustomColumn(newColumn));
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
dispatch(toggleCustomColumnModalOpen(false));
}
} catch (error) {
logger.error('Error creating custom column:', error);
message.error('Failed to create custom column');
}
} else if (customColumnModalType === 'edit' && customColumnId) {
const updatedColumn = openedColumn
? {
...openedColumn,
name: value.fieldTitle,
columnHeader: (
<CustomColumnHeader columnKey={customColumnId} columnName={value.fieldTitle} />
),
custom_column_obj: {
...openedColumn.custom_column_obj,
fieldTitle: value.fieldTitle,
fieldType: value.fieldType,
numberType: value.numberType,
decimals: value.decimals,
label: value.label,
labelPosition: value.labelPosition,
previewValue: value.previewValue,
expression: value.expression,
firstNumericColumn: value.firstNumericColumn,
secondNumericColumn: value.secondNumericColumn,
labelsList: value.fieldType === 'labels' ? labelsList : [],
selectionsList: value.fieldType === 'selection' ? selectionsList : [],
},
}
: null;
if (updatedColumn) {
try {
// Prepare the configuration object
const configuration = {
field_title: value.fieldTitle,
field_type: value.fieldType,
number_type: value.numberType,
decimals: value.decimals,
label: value.label,
label_position: value.labelPosition,
preview_value: value.previewValue,
expression: value.expression,
first_numeric_column_key: value.firstNumericColumn?.key,
second_numeric_column_key: value.secondNumericColumn?.key,
selections_list:
value.fieldType === 'selection'
? selectionsList.map((selection, index) => ({
selection_id: selection.selection_id,
selection_name: selection.selection_name,
selection_color: selection.selection_color,
selection_order: index,
}))
: [],
labels_list:
value.fieldType === 'labels'
? labelsList.map((label, index) => ({
label_id: label.label_id,
label_name: label.label_name,
label_color: label.label_color,
label_order: index,
}))
: [],
};
// Make API request to update custom column using the service
await tasksCustomColumnsService.updateCustomColumn(openedColumn?.id || customColumnId, {
name: value.fieldTitle,
field_type: value.fieldType,
width: 150,
is_visible: true,
configuration
});
// Close modal
dispatch(toggleCustomColumnModalOpen(false));
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
// Reload the page instead of updating the slice
window.location.reload();
} catch (error) {
logger.error('Error updating custom column:', error);
message.error('Failed to update custom column');
}
}
}
mainForm.resetFields();
} catch (error) {
logger.error('error in custom column modal', error);
}
};
return (
<Modal
title={customColumnModalType === 'create' ? 'Add field' : 'Edit field'}
centered
open={isCustomColumnModalOpen}
onCancel={() => {
dispatch(toggleCustomColumnModalOpen(false));
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
}}
styles={{
header: { position: 'relative' },
footer: { display: 'none' },
}}
onClose={() => {
mainForm.resetFields();
}}
afterOpenChange={open => {
if (open && customColumnModalType === 'edit' && openedColumn) {
// Set the field type first so the correct form fields are displayed
dispatch(setCustomFieldType(openedColumn.custom_column_obj?.fieldType || 'people'));
// Set other field values based on the custom column type
if (openedColumn.custom_column_obj?.fieldType === 'number') {
dispatch(setCustomFieldNumberType(openedColumn.custom_column_obj?.numberType || 'formatted'));
dispatch(setDecimals(openedColumn.custom_column_obj?.decimals || 0));
dispatch(setLabel(openedColumn.custom_column_obj?.label || ''));
dispatch(setLabelPosition(openedColumn.custom_column_obj?.labelPosition || 'left'));
} else if (openedColumn.custom_column_obj?.fieldType === 'formula') {
dispatch(setExpression(openedColumn.custom_column_obj?.expression || 'add'));
dispatch(setFirstNumericColumn(openedColumn.custom_column_obj?.firstNumericColumn || null));
dispatch(setSecondNumericColumn(openedColumn.custom_column_obj?.secondNumericColumn || null));
} else if (openedColumn.custom_column_obj?.fieldType === 'selection') {
// Directly set the selections list in the Redux store
if (Array.isArray(openedColumn.custom_column_obj?.selectionsList)) {
console.log('Setting selections list:', openedColumn.custom_column_obj.selectionsList);
dispatch(setSelectionsList(openedColumn.custom_column_obj.selectionsList));
}
} else if (openedColumn.custom_column_obj?.fieldType === 'labels') {
// Directly set the labels list in the Redux store
if (Array.isArray(openedColumn.custom_column_obj?.labelsList)) {
console.log('Setting labels list:', openedColumn.custom_column_obj.labelsList);
dispatch(setLabelsList(openedColumn.custom_column_obj.labelsList));
}
}
// Set form values
mainForm.setFieldsValue({
fieldTitle: openedColumn.name || openedColumn.custom_column_obj?.fieldTitle,
fieldType: openedColumn.custom_column_obj?.fieldType,
numberType: openedColumn.custom_column_obj?.numberType,
decimals: openedColumn.custom_column_obj?.decimals,
label: openedColumn.custom_column_obj?.label,
labelPosition: openedColumn.custom_column_obj?.labelPosition,
previewValue: openedColumn.custom_column_obj?.previewValue,
expression: openedColumn.custom_column_obj?.expression,
firstNumericColumn: openedColumn.custom_column_obj?.firstNumericColumn,
secondNumericColumn: openedColumn.custom_column_obj?.secondNumericColumn,
});
} else if (open && customColumnModalType === 'create') {
// Reset form for create mode
mainForm.resetFields();
dispatch(setCustomFieldType('people'));
}
}}
>
<Divider style={{ position: 'absolute', left: 0, top: 32 }} />
<Form
form={mainForm}
layout="vertical"
onFinish={handleFormSubmit}
style={{ marginBlockStart: 24 }}
initialValues={
customColumnModalType === 'create'
? {
fieldType,
numberType,
decimals,
label,
labelPosition,
previewValue,
expression,
firstNumericColumn,
secondNumericColumn,
}
: {
fieldTitle: openedColumn?.custom_column_obj.fieldTitle,
fieldType: openedColumn?.custom_column_obj.fieldType,
numberType: openedColumn?.custom_column_obj.numberType,
decimals: openedColumn?.custom_column_obj.decimals,
label: openedColumn?.custom_column_obj.label,
labelPosition: openedColumn?.custom_column_obj.labelPosition,
previewValue: openedColumn?.custom_column_obj.previewValue,
expression: openedColumn?.custom_column_obj.expression,
firstNumericColumn: openedColumn?.custom_column_obj.firstNumericColumn,
secondNumericColumn: openedColumn?.custom_column_obj.secondNumericColumn,
}
}
>
<Flex gap={16} align="center" justify="space-between">
<Form.Item
name={'fieldTitle'}
label={<Typography.Text>Field title</Typography.Text>}
layout="vertical"
rules={[
{
required: true,
message: 'Field title is required',
},
]}
required={false}
>
<Input placeholder="title" style={{ minWidth: '100%', width: 300 }} />
</Form.Item>
<Form.Item
name={'fieldType'}
label={<Typography.Text>Type</Typography.Text>}
layout="vertical"
>
<Select
options={fieldTypesOptions}
defaultValue={fieldType}
value={fieldType}
onChange={value => dispatch(setCustomFieldType(value))}
style={{
minWidth: '100%',
width: 150,
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
borderRadius: 4,
}}
/>
</Form.Item>
</Flex>
{/* render form items based on types */}
{customFieldType === 'key' && <KeyTypeColumn />}
{customFieldType === 'number' && <NumberTypeColumn />}
{customFieldType === 'formula' && <FormulaTypeColumn />}
{customFieldType === 'labels' && <LabelTypeColumn />}
{customFieldType === 'selection' && <SelectionTypeColumn />}
<Flex
gap={8}
align="center"
justify={`${customColumnModalType === 'create' ? 'flex-end' : 'space-between'}`}
style={{ marginBlockStart: 24 }}
>
{customColumnModalType === 'edit' && customColumnId && (
<Popconfirm
title="Are you sure you want to delete this custom column?"
description="This action cannot be undone. All data associated with this column will be permanently deleted."
icon={<ExclamationCircleFilled style={{ color: 'red' }} />}
onConfirm={handleDeleteColumn}
okText="Delete"
cancelText="Cancel"
okButtonProps={{ danger: true }}
>
<Button danger>Delete</Button>
</Popconfirm>
)}
<Flex gap={8}>
<Button onClick={() => dispatch(toggleCustomColumnModalOpen(false))}>Cancel</Button>
{customColumnModalType === 'create' ? (
<Button type="primary" htmlType="submit">
Create
</Button>
) : (
<Button type="primary" htmlType="submit">
Update
</Button>
)}
</Flex>
</Flex>
</Form>
<Divider style={{ position: 'absolute', left: 0, bottom: 42 }} />
</Modal>
);
};
export default CustomColumnModal;

View File

@@ -0,0 +1,103 @@
import { Flex, Form, Select, Typography } from 'antd';
import React from 'react';
import { themeWiseColor } from '../../../../../../../../utils/themeWiseColor';
import { useAppSelector } from '../../../../../../../../hooks/useAppSelector';
import { useAppDispatch } from '../../../../../../../../hooks/useAppDispatch';
import {
setExpression,
setFirstNumericColumn,
setSecondNumericColumn,
} from '../../../../../../../../features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
const FormulaTypeColumn = () => {
// get theme details from the theme reducer
const themeMode = useAppSelector(state => state.themeReducer.mode);
const dispatch = useAppDispatch();
// get initial data from task list custom column slice
const expression = useAppSelector(state => state.taskListCustomColumnsReducer.expression);
// get columns from column slice and filter only numeric columns
const columnsOptions = useAppSelector(
state => state.projectViewTaskListColumnsReducer.columnList
);
// filter numeric columns only
const numericColumns = columnsOptions.filter(
column => column.customColumnObj?.fieldType === 'number'
);
// expression types options
const expressionTypesOptions = [
{ key: 'add', value: 'add', label: '+ Add' },
{ key: 'substract', value: 'substract', label: '- Substract' },
{ key: 'divide', value: 'divide', label: '/ Divide' },
{ key: 'multiply', value: 'multiply', label: 'x Multiply' },
];
return (
<Flex gap={8} align="center" justify="space-between">
<Form.Item
name={'firstNumericColumn'}
label={<Typography.Text>First Column</Typography.Text>}
>
{/* first numeric column */}
<Select
options={numericColumns.map(col => ({
key: col.key,
value: col.key,
label: col.name,
}))}
onChange={value => dispatch(setFirstNumericColumn(value))}
placeholder="Select first column"
style={{
minWidth: '100%',
width: 150,
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
borderRadius: 4,
}}
/>
</Form.Item>
<Form.Item name={'expression'} label={<Typography.Text>Expression</Typography.Text>}>
{/* expression type */}
<Select
options={expressionTypesOptions}
value={expression}
onChange={value => dispatch(setExpression(value))}
style={{
minWidth: '100%',
width: 150,
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
borderRadius: 4,
}}
/>
</Form.Item>
<Form.Item
name={'secondNumericColumn'}
label={<Typography.Text>Second Column</Typography.Text>}
>
{/* second numeric column */}
<Select
options={numericColumns.map(col => ({
key: col.key,
value: col.key,
label: col.name,
}))}
onChange={value => dispatch(setSecondNumericColumn(value))}
placeholder="Select second column"
style={{
minWidth: '100%',
width: 150,
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
borderRadius: 4,
}}
/>
</Form.Item>
</Flex>
);
};
export default FormulaTypeColumn;

View File

@@ -0,0 +1,28 @@
import { Flex, Form, Input, Typography } from 'antd';
import React, { useState } from 'react';
const KeyTypeColumn = () => {
const [keyLabel, setKeyLabel] = useState<string>('MK');
return (
<Flex gap={16}>
<Form.Item name="customKeyLabel" label="Label">
<Input
value={keyLabel}
placeholder="ex-:MK"
maxLength={5}
style={{ textTransform: 'uppercase' }}
onChange={e => setKeyLabel(e.currentTarget.value)}
/>
</Form.Item>
<Form.Item name="customKeyPreviewValue" label="Preview">
<Typography.Text style={{ textTransform: 'uppercase' }}>
{keyLabel.length === 0 ? 'MK' : keyLabel}-1
</Typography.Text>
</Form.Item>
</Flex>
);
};
export default KeyTypeColumn;

View File

@@ -0,0 +1,142 @@
import React, { useState, useEffect } from 'react';
import { nanoid } from 'nanoid';
import { PhaseColorCodes } from '../../../../../../../../shared/constants';
import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';
import { useAppDispatch } from '../../../../../../../../hooks/useAppDispatch';
import { useAppSelector } from '../../../../../../../../hooks/useAppSelector';
import { setLabelsList } from '../../../../../../../../features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
export type LabelType = {
label_id: string;
label_name: string;
label_color: string;
};
const LabelTypeColumn = () => {
const dispatch = useAppDispatch();
const [labels, setLabels] = useState<LabelType[]>([
{
label_id: nanoid(),
label_name: 'Untitled label',
label_color: PhaseColorCodes[0],
},
]);
// Get the custom column modal type and column ID from the store
const { customColumnModalType, customColumnId } = useAppSelector(
state => state.taskListCustomColumnsReducer
);
// Get the opened column data if in edit mode
const openedColumn = useAppSelector(state =>
state.taskReducer.customColumns.find(col => col.key === customColumnId)
);
// Load existing labels when in edit mode
useEffect(() => {
if (customColumnModalType === 'edit' && openedColumn?.custom_column_obj?.labelsList) {
const existingLabels = openedColumn.custom_column_obj.labelsList;
if (Array.isArray(existingLabels) && existingLabels.length > 0) {
setLabels(existingLabels);
dispatch(setLabelsList(existingLabels));
}
}
}, [customColumnModalType, openedColumn, customColumnId, dispatch]);
// phase color options
const phaseOptionColorList = PhaseColorCodes.map(color => ({
value: color,
label: (
<Tag
color={color}
style={{
display: 'flex',
alignItems: 'center',
width: 15,
height: 15,
borderRadius: '50%',
}}
/>
),
}));
// add a new label
const handleAddLabel = () => {
const newLabel = {
label_id: nanoid(),
label_name: 'Untitled label',
label_color: PhaseColorCodes[0],
};
setLabels(prevLabels => [...prevLabels, newLabel]);
dispatch(setLabelsList([...labels, newLabel])); // update the slice with the new label
};
// update label name
const handleUpdateLabelName = (labelId: string, labelName: string) => {
const updatedLabels = labels.map(label =>
label.label_id === labelId ? { ...label, label_name: labelName } : label
);
setLabels(updatedLabels);
dispatch(setLabelsList(updatedLabels)); // update the slice with the new label name
};
// update label color
const handleUpdateLabelColor = (labelId: string, labelColor: string) => {
const updatedLabels = labels.map(label =>
label.label_id === labelId ? { ...label, label_color: labelColor } : label
);
setLabels(updatedLabels);
dispatch(setLabelsList(updatedLabels)); // update the slice with the new label color
};
// remove a label
const handleRemoveLabel = (labelId: string) => {
const updatedLabels = labels.filter(label => label.label_id !== labelId);
setLabels(updatedLabels);
dispatch(setLabelsList(updatedLabels)); // update the slice after label removal
};
return (
<div style={{ maxWidth: '100%', minHeight: 180 }}>
<Typography.Text>Labels</Typography.Text>
<Flex vertical gap={8}>
<Flex vertical gap={8} style={{ maxHeight: 120, overflow: 'auto' }}>
{labels.map(label => (
<Flex gap={8} key={label.label_id}>
<HolderOutlined style={{ fontSize: 18 }} />
<Input
value={label.label_name}
onChange={e => handleUpdateLabelName(label.label_id, e.target.value)}
style={{ width: 'fit-content', maxWidth: 400 }}
/>
<Flex gap={8} align="center">
<Select
options={phaseOptionColorList}
value={label.label_color}
onChange={value => handleUpdateLabelColor(label.label_id, value)}
style={{ width: 48 }}
suffixIcon={null}
/>
<CloseCircleOutlined
onClick={() => handleRemoveLabel(label.label_id)}
style={{ cursor: 'pointer' }}
/>
</Flex>
</Flex>
))}
</Flex>
<Button
type="link"
onClick={handleAddLabel}
style={{ width: 'fit-content', padding: 0 }}
>
+ Add a label
</Button>
</Flex>
</div>
);
};
export default LabelTypeColumn;

View File

@@ -0,0 +1,45 @@
import { Form, Select, Typography } from 'antd';
import React from 'react';
import { useAppSelector } from '../../../../../../../../hooks/useAppSelector';
import { themeWiseColor } from '../../../../../../../../utils/themeWiseColor';
import { useAppDispatch } from '../../../../../../../../hooks/useAppDispatch';
import { setDecimals } from '../../../../../../../../features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
const FormattedTypeNumberColumn = () => {
// Get theme details from the theme reducer
const themeMode = useAppSelector(state => state.themeReducer.mode);
const dispatch = useAppDispatch();
// get initial data from task list custom column slice
const decimals: number = useAppSelector(state => state.taskListCustomColumnsReducer.decimals);
const previewValue: number = useAppSelector(
state => state.taskListCustomColumnsReducer.previewValue
);
return (
<>
<Form.Item name="decimals" label={<Typography.Text>Decimals</Typography.Text>}>
<Select
options={[1, 2, 3, 4].map(item => ({
key: item,
value: item,
label: item,
}))}
defaultValue={decimals}
onChange={value => dispatch(setDecimals(value))}
style={{
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
borderRadius: 4,
}}
/>
</Form.Item>
<Form.Item name="previewValue" label={<Typography.Text>Preview</Typography.Text>}>
<Typography.Text>{previewValue.toFixed(decimals)}</Typography.Text>
</Form.Item>
</>
);
};
export default FormattedTypeNumberColumn;

View File

@@ -0,0 +1,61 @@
import { Form, Select, Typography } from 'antd';
import React from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { themeWiseColor } from '@/utils/themeWiseColor';
import FormattedTypeNumberColumn from './formatted-type-number-column';
import UnformattedTypeNumberColumn from './unformatted-type-number-column';
import PercentageTypeNumberColumn from './percentage-type-number-column';
import WithLabelTypeNumberColumn from './with-label-type-number-column';
import {
CustomFieldNumberTypes,
setCustomFieldNumberType,
} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
const NumberTypeColumn = () => {
// get theme details from theme reducer
const themeMode = useAppSelector(state => state.themeReducer.mode);
const dispatch = useAppDispatch();
// get initial data from task list custom column slice
const numberType: CustomFieldNumberTypes = useAppSelector(
state => state.taskListCustomColumnsReducer.customFieldNumberType
);
const numberTypesOptions = [
{ key: 'unformatted', value: 'unformatted', label: 'Unformatted' },
{ key: 'percentage', value: 'percentage', label: 'Percentage' },
{ key: 'withLabel', value: 'withLabel', label: 'With Label' },
{ key: 'formatted', value: 'formatted', label: 'Formatted' },
];
return (
<div className={numberType === 'withLabel' ? 'grid grid-cols-5 gap-x-4' : 'flex gap-4'}>
<Form.Item
name="numberType"
label={<Typography.Text>Number Type</Typography.Text>}
className={numberType === 'withLabel' ? 'col-span-2' : ''}
>
<Select
options={numberTypesOptions}
value={numberType}
onChange={value => dispatch(setCustomFieldNumberType(value))}
style={{
minWidth: '100%',
width: 150,
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
borderRadius: 4,
}}
/>
</Form.Item>
{numberType === 'formatted' && <FormattedTypeNumberColumn />}
{numberType === 'unformatted' && <UnformattedTypeNumberColumn />}
{numberType === 'percentage' && <PercentageTypeNumberColumn />}
{numberType === 'withLabel' && <WithLabelTypeNumberColumn />}
</div>
);
};
export default NumberTypeColumn;

View File

@@ -0,0 +1,45 @@
import { Form, Select, Typography } from 'antd';
import React from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setDecimals } from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
const PercentageTypeNumberColumn = () => {
// get theme details from theme reducer
const themeMode = useAppSelector(state => state.themeReducer.mode);
const dispatch = useAppDispatch();
// get initial data from task list custom column slice
const decimals: number = useAppSelector(state => state.taskListCustomColumnsReducer.decimals);
const previewValue: number = useAppSelector(
state => state.taskListCustomColumnsReducer.previewValue
);
return (
<>
<Form.Item name={'decimals'} label={<Typography.Text>Decimals</Typography.Text>}>
<Select
options={[1, 2, 3, 4].map(item => ({
key: item,
value: item,
label: item,
}))}
defaultValue={decimals}
onChange={value => dispatch(setDecimals(value))}
style={{
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
borderRadius: 4,
}}
/>
</Form.Item>
<Form.Item name={'previewValue'} label={<Typography.Text>Preview</Typography.Text>}>
<Typography.Text>{previewValue.toFixed(decimals)}%</Typography.Text>
</Form.Item>
</>
);
};
export default PercentageTypeNumberColumn;

View File

@@ -0,0 +1,18 @@
import { Form, Typography } from 'antd';
import React from 'react';
import { useAppSelector } from '../../../../../../../../hooks/useAppSelector';
const UnformattedTypeNumberColumn = () => {
// get initial data from task list custom column slice
const previewValue: number = useAppSelector(
state => state.taskListCustomColumnsReducer.previewValue
);
return (
<Form.Item name={'previewValue'} label={<Typography.Text>Preview</Typography.Text>}>
{previewValue}
</Form.Item>
);
};
export default UnformattedTypeNumberColumn;

View File

@@ -0,0 +1,85 @@
import { Form, Input, Select, Typography } from 'antd';
import React from 'react';
import { useAppSelector } from '../../../../../../../../hooks/useAppSelector';
import { themeWiseColor } from '../../../../../../../../utils/themeWiseColor';
import { useAppDispatch } from '../../../../../../../../hooks/useAppDispatch';
import {
setDecimals,
setLabel,
setLabelPosition,
} from '../../../../../../../../features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
const WithLabelTypeNumberColumn = () => {
// get theme details from theme reducer
const themeMode = useAppSelector(state => state.themeReducer.mode);
const dispatch = useAppDispatch();
// get initial data from task list custom column slice
const decimals: number = useAppSelector(state => state.taskListCustomColumnsReducer.decimals);
const label: string = useAppSelector(state => state.taskListCustomColumnsReducer.label);
const labelPosition: 'left' | 'right' = useAppSelector(
state => state.taskListCustomColumnsReducer.labelPosition
);
const previewValue: number = useAppSelector(
state => state.taskListCustomColumnsReducer.previewValue
);
return (
<>
<Form.Item name={'label'} label={<Typography.Text>Label</Typography.Text>}>
<Input value={label} onChange={e => dispatch(setLabel(e.currentTarget.value))} />
</Form.Item>
<Form.Item name={'labelPosition'} label={<Typography.Text>Position</Typography.Text>}>
<Select
options={[
{
key: 'left',
value: 'left',
label: 'Left',
},
{ key: 'right', value: 'right', label: 'Right' },
]}
defaultValue={labelPosition}
value={labelPosition}
onChange={value => dispatch(setLabelPosition(value))}
style={{
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
borderRadius: 4,
}}
/>
</Form.Item>
<Form.Item name={'decimals'} label={<Typography.Text>Decimals</Typography.Text>}>
<Select
options={[1, 2, 3, 4].map(item => ({
key: item,
value: item,
label: item,
}))}
value={decimals}
onChange={value => dispatch(setDecimals(value))}
style={{
border: `1px solid ${themeWiseColor('#d9d9d9', '#424242', themeMode)}`,
borderRadius: 4,
}}
/>
</Form.Item>
<Form.Item
name={'previewValue'}
label={<Typography.Text>Preview</Typography.Text>}
className="col-span-5"
>
<Typography.Text>
{labelPosition === 'left'
? `${label} ${previewValue.toFixed(decimals)}`
: `${previewValue.toFixed(decimals)} ${label} `}
</Typography.Text>
</Form.Item>
</>
);
};
export default WithLabelTypeNumberColumn;

View File

@@ -0,0 +1,154 @@
import React, { useState, useEffect } from 'react';
import { nanoid } from 'nanoid';
import { PhaseColorCodes } from '../../../../../../../../shared/constants';
import { Button, Flex, Input, Select, Tag, Typography } from 'antd';
import { CloseCircleOutlined, HolderOutlined } from '@ant-design/icons';
import { useAppDispatch } from '../../../../../../../../hooks/useAppDispatch';
import { useAppSelector } from '../../../../../../../../hooks/useAppSelector';
import { setSelectionsList } from '../../../../../../../../features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
export type SelectionType = {
selection_color: string;
selection_id: string;
selection_name: string;
};
const SelectionTypeColumn = () => {
const dispatch = useAppDispatch();
const [selections, setSelections] = useState<SelectionType[]>([
{
selection_id: nanoid(),
selection_name: 'Untitled selection',
selection_color: PhaseColorCodes[0],
},
]);
// Get the custom column modal type and column ID from the store
const { customColumnModalType, customColumnId, selectionsList: storeSelectionsList } = useAppSelector(
state => state.taskListCustomColumnsReducer
);
// Get the opened column data if in edit mode
const openedColumn = useAppSelector(state =>
state.taskReducer.customColumns.find(col => col.key === customColumnId)
);
console.log('SelectionTypeColumn render:', {
customColumnModalType,
customColumnId,
openedColumn,
storeSelectionsList,
'openedColumn?.custom_column_obj?.selectionsList': openedColumn?.custom_column_obj?.selectionsList
});
// Load existing selections when in edit mode
useEffect(() => {
if (customColumnModalType === 'edit' && openedColumn?.custom_column_obj?.selectionsList) {
const existingSelections = openedColumn.custom_column_obj.selectionsList;
console.log('Loading existing selections:', existingSelections);
if (Array.isArray(existingSelections) && existingSelections.length > 0) {
setSelections(existingSelections);
dispatch(setSelectionsList(existingSelections));
}
}
}, [customColumnModalType, openedColumn, customColumnId, dispatch]);
// phase color options
const phaseOptionColorList = PhaseColorCodes.map(color => ({
value: color,
label: (
<Tag
color={color}
style={{
display: 'flex',
alignItems: 'center',
width: 15,
height: 15,
borderRadius: '50%',
}}
/>
),
}));
// add a new selection
const handleAddSelection = () => {
const newSelection = {
selection_id: nanoid(),
selection_name: 'Untitled selection',
selection_color: PhaseColorCodes[0],
};
setSelections(prevSelections => [...prevSelections, newSelection]);
dispatch(setSelectionsList([...selections, newSelection])); // update the slice with the new selection
};
// update selection name
const handleUpdateSelectionName = (selectionId: string, selectionName: string) => {
const updatedSelections = selections.map(selection =>
selection.selection_id === selectionId ? { ...selection, selection_name: selectionName } : selection
);
setSelections(updatedSelections);
dispatch(setSelectionsList(updatedSelections)); // update the slice with the new selection name
};
// update selection color
const handleUpdateSelectionColor = (selectionId: string, selectionColor: string) => {
const updatedSelections = selections.map(selection =>
selection.selection_id === selectionId ? { ...selection, selection_color: selectionColor } : selection
);
setSelections(updatedSelections);
dispatch(setSelectionsList(updatedSelections)); // update the slice with the new selection color
};
// remove a selection
const handleRemoveSelection = (selectionId: string) => {
const updatedSelections = selections.filter(selection => selection.selection_id !== selectionId);
setSelections(updatedSelections);
dispatch(setSelectionsList(updatedSelections)); // update the slice after selection removal
};
return (
<div style={{ maxWidth: '100%', minHeight: 180 }}>
<Typography.Text>Selections</Typography.Text>
<Flex vertical gap={8}>
<Flex vertical gap={8} style={{ maxHeight: 120, overflow: 'auto' }}>
{selections.map(selection => (
<Flex gap={8} key={selection.selection_id}>
<HolderOutlined style={{ fontSize: 18 }} />
<Input
value={selection.selection_name}
onChange={e => handleUpdateSelectionName(selection.selection_id, e.target.value)}
style={{ width: 'fit-content', maxWidth: 400 }}
/>
<Flex gap={8} align="center">
<Select
options={phaseOptionColorList}
value={selection.selection_color}
onChange={value => handleUpdateSelectionColor(selection.selection_id, value)}
style={{ width: 48 }}
suffixIcon={null}
/>
<CloseCircleOutlined
onClick={() => handleRemoveSelection(selection.selection_id)}
style={{ cursor: 'pointer' }}
/>
</Flex>
</Flex>
))}
</Flex>
<Button
type="link"
onClick={handleAddSelection}
style={{ width: 'fit-content', padding: 0 }}
>
+ Add a selection
</Button>
</Flex>
</div>
);
};
export default SelectionTypeColumn;

View File

@@ -0,0 +1,671 @@
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
import { useState, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import Flex from 'antd/es/flex';
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
import {
DndContext,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
DragStartEvent,
pointerWithin,
} from '@dnd-kit/core';
import { SocketEvents } from '@/shared/socket-events';
import logger from '@/utils/errorLogger';
import alertService from '@/services/alerts/alertService';
import { tasksApiService } from '@/api/tasks/tasks.api.service';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
import { ILabelsChangeResponse } from '@/types/tasks/taskList.types';
import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.types';
import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types';
import { ITaskPhaseChangeResponse } from '@/types/tasks/task-phase-change-response';
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import {
fetchTaskAssignees,
updateTaskAssignees,
fetchLabelsByProject,
updateTaskLabel,
updateTaskStatus,
updateTaskPriority,
updateTaskEndDate,
updateTaskEstimation,
updateTaskName,
updateTaskPhase,
updateTaskStartDate,
IGroupBy,
updateTaskDescription,
updateSubTasks,
updateTaskProgress,
} from '@/features/tasks/tasks.slice';
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
import {
setStartDate,
setTaskAssignee,
setTaskEndDate,
setTaskLabels,
setTaskPriority,
setTaskStatus,
setTaskSubscribers,
} from '@/features/task-drawer/task-drawer.slice';
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
import TaskListTableWrapper from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper';
import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar';
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics-events';
import { ALPHA_CHANNEL } from '@/shared/constants';
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
interface TaskGroupWrapperProps {
taskGroups: ITaskListGroup[];
groupBy: string;
}
const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
const [groups, setGroups] = useState(taskGroups);
const [activeId, setActiveId] = useState<string | null>(null);
const dispatch = useAppDispatch();
const { socket } = useSocket();
const currentSession = useAuthService().getCurrentSession();
const { trackMixpanelEvent } = useMixpanelTracking();
const themeMode = useAppSelector(state => state.themeReducer.mode);
const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees);
const { projectId } = useAppSelector(state => state.projectReducer);
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: { distance: 8 },
})
);
useEffect(() => {
setGroups(taskGroups);
}, [taskGroups]);
const resetTaskRowStyles = useCallback(() => {
document.querySelectorAll<HTMLElement>('.task-row').forEach(row => {
row.style.transition = 'transform 0.2s ease, opacity 0.2s ease';
row.style.cssText =
'opacity: 1 !important; position: relative !important; z-index: auto !important; transform: none !important;';
row.setAttribute('data-is-dragging', 'false');
});
}, []);
// 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 = groups.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,
})
);
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, groups, dispatch]);
// Socket handler for label updates
useEffect(() => {
if (!socket) return;
const handleLabelsChange = async (labels: ILabelsChangeResponse) => {
await Promise.all([
dispatch(updateTaskLabel(labels)),
dispatch(setTaskLabels(labels)),
dispatch(fetchLabels()),
projectId && dispatch(fetchLabelsByProject(projectId)),
]);
};
socket.on(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
socket.on(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
return () => {
socket.off(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
socket.off(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
};
}, [socket, dispatch, projectId]);
// Socket handler for status updates
useEffect(() => {
if (!socket) return;
const handleTaskStatusChange = (response: ITaskListStatusChangeResponse) => {
if (response.completed_deps === false) {
alertService.error(
'Task is not completed',
'Please complete the task dependencies before proceeding'
);
return;
}
dispatch(updateTaskStatus(response));
// dispatch(setTaskStatus(response));
dispatch(deselectAll());
};
const handleTaskProgress = (data: {
id: string;
status: string;
complete_ratio: number;
completed_count: number;
total_tasks_count: number;
parent_task: string;
}) => {
dispatch(
updateTaskProgress({
taskId: data.parent_task || data.id,
progress: data.complete_ratio,
totalTasksCount: data.total_tasks_count,
completedCount: data.completed_count,
})
);
};
socket.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
return () => {
socket.off(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
socket.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
};
}, [socket, dispatch]);
// Socket handler for priority updates
useEffect(() => {
if (!socket) return;
const handlePriorityChange = (response: ITaskListPriorityChangeResponse) => {
dispatch(updateTaskPriority(response));
dispatch(setTaskPriority(response));
dispatch(deselectAll());
};
socket.on(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
return () => {
socket.off(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
};
}, [socket, 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]);
// Socket handler for task name updates
useEffect(() => {
if (!socket) return;
const handleTaskNameChange = (data: { id: string; parent_task: string; name: string }) => {
dispatch(updateTaskName(data));
};
socket.on(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange);
return () => {
socket.off(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange);
};
}, [socket, dispatch]);
// Socket handler for phase updates
useEffect(() => {
if (!socket) return;
const handlePhaseChange = (data: ITaskPhaseChangeResponse) => {
dispatch(updateTaskPhase(data));
dispatch(deselectAll());
};
socket.on(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
return () => {
socket.off(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
};
}, [socket, dispatch]);
// Socket handler for start date updates
useEffect(() => {
if (!socket) return;
const handleStartDateChange = (task: {
id: string;
parent_task: string | null;
start_date: string;
}) => {
dispatch(updateTaskStartDate({ task }));
dispatch(setStartDate(task));
};
socket.on(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
return () => {
socket.off(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
};
}, [socket, dispatch]);
// Socket handler for task subscribers updates
useEffect(() => {
if (!socket) return;
const handleTaskSubscribersChange = (data: InlineMember[]) => {
dispatch(setTaskSubscribers(data));
};
socket.on(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
return () => {
socket.off(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
};
}, [socket, dispatch]);
// Socket handler for task estimation updates
useEffect(() => {
if (!socket) return;
const handleEstimationChange = (task: {
id: string;
parent_task: string | null;
estimation: number;
}) => {
dispatch(updateTaskEstimation({ task }));
};
socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
return () => {
socket.off(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
};
}, [socket, dispatch]);
// Socket handler for task description updates
useEffect(() => {
if (!socket) return;
const handleTaskDescriptionChange = (data: {
id: string;
parent_task: string;
description: string;
}) => {
dispatch(updateTaskDescription(data));
};
socket.on(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
return () => {
socket.off(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), handleTaskDescriptionChange);
};
}, [socket, dispatch]);
// Socket handler for new task creation
useEffect(() => {
if (!socket) return;
const handleNewTaskReceived = (data: IProjectTask) => {
if (!data) return;
if (data.parent_task_id) {
dispatch(updateSubTasks(data));
}
};
socket.on(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
return () => {
socket.off(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
};
}, [socket, dispatch]);
const handleDragStart = useCallback(({ active }: DragStartEvent) => {
setActiveId(active.id as string);
// Add smooth transition to the dragged item
const draggedElement = document.querySelector(`[data-id="${active.id}"]`);
if (draggedElement) {
(draggedElement as HTMLElement).style.transition = 'transform 0.2s ease';
}
}, []);
const handleDragEnd = useCallback(
async ({ active, over }: DragEndEvent) => {
setActiveId(null);
if (!over) return;
const activeGroupId = active.data.current?.groupId;
const overGroupId = over.data.current?.groupId;
const activeTaskId = active.id;
const overTaskId = over.id;
const sourceGroup = taskGroups.find(g => g.id === activeGroupId);
const targetGroup = taskGroups.find(g => g.id === overGroupId);
if (!sourceGroup || !targetGroup) return;
const fromIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
if (fromIndex === -1) return;
// Create a deep clone of the task to avoid reference issues
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
// Check if task dependencies allow the move
if (activeGroupId !== overGroupId) {
const canContinue = await checkTaskDependencyStatus(task.id, overGroupId);
if (!canContinue) {
alertService.error(
'Task is not completed',
'Please complete the task dependencies before proceeding'
);
resetTaskRowStyles();
return;
}
// Update task properties based on target group
switch (groupBy) {
case IGroupBy.STATUS:
task.status = overGroupId;
task.status_color = targetGroup.color_code;
task.status_color_dark = targetGroup.color_code_dark;
break;
case IGroupBy.PRIORITY:
task.priority = overGroupId;
task.priority_color = targetGroup.color_code;
task.priority_color_dark = targetGroup.color_code_dark;
break;
case IGroupBy.PHASE:
// Check if ALPHA_CHANNEL is already added
const baseColor = targetGroup.color_code.endsWith(ALPHA_CHANNEL)
? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length) // Remove ALPHA_CHANNEL
: targetGroup.color_code; // Use as is if not present
task.phase_id = overGroupId;
task.phase_color = baseColor; // Set the cleaned color
break;
}
}
const isTargetGroupEmpty = targetGroup.tasks.length === 0;
// Calculate toIndex - for empty groups, always add at index 0
const toIndex = isTargetGroupEmpty
? 0
: overTaskId
? targetGroup.tasks.findIndex(t => t.id === overTaskId)
: targetGroup.tasks.length;
// Calculate toPos similar to Angular implementation
const toPos = isTargetGroupEmpty
? -1
: targetGroup.tasks[toIndex]?.sort_order ||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
-1;
// Update Redux state
if (activeGroupId === overGroupId) {
// Same group - move within array
const updatedTasks = [...sourceGroup.tasks];
updatedTasks.splice(fromIndex, 1);
updatedTasks.splice(toIndex, 0, task);
dispatch({
type: 'taskReducer/reorderTasks',
payload: {
activeGroupId,
overGroupId,
fromIndex,
toIndex,
task,
updatedSourceTasks: updatedTasks,
updatedTargetTasks: updatedTasks,
},
});
} else {
// Different groups - transfer between arrays
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
const updatedTargetTasks = [...targetGroup.tasks];
if (isTargetGroupEmpty) {
updatedTargetTasks.push(task);
} else if (toIndex >= 0 && toIndex <= updatedTargetTasks.length) {
updatedTargetTasks.splice(toIndex, 0, task);
} else {
updatedTargetTasks.push(task);
}
dispatch({
type: 'taskReducer/reorderTasks',
payload: {
activeGroupId,
overGroupId,
fromIndex,
toIndex,
task,
updatedSourceTasks,
updatedTargetTasks,
},
});
}
// Emit socket event
socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
project_id: projectId,
from_index: sourceGroup.tasks[fromIndex].sort_order,
to_index: toPos,
to_last_index: isTargetGroupEmpty,
from_group: sourceGroup.id,
to_group: targetGroup.id,
group_by: groupBy,
task: sourceGroup.tasks[fromIndex], // Send original task to maintain references
team_id: currentSession?.team_id,
});
// Reset styles
setTimeout(resetTaskRowStyles, 0);
trackMixpanelEvent(evt_project_task_list_drag_and_move);
},
[
taskGroups,
groupBy,
projectId,
currentSession?.team_id,
dispatch,
socket,
resetTaskRowStyles,
trackMixpanelEvent,
]
);
const handleDragOver = useCallback(
({ active, over }: DragEndEvent) => {
if (!over) return;
const activeGroupId = active.data.current?.groupId;
const overGroupId = over.data.current?.groupId;
const activeTaskId = active.id;
const overTaskId = over.id;
const sourceGroup = taskGroups.find(g => g.id === activeGroupId);
const targetGroup = taskGroups.find(g => g.id === overGroupId);
if (!sourceGroup || !targetGroup) return;
const fromIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
const toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
if (fromIndex === -1 || toIndex === -1) return;
// Create a deep clone of the task to avoid reference issues
const task = JSON.parse(JSON.stringify(sourceGroup.tasks[fromIndex]));
// Update Redux state
if (activeGroupId === overGroupId) {
// Same group - move within array
const updatedTasks = [...sourceGroup.tasks];
updatedTasks.splice(fromIndex, 1);
updatedTasks.splice(toIndex, 0, task);
dispatch({
type: 'taskReducer/reorderTasks',
payload: {
activeGroupId,
overGroupId,
fromIndex,
toIndex,
task,
updatedSourceTasks: updatedTasks,
updatedTargetTasks: updatedTasks,
},
});
} else {
// Different groups - transfer between arrays
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
const updatedTargetTasks = [...targetGroup.tasks];
updatedTargetTasks.splice(toIndex, 0, task);
dispatch({
type: 'taskReducer/reorderTasks',
payload: {
activeGroupId,
overGroupId,
fromIndex,
toIndex,
task,
updatedSourceTasks,
updatedTargetTasks,
},
});
}
},
[taskGroups, dispatch]
);
// Add CSS styles for drag and drop animations
useIsomorphicLayoutEffect(() => {
const style = document.createElement('style');
style.textContent = `
.task-row {
opacity: 1 !important;
position: relative !important;
z-index: auto !important;
transform: none !important;
transition: transform 0.2s ease, opacity 0.2s ease !important;
will-change: transform, opacity;
}
.task-row[data-is-dragging="true"] {
z-index: 100 !important;
transition: none !important;
}
`;
document.head.appendChild(style);
return () => {
document.head.removeChild(style);
};
}, []);
// Handle animation cleanup after drag ends
useIsomorphicLayoutEffect(() => {
if (activeId === null) {
// Final cleanup after React updates DOM
const timeoutId = setTimeout(resetTaskRowStyles, 50);
return () => clearTimeout(timeoutId);
}
}, [activeId, resetTaskRowStyles]);
return (
<DndContext
sensors={sensors}
collisionDetection={pointerWithin}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
>
<Flex gap={24} vertical>
{taskGroups?.map(taskGroup => (
<TaskListTableWrapper
key={taskGroup.id}
taskList={taskGroup.tasks}
tableId={taskGroup.id}
name={taskGroup.name}
groupBy={groupBy}
statusCategory={taskGroup.category_id}
color={themeMode === 'dark' ? taskGroup.color_code_dark : taskGroup.color_code}
activeId={activeId}
/>
))}
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
{createPortal(
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
document.body,
'task-template-drawer'
)}
</Flex>
</DndContext>
);
};
export default TaskGroupWrapper;

View File

@@ -0,0 +1,14 @@
import { Tooltip, Typography } from 'antd';
import React from 'react';
import { durationDateFormat } from '@/utils/durationDateFormat';
import { formatDate } from '@/utils/timeUtils';
const TaskListCompletedDateCell = ({ completedDate }: { completedDate: string | null }) => {
return (
<Tooltip title={completedDate ? formatDate(new Date(completedDate)) : 'N/A'}>
<Typography.Text>{durationDateFormat(completedDate || null)}</Typography.Text>
</Tooltip>
);
};
export default TaskListCompletedDateCell;

View File

@@ -0,0 +1,13 @@
import { Tooltip, Typography } from 'antd';
import { durationDateFormat } from '@/utils/durationDateFormat';
import { formatDate } from '@/utils/timeUtils';
const TaskListCreatedDateCell = ({ createdDate }: { createdDate: string | null }) => {
return (
<Tooltip title={createdDate ? formatDate(new Date(createdDate)) : 'N/A'}>
<Typography.Text>{durationDateFormat(createdDate || null)}</Typography.Text>
</Tooltip>
);
};
export default TaskListCreatedDateCell;

View File

@@ -0,0 +1,21 @@
import { Typography } from 'antd';
import DOMPurify from 'dompurify';
const TaskListDescriptionCell = ({ description }: { description: string }) => {
const sanitizedDescription = DOMPurify.sanitize(description);
return (
<Typography.Paragraph
ellipsis={{
expandable: false,
rows: 1,
tooltip: description,
}}
style={{ width: 260, marginBlockEnd: 0 }}
>
<span dangerouslySetInnerHTML={{ __html: sanitizedDescription }} />
</Typography.Paragraph>
);
};
export default TaskListDescriptionCell;

View File

@@ -0,0 +1,54 @@
import { DatePicker } from 'antd';
import { colors } from '@/styles/colors';
import dayjs, { Dayjs } from 'dayjs';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { getUserSession } from '@/utils/session-helper';
import logger from '@/utils/errorLogger';
const TaskListDueDateCell = ({ task }: { task: IProjectTask }) => {
const { socket } = useSocket();
const dueDayjs = task.end_date ? dayjs(task.end_date) : null;
const startDayjs = task.start_date ? dayjs(task.start_date) : null;
const handleEndDateChange = (date: Dayjs | null) => {
try {
socket?.emit(
SocketEvents.TASK_END_DATE_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
end_date: date?.format('YYYY-MM-DD'),
parent_task: task.parent_task_id,
time_zone: getUserSession()?.timezone_name
? getUserSession()?.timezone_name
: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
);
} catch (error) {
logger.error('Failed to update due date:', error);
}
};
const disabledEndDate = (current: Dayjs) => {
return current && startDayjs ? current < startDayjs : false;
};
return (
<DatePicker
placeholder="Set Date"
value={dueDayjs}
format={'MMM DD, YYYY'}
suffixIcon={null}
onChange={handleEndDateChange}
disabledDate={disabledEndDate}
style={{
backgroundColor: colors.transparent,
border: 'none',
boxShadow: 'none',
}}
/>
);
};
export default TaskListDueDateCell;

View File

@@ -0,0 +1,24 @@
import { TimePicker, TimePickerProps } from 'antd';
import React from 'react';
// import dayjs from 'dayjs';
const TaskListDueTimeCell = () => {
// function to trigger time change
const onTimeChange: TimePickerProps['onChange'] = (time, timeString) => {
console.log(time, timeString);
};
return (
<TimePicker
format={'HH:mm'}
changeOnScroll
onChange={onTimeChange}
style={{
border: 'none',
background: 'transparent',
}}
/>
);
};
export default TaskListDueTimeCell;

View File

@@ -0,0 +1,12 @@
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { Typography } from 'antd';
import React from 'react';
interface ITaskListEstimationCellProps {
task: IProjectTask
}
const TaskListEstimationCell = ({ task }: ITaskListEstimationCellProps) => {
return <Typography.Text>{task?.total_time_string}</Typography.Text>;
};
export default TaskListEstimationCell;

View File

@@ -0,0 +1,26 @@
import { Flex } from 'antd';
import CustomColordLabel from '@/components/taskListCommon/labelsSelector/CustomColordLabel';
import CustomNumberLabel from '@/components/taskListCommon/labelsSelector/CustomNumberLabel';
import LabelsSelector from '@/components/taskListCommon/labelsSelector/LabelsSelector';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
interface TaskListLabelsCellProps {
task: IProjectTask;
}
const TaskListLabelsCell = ({ task }: TaskListLabelsCellProps) => {
return (
<Flex>
{task.labels?.map((label, index) => (
label.end && label.names && label.name ? (
<CustomNumberLabel key={`${label.id}-${index}`} labelList={label.names ?? []} namesString={label.name} />
) : (
<CustomColordLabel key={`${label.id}-${index}`} label={label} />
)
))}
<LabelsSelector task={task} />
</Flex>
);
};
export default TaskListLabelsCell;

View File

@@ -0,0 +1,13 @@
import { Tooltip, Typography } from 'antd';
import { durationDateFormat } from '@/utils/durationDateFormat';
import { formatDate } from '@/utils/timeUtils';
const TaskListLastUpdatedCell = ({ lastUpdated }: { lastUpdated: string | null }) => {
return (
<Tooltip title={lastUpdated ? formatDate(new Date(lastUpdated)) : 'N/A'}>
<Typography.Text>{durationDateFormat(lastUpdated || null)}</Typography.Text>
</Tooltip>
);
};
export default TaskListLastUpdatedCell;

View File

@@ -0,0 +1,21 @@
import Flex from 'antd/es/flex';
import Avatars from '@/components/avatars/avatars';
import AssigneeSelector from '@/components/taskListCommon/assignee-selector/assignee-selector';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
type TaskListMembersCellProps = {
groupId: string;
task: IProjectTask;
};
const TaskListMembersCell = ({ groupId, task }: TaskListMembersCellProps) => {
return (
<Flex gap={4} align="center" onClick={() => {}}>
<Avatars members={task.assignees || []} />
<AssigneeSelector task={task} groupId={groupId} />
</Flex>
);
};
export default TaskListMembersCell;

View File

@@ -0,0 +1,22 @@
/* Set the stroke width to 9px for the progress circle */
.task-progress.ant-progress-circle .ant-progress-circle-path {
stroke-width: 9px !important; /* Adjust the stroke width */
}
/* Adjust the inner check mark for better alignment and visibility */
.task-progress.ant-progress-circle.ant-progress-status-success .ant-progress-inner .anticon-check {
font-size: 8px; /* Adjust font size for the check mark */
color: green; /* Optional: Set a color */
transform: translate(-50%, -50%); /* Center align */
position: absolute;
top: 50%;
left: 50%;
padding: 0;
width: 8px;
}
/* Adjust the text inside the progress circle */
.task-progress.ant-progress-circle .ant-progress-text {
font-size: 10px; /* Ensure the text size fits well */
line-height: 1;
}

View File

@@ -0,0 +1,23 @@
import { Progress, Tooltip } from 'antd';
import './task-list-progress-cell.css';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
type TaskListProgressCellProps = {
task: IProjectTask;
};
const TaskListProgressCell = ({ task }: TaskListProgressCellProps) => {
return task.is_sub_task ? null : (
<Tooltip title={`${task.completed_count || 0} / ${task.total_tasks_count || 0}`}>
<Progress
percent={task.complete_ratio || 0}
type="circle"
size={24}
style={{ cursor: 'default' }}
strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7}
/>
</Tooltip>
);
};
export default TaskListProgressCell;

View File

@@ -0,0 +1,8 @@
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { Typography } from 'antd';
const TaskListReporterCell = ({ task }: { task: IProjectTask }) => {
return <Typography.Text>{task?.reporter}</Typography.Text>;
};
export default TaskListReporterCell;

View File

@@ -0,0 +1,49 @@
import { DatePicker } from 'antd';
import { colors } from '@/styles/colors';
import dayjs, { Dayjs } from 'dayjs';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { getUserSession } from '@/utils/session-helper';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
const TaskListStartDateCell = ({ task }: { task: IProjectTask }) => {
const { socket } = useSocket();
const startDayjs = task.start_date ? dayjs(task.start_date) : null;
const dueDayjs = task.end_date ? dayjs(task.end_date) : null;
const handleStartDateChange = (date: Dayjs | null) => {
socket?.emit(
SocketEvents.TASK_START_DATE_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
start_date: date?.format(),
parent_task: task.parent_task_id,
time_zone: getUserSession()?.timezone_name
? getUserSession()?.timezone_name
: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
);
};
const disabledStartDate = (current: Dayjs) => {
return current && dueDayjs ? current > dueDayjs : false;
};
return (
<DatePicker
placeholder="Set Date"
value={startDayjs}
onChange={handleStartDateChange}
format={'MMM DD, YYYY'}
suffixIcon={null}
disabledDate={disabledStartDate}
style={{
backgroundColor: colors.transparent,
border: 'none',
boxShadow: 'none',
}}
/>
);
};
export default TaskListStartDateCell;

View File

@@ -0,0 +1,13 @@
.open-task-button {
opacity: 0;
transition: opacity 0.2s ease-in-out;
}
.task-row:hover .open-task-button {
opacity: 1;
}
.edit-mode-cell {
position: relative;
z-index: 1;
}

View File

@@ -0,0 +1,245 @@
import { Flex, Typography, Button, Input, Tooltip } from 'antd';
import type { InputRef } from 'antd';
import {
DoubleRightOutlined,
DownOutlined,
RightOutlined,
ExpandAltOutlined,
CommentOutlined,
EyeOutlined,
PaperClipOutlined,
MinusCircleOutlined,
RetweetOutlined,
} from '@ant-design/icons';
import { colors } from '@/styles/colors';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import './task-list-task-cell.css';
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import { useState, useRef, useEffect } from 'react';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { fetchSubTasks } from '@/features/tasks/tasks.slice';
type TaskListTaskCellProps = {
task: IProjectTask;
isSubTask?: boolean;
toggleTaskExpansion: (taskId: string) => void;
projectId: string;
};
const TaskListTaskCell = ({
task,
isSubTask = false,
toggleTaskExpansion,
projectId,
}: TaskListTaskCellProps) => {
const { t } = useTranslation('task-list-table');
const { socket, connected } = useSocket();
const [editTaskName, setEditTaskName] = useState(false);
const [taskName, setTaskName] = useState(task.name || '');
const inputRef = useRef<InputRef>(null);
const wrapperRef = useRef<HTMLDivElement>(null);
const dispatch = useAppDispatch();
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
handleTaskNameSave();
}
};
if (editTaskName) {
document.addEventListener('mousedown', handleClickOutside);
inputRef.current?.focus();
}
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [editTaskName]);
const handleToggleExpansion = (taskId: string) => {
if (task.sub_tasks_count && task.sub_tasks_count > 0 && !task.sub_tasks) {
dispatch(fetchSubTasks({ taskId, projectId }));
}
toggleTaskExpansion(taskId);
};
const renderToggleButtonForHasSubTasks = (taskId: string | null, hasSubtasks: boolean) => {
if (!hasSubtasks || !taskId) return null;
return (
<button
onClick={() => handleToggleExpansion(taskId)}
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
>
{task.show_sub_tasks ? <DownOutlined /> : <RightOutlined />}
</button>
);
};
const renderToggleButtonForNonSubtasks = (
taskId: string,
isSubTask: boolean,
subTasksCount: number
) => {
if (subTasksCount > 0) {
return (
<button
onClick={() => handleToggleExpansion(taskId)}
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
>
{task.show_sub_tasks ? <DownOutlined /> : <RightOutlined />}
</button>
);
}
return !isSubTask ? (
<button
onClick={() => handleToggleExpansion(taskId)}
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54] open-task-button"
>
{task.show_sub_tasks ? <DownOutlined /> : <RightOutlined />}
</button>
) : (
<div className="h-4 w-4"></div>
);
};
const renderSubtasksCountLabel = (taskId: string, isSubTask: boolean, subTasksCount: number) => {
if (!taskId) return null;
return (
!isSubTask && (
<Button
onClick={() => handleToggleExpansion(taskId)}
size="small"
style={{
display: 'flex',
gap: 2,
paddingInline: 4,
alignItems: 'center',
justifyItems: 'center',
border: 'none',
}}
>
<Typography.Text style={{ fontSize: 12, lineHeight: 1 }}>{subTasksCount}</Typography.Text>
<DoubleRightOutlined style={{ fontSize: 10 }} />
</Button>
)
);
};
const handleTaskNameSave = () => {
const taskName = inputRef.current?.input?.value;
if (taskName?.trim() !== '' && connected) {
socket?.emit(
SocketEvents.TASK_NAME_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
name: taskName,
parent_task: task.parent_task_id,
})
);
setEditTaskName(false);
}
};
return (
<Flex
align="center"
justify="space-between"
className={editTaskName ? 'edit-mode-cell' : ''}
style={{
margin: editTaskName ? '-8px' : undefined,
border: editTaskName ? '1px solid #1677ff' : undefined,
backgroundColor: editTaskName ? 'rgba(22, 119, 255, 0.02)' : undefined,
minHeight: editTaskName ? '42px' : undefined,
}}
>
<Flex gap={8} align="center">
{!!task?.sub_tasks?.length ? (
renderToggleButtonForHasSubTasks(task.id || null, !!task?.sub_tasks?.length)
) : (
<div className="h-4 w-4">
{renderToggleButtonForNonSubtasks(task.id || '', isSubTask, task.sub_tasks_count || 0)}
</div>
)}
{isSubTask && <DoubleRightOutlined style={{ fontSize: 12 }} />}
<div ref={wrapperRef} style={{ flex: 1 }}>
{!editTaskName && (
<Typography.Text
ellipsis={{ tooltip: task.name }}
onClick={() => setEditTaskName(true)}
style={{ cursor: 'pointer', width: 'auto', maxWidth: '350px' }}
>
{task.name}
</Typography.Text>
)}
{editTaskName && (
<Input
ref={inputRef}
variant="borderless"
value={taskName}
onChange={e => setTaskName(e.target.value)}
autoFocus
onPressEnter={handleTaskNameSave}
style={{
width: 350,
padding: 0,
}}
/>
)}
</div>
{!editTaskName &&
renderSubtasksCountLabel(task.id || '', isSubTask, task.sub_tasks_count || 0)}
{task?.comments_count ? (
<CommentOutlined type="secondary" style={{ fontSize: 14 }} />
) : null}
{task?.has_subscribers ? <EyeOutlined type="secondary" style={{ fontSize: 14 }} /> : null}
{task?.attachments_count ? (
<PaperClipOutlined type="secondary" style={{ fontSize: 14 }} />
) : null}
{task?.has_dependencies ? (
<MinusCircleOutlined type="secondary" style={{ fontSize: 14 }} />
) : null}
{task?.schedule_id ? (
<Tooltip title="Recurring Task">
<RetweetOutlined type="secondary" style={{ fontSize: 14 }} />
</Tooltip>
) : null}
</Flex>
<div className="open-task-button">
<Button
type="text"
icon={<ExpandAltOutlined />}
onClick={() => {
dispatch(setSelectedTaskId(task.id || ''));
dispatch(setShowTaskDrawer(true));
}}
style={{
backgroundColor: colors.transparent,
padding: 0,
height: 'fit-content',
}}
>
{t('openButton')}
</Button>
</div>
</Flex>
);
};
export default TaskListTaskCell;

View File

@@ -0,0 +1,12 @@
import { Tag, Tooltip } from 'antd';
import React from 'react';
const TaskListTaskIdCell = ({ taskId }: { taskId: string | null }) => {
return (
<Tooltip title={taskId} className="flex justify-center">
<Tag>{taskId}</Tag>
</Tooltip>
);
};
export default TaskListTaskIdCell;

View File

@@ -0,0 +1,26 @@
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
import { useTaskTimer } from '@/hooks/useTaskTimer';
type TaskListTimeTrackerCellProps = {
task: IProjectTask;
};
const TaskListTimeTrackerCell = ({ task }: TaskListTimeTrackerCellProps) => {
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer(
task.id || '',
task.timer_start_time || null
);
return (
<TaskTimer
taskId={task.id || ''}
started={started}
handleStartTimer={handleStartTimer}
handleStopTimer={handleStopTimer}
timeString={timeString}
/>
);
};
export default TaskListTimeTrackerCell;

View File

@@ -0,0 +1,37 @@
import { Input } from 'antd';
import React, { useState } from 'react';
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
import { colors } from '../../../../../../styles/colors';
import { useTranslation } from 'react-i18next';
const AddSubTaskListRow = () => {
const [isEdit, setIsEdit] = useState<boolean>(false);
// localization
const { t } = useTranslation('task-list-table');
// get data theme data from redux
const themeMode = useAppSelector(state => state.themeReducer.mode);
const customBorderColor = themeMode === 'dark' && ' border-[#303030]';
return (
<div className={`border-t ${customBorderColor}`}>
{isEdit ? (
<Input
className="h-12 w-full rounded-none"
style={{ borderColor: colors.skyBlue }}
placeholder={t('addTaskInputPlaceholder')}
onBlur={() => setIsEdit(false)}
/>
) : (
<Input
onFocus={() => setIsEdit(true)}
className="w-[300px] border-none"
value={t('addSubTaskText')}
/>
)}
</div>
);
};
export default AddSubTaskListRow;

View File

@@ -0,0 +1,156 @@
import Input, { InputRef } from 'antd/es/input';
import { useMemo, useRef, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { colors } from '@/styles/colors';
import { useTranslation } from 'react-i18next';
import { SocketEvents } from '@/shared/socket-events';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { DRAWER_ANIMATION_INTERVAL } from '@/shared/constants';
import {
getCurrentGroup,
GROUP_BY_STATUS_VALUE,
GROUP_BY_PRIORITY_VALUE,
GROUP_BY_PHASE_VALUE,
addTask,
} from '@/features/tasks/tasks.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useSocket } from '@/socket/socketContext';
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
import { useAuthService } from '@/hooks/useAuth';
interface IAddTaskListRowProps {
groupId?: string | null;
parentTask?: string | null;
}
interface IAddNewTask extends IProjectTask {
groupId: string;
}
const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowProps) => {
const [isEdit, setIsEdit] = useState<boolean>(false);
const [taskName, setTaskName] = useState<string>('');
const [creatingTask, setCreatingTask] = useState<boolean>(false);
const taskInputRef = useRef<InputRef>(null);
const dispatch = useAppDispatch();
const currentSession = useAuthService().getCurrentSession();
const { socket } = useSocket();
const { t } = useTranslation('task-list-table');
const themeMode = useAppSelector(state => state.themeReducer.mode);
const customBorderColor = useMemo(() => themeMode === 'dark' && ' border-[#303030]', [themeMode]);
const projectId = useAppSelector(state => state.projectReducer.projectId);
const createRequestBody = (): ITaskCreateRequest | null => {
if (!projectId || !currentSession) return null;
const body: ITaskCreateRequest = {
project_id: projectId,
name: taskName,
reporter_id: currentSession.id,
team_id: currentSession.team_id,
};
const groupBy = getCurrentGroup();
if (groupBy.value === GROUP_BY_STATUS_VALUE) {
body.status_id = groupId || undefined;
} else if (groupBy.value === GROUP_BY_PRIORITY_VALUE) {
body.priority_id = groupId || undefined;
} else if (groupBy.value === GROUP_BY_PHASE_VALUE) {
body.phase_id = groupId || undefined;
}
if (parentTask) {
body.parent_task_id = parentTask;
}
return body;
};
const reset = (scroll = true) => {
setIsEdit(false);
setCreatingTask(false);
setTaskName('');
setIsEdit(true);
setTimeout(() => {
taskInputRef.current?.focus();
if (scroll) window.scrollTo(0, document.body.scrollHeight);
}, DRAWER_ANIMATION_INTERVAL);
};
const onNewTaskReceived = (task: IAddNewTask) => {
if (!groupId) return;
// Ensure we're adding the task with the correct group
const taskWithGroup = {
...task,
groupId: groupId,
};
// Add the task to the state
dispatch(
addTask({
task: taskWithGroup,
groupId,
insert: true,
})
);
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id || task.id);
// Reset the input state
reset(false);
};
const addInstantTask = async () => {
if (creatingTask || !projectId || !currentSession || taskName.trim() === '') 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) => {
setCreatingTask(false);
onNewTaskReceived(task as IAddNewTask);
});
} catch (error) {
console.error('Error adding task:', error);
setCreatingTask(false);
}
};
const handleAddTask = () => {
setIsEdit(false);
addInstantTask();
};
return (
<div>
{isEdit ? (
<Input
className="h-12 w-full rounded-none"
style={{ borderColor: colors.skyBlue }}
placeholder={t('addTaskInputPlaceholder')}
onChange={e => setTaskName(e.target.value)}
onBlur={handleAddTask}
onPressEnter={handleAddTask}
ref={taskInputRef}
/>
) : (
<Input
onFocus={() => setIsEdit(true)}
className="w-[300px] border-none"
value={parentTask ? t('addSubTaskText') : t('addTaskText')}
ref={taskInputRef}
/>
)}
</div>
);
};
export default AddTaskListRow;

View File

@@ -0,0 +1,15 @@
/* custom collapse styles for content box and the left border */
.ant-collapse-header {
margin-bottom: 6px !important;
}
.custom-collapse-content-box .ant-collapse-content-box {
padding: 0 !important;
}
:where(.css-dev-only-do-not-override-1w6wsvq).ant-collapse-ghost
> .ant-collapse-item
> .ant-collapse-content
> .ant-collapse-content-box {
padding: 0;
}

View File

@@ -0,0 +1,260 @@
import { useState, useEffect } from 'react'; // Add useEffect import
import { useTranslation } from 'react-i18next';
import { useDroppable } from '@dnd-kit/core';
import Flex from 'antd/es/flex';
import Badge from 'antd/es/badge';
import Button from 'antd/es/button';
import ConfigProvider from 'antd/es/config-provider';
import Dropdown from 'antd/es/dropdown';
import Input from 'antd/es/input';
import Typography from 'antd/es/typography';
import { MenuProps } from 'antd/es/menu';
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
import { colors } from '@/styles/colors';
import './task-list-table-wrapper.css';
import TaskListTable from '../task-list-table';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import Collapsible from '@/components/collapsible/collapsible';
import { fetchTaskGroups, fetchTaskListColumns, IGroupBy, updateTaskGroupColor } from '@/features/tasks/tasks.slice';
import { useAuthService } from '@/hooks/useAuth';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
import logger from '@/utils/errorLogger';
import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events';
import { ALPHA_CHANNEL } from '@/shared/constants';
import useIsProjectManager from '@/hooks/useIsProjectManager';
interface TaskListTableWrapperProps {
taskList: IProjectTask[];
tableId: string;
name: string;
groupBy: string;
color: string;
statusCategory?: string | null;
activeId?: string | null;
}
const TaskListTableWrapper = ({
taskList,
tableId,
name,
groupBy,
color,
statusCategory = null,
activeId,
}: TaskListTableWrapperProps) => {
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const { trackMixpanelEvent } = useMixpanelTracking();
const dispatch = useAppDispatch();
const isProjectManager = useIsProjectManager();
const [tableName, setTableName] = useState<string>(name);
const [showRenameInput, setShowRenameInput] = useState<boolean>(false);
const [isRenaming, setIsRenaming] = useState<boolean>(false);
const [isExpanded, setIsExpanded] = useState<boolean>(true);
const [currentCategory, setCurrentCategory] = useState<string | null>(statusCategory);
const { t } = useTranslation('task-list-table');
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
const { projectId } = useAppSelector(state => state.projectReducer);
const { setNodeRef, isOver } = useDroppable({
id: tableId,
data: { groupId: tableId },
});
// Sync currentCategory with statusCategory prop when it changes
useEffect(() => {
setCurrentCategory(statusCategory);
}, [statusCategory]);
const handlToggleExpand = (e: React.MouseEvent) => {
if (isRenaming || showRenameInput) {
e.stopPropagation();
return;
}
setIsExpanded(!isExpanded);
};
const handleInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === ' ') {
e.stopPropagation();
}
};
const updateStatus = async (categoryId = currentCategory) => {
if (!categoryId || !projectId || !tableId) return;
const body: ITaskStatusUpdateModel = {
name: tableName.trim(),
project_id: projectId,
category_id: categoryId,
};
const res = await statusApiService.updateStatus(tableId, body, projectId);
if (res.done) {
setCurrentCategory(categoryId); // Update local state immediately
dispatch(fetchTaskListColumns(projectId));
dispatch(fetchPhasesByProjectId(projectId));
dispatch(fetchTaskGroups(projectId));
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Status' });
if (res.body.color_code) {
dispatch(
updateTaskGroupColor({
groupId: tableId,
colorCode: res.body.color_code + ALPHA_CHANNEL,
})
);
}
dispatch(fetchStatuses(projectId));
}
};
const handleRename = async () => {
if (!projectId || isRenaming || !(isOwnerOrAdmin || isProjectManager) || !tableId) return;
if (tableName.trim() === name.trim()) {
setShowRenameInput(false);
return;
}
setShowRenameInput(false);
setIsRenaming(true);
try {
if (groupBy === IGroupBy.STATUS) {
await updateStatus();
} else if (groupBy === IGroupBy.PHASE) {
const body = { id: tableId, name: tableName.trim() };
const res = await phasesApiService.updateNameOfPhase(tableId, body as ITaskPhase, projectId);
if (res.done) {
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' });
dispatch(fetchPhasesByProjectId(projectId));
}
}
} catch (error) {
logger.error('Error renaming:', error);
setTableName(name);
setShowRenameInput(true);
} finally {
setIsRenaming(false);
}
};
const handleBlurOrEnter = () => {
handleRename();
setShowRenameInput(false);
};
const handleCategoryChange = async (categoryId: string) => {
trackMixpanelEvent(evt_project_board_column_setting_click, { 'Change category': 'Status' });
await updateStatus(categoryId); // Update backend and Redux store
};
const items: MenuProps['items'] = [
{
key: '1',
icon: <EditOutlined />,
label: groupBy === IGroupBy.STATUS ? 'Rename status' : 'Rename phase',
onClick: () => setShowRenameInput(true),
},
groupBy === IGroupBy.STATUS && {
key: '2',
icon: <RetweetOutlined />,
label: 'Change category',
children: statusCategories?.map(status => ({
key: status.id,
label: (
<Flex
gap={8}
onClick={() => status.id && handleCategoryChange(status.id)}
style={currentCategory === status.id ? { fontWeight: 700 } : {}} // Use currentCategory here
>
<Badge color={status.color_code} />
{status.name}
</Flex>
),
})),
},
].filter(Boolean) as MenuProps['items'];
const isEditable = isOwnerOrAdmin || isProjectManager;
return (
<div ref={setNodeRef}>
<ConfigProvider
wave={{ disabled: true }}
theme={{
components: {
Select: {
colorBorder: colors.transparent,
},
},
}}
>
<Flex vertical>
<Flex style={{ transform: 'translateY(6px)' }}>
<Button
className="custom-collapse-button"
style={{
backgroundColor: color,
border: 'none',
borderBottomLeftRadius: isExpanded ? 0 : 4,
borderBottomRightRadius: isExpanded ? 0 : 4,
color: colors.darkGray,
}}
icon={<RightOutlined rotate={isExpanded ? 90 : 0} />}
onClick={handlToggleExpand}
>
{(showRenameInput && name !== 'Unmapped') ? (
<Input
size="small"
value={tableName}
onChange={e => setTableName(e.target.value)}
onBlur={handleBlurOrEnter}
onPressEnter={handleBlurOrEnter}
onKeyDown={handleInputKeyDown}
onClick={e => e.stopPropagation()}
autoFocus
style={{ cursor: 'text' }}
/>
) : (
<Typography.Text
style={{
fontSize: 14,
fontWeight: 600,
}}
>
{t(tableName)} ({taskList.length})
</Typography.Text>
)}
</Button>
{groupBy !== IGroupBy.PRIORITY && !showRenameInput && isEditable && name !== 'Unmapped' && (
<Dropdown menu={{ items }}>
<Button
icon={<EllipsisOutlined />}
className="borderless-icon-btn"
title={isEditable ? undefined : t('noPermission')}
/>
</Dropdown>
)}
</Flex>
<Collapsible
isOpen={isExpanded}
className={`border-l-[3px] relative after:content after:absolute after:h-full after:w-1 after:z-10 after:top-0 after:left-0`}
color={color}
>
<TaskListTable taskList={taskList} tableId={tableId} activeId={activeId} />
</Collapsible>
</Flex>
</ConfigProvider>
</div>
);
};
export default TaskListTableWrapper;

View File

@@ -0,0 +1,122 @@
import { Button, ConfigProvider, Flex, Form, Mentions, Space, Tooltip, Typography } from 'antd';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '../../../../hooks/useAppSelector';
import CustomAvatar from '../../../../components/CustomAvatar';
import { colors } from '../../../../styles/colors';
import { relative } from 'path';
const ProjectViewUpdates = () => {
const [characterLength, setCharacterLength] = useState<number>(0);
const [isCommentBoxExpand, setIsCommentBoxExpand] = useState<boolean>(false);
// localization
const { t } = useTranslation('projectViewUpdatesTab');
const [form] = Form.useForm();
// get member list from project members slice
const projectMembersList = useAppSelector(state => state.projectMemberReducer.membersList);
// function to handle cancel
const handleCancel = () => {
form.resetFields(['comment']);
setCharacterLength(0);
setIsCommentBoxExpand(false);
};
// mentions options
const mentionsOptions = projectMembersList
? projectMembersList.map(member => ({
value: member.memberName,
label: member.memberName,
}))
: [];
return (
<Flex gap={24} vertical>
<Flex vertical>
<Flex gap={8}>
<CustomAvatar avatarName="Sachintha Prasd" />
<Flex vertical>
<Space>
<Typography.Text style={{ fontSize: 13, color: colors.lightGray }}>
Sachintha Prasad
</Typography.Text>
<Tooltip title="Nov 25,2024,10.45.54 AM">
<Typography.Text style={{ fontSize: 13, color: colors.deepLightGray }}>
7 hours ago
</Typography.Text>
</Tooltip>
</Space>
<Typography.Paragraph>Hello this is a test message</Typography.Paragraph>
<ConfigProvider
wave={{ disabled: true }}
theme={{
components: {
Button: {
defaultColor: colors.lightGray,
defaultHoverColor: colors.darkGray,
},
},
}}
>
<Button
type="text"
style={{
width: 'fit-content',
border: 'none',
backgroundColor: 'transparent',
padding: 0,
fontSize: 13,
height: 24,
}}
>
{t('deleteButton')}
</Button>
</ConfigProvider>
</Flex>
</Flex>
</Flex>
<Form form={form}>
<Form.Item name={'comment'}>
<Mentions
placeholder={t('inputPlaceholder')}
options={mentionsOptions}
autoSize
maxLength={2000}
onClick={() => setIsCommentBoxExpand(true)}
onChange={e => setCharacterLength(e.length)}
style={{
minHeight: isCommentBoxExpand ? 180 : 60,
paddingBlockEnd: 24,
}}
/>
<span
style={{
position: 'absolute',
bottom: 4,
right: 12,
color: colors.lightGray,
}}
>{`${characterLength}/2000`}</span>
</Form.Item>
{isCommentBoxExpand && (
<Form.Item>
<Flex gap={8} justify="flex-end">
<Button onClick={handleCancel}>{t('cancelButton')}</Button>
<Button type="primary" disabled={characterLength === 0}>
{t('addButton')}
</Button>
</Flex>
</Form.Item>
)}
</Form>
</Flex>
);
};
export default ProjectViewUpdates;