Merge branch 'feature/project-list-grouping' into upstream/feature/project-groupby
This commit is contained in:
@@ -24,8 +24,8 @@ import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { getUserSession, setSession } from '@/utils/session-helper';
|
||||
import { validateEmail } from '@/utils/validateEmail';
|
||||
import { sanitizeInput } from '@/utils/sanitizeInput';
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import logoDark from '@/assets/images/logo-dark-mode.png';
|
||||
import logo from '@/assets/images/worklenz-light-mode.png';
|
||||
import logoDark from '@/assets/images/worklenz-dark-mode.png';
|
||||
|
||||
import './account-setup.css';
|
||||
import { IAccountSetupRequest } from '@/types/project-templates/project-templates.types';
|
||||
|
||||
@@ -77,6 +77,18 @@ const LoginPage: React.FC = () => {
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Check and unregister ngsw-worker if present
|
||||
if ('serviceWorker' in navigator) {
|
||||
navigator.serviceWorker.getRegistrations().then(function(registrations) {
|
||||
const ngswWorker = registrations.find(reg => reg.active?.scriptURL.includes('ngsw-worker'));
|
||||
if (ngswWorker) {
|
||||
ngswWorker.unregister().then(() => {
|
||||
window.location.reload();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
trackMixpanelEvent(evt_login_page_visit);
|
||||
if (currentSession && !currentSession?.setup_completed) {
|
||||
navigate('/worklenz/setup');
|
||||
|
||||
@@ -132,7 +132,7 @@ const RecentAndFavouriteProjectList = () => {
|
||||
<div style={{ maxHeight: 420, overflow: 'auto' }}>
|
||||
{projectsData?.body?.length === 0 ? (
|
||||
<Empty
|
||||
image="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||
image="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
||||
imageStyle={{ height: 60 }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
||||
@@ -89,7 +89,7 @@ const TasksList: React.FC = React.memo(() => {
|
||||
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleSelectTask = useCallback((task : IMyTask) => {
|
||||
const handleSelectTask = useCallback((task: IMyTask) => {
|
||||
dispatch(setSelectedTaskId(task.id || ''));
|
||||
dispatch(fetchTask({ taskId: task.id || '', projectId: task.project_id || '' }));
|
||||
dispatch(setProjectId(task.project_id || ''));
|
||||
@@ -155,7 +155,7 @@ const TasksList: React.FC = React.memo(() => {
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<Tooltip title={record.project_name}>
|
||||
<Typography.Paragraph style={{ margin: 0, paddingInlineEnd: 6, maxWidth:120 }} ellipsis={{ tooltip: true }}>
|
||||
<Typography.Paragraph style={{ margin: 0, paddingInlineEnd: 6, maxWidth: 120 }} ellipsis={{ tooltip: true }}>
|
||||
<Badge color={record.phase_color || 'blue'} style={{ marginInlineEnd: 4 }} />
|
||||
{record.project_name}
|
||||
</Typography.Paragraph>
|
||||
@@ -259,7 +259,7 @@ const TasksList: React.FC = React.memo(() => {
|
||||
<Skeleton active />
|
||||
) : data?.body.total === 0 ? (
|
||||
<EmptyListPlaceholder
|
||||
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
||||
text=" No tasks to show."
|
||||
/>
|
||||
) : (
|
||||
@@ -271,10 +271,10 @@ const TasksList: React.FC = React.memo(() => {
|
||||
columns={columns as TableProps<IMyTask>['columns']}
|
||||
size="middle"
|
||||
rowClassName={() => 'custom-row-height'}
|
||||
loading={homeTasksFetching && !skipAutoRefetch}
|
||||
loading={homeTasksFetching && skipAutoRefetch}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
|
||||
<div style={{ marginTop: 16, textAlign: 'right', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
|
||||
@@ -147,7 +147,7 @@ const TodoList = () => {
|
||||
<div style={{ maxHeight: 420, overflow: 'auto' }}>
|
||||
{data?.body.length === 0 ? (
|
||||
<EmptyListPlaceholder
|
||||
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
||||
text={t('home:todoList.noTasks')}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useEffect, useState, useRef, useMemo } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import TaskListFilters from '../taskList/task-list-filters/task-list-filters';
|
||||
import { Flex, Skeleton } from 'antd';
|
||||
@@ -35,7 +35,6 @@ import { evt_project_board_visit, evt_project_task_list_drag_and_move } from '@/
|
||||
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 = () => {
|
||||
@@ -45,7 +44,10 @@ const ProjectViewBoard = () => {
|
||||
const authService = useAuthService();
|
||||
const currentSession = authService.getCurrentSession();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const [ currentTaskIndex, setCurrentTaskIndex] = useState(-1);
|
||||
const [currentTaskIndex, setCurrentTaskIndex] = useState(-1);
|
||||
// Add local loading state to immediately show skeleton
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer);
|
||||
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
|
||||
@@ -56,14 +58,34 @@ const ProjectViewBoard = () => {
|
||||
// Store the original source group ID when drag starts
|
||||
const originalSourceGroupIdRef = useRef<string | null>(null);
|
||||
|
||||
// Update loading state based on all loading conditions
|
||||
useEffect(() => {
|
||||
if (projectId && groupBy && projectView === 'kanban') {
|
||||
if (!loadingGroups) {
|
||||
dispatch(fetchBoardTaskGroups(projectId));
|
||||
setIsLoading(loadingGroups || loadingStatusCategories);
|
||||
}, [loadingGroups, loadingStatusCategories]);
|
||||
|
||||
// Load data efficiently with async/await and Promise.all
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
if (projectId && groupBy && projectView === 'kanban') {
|
||||
const promises = [];
|
||||
|
||||
if (!loadingGroups) {
|
||||
promises.push(dispatch(fetchBoardTaskGroups(projectId)));
|
||||
}
|
||||
|
||||
if (!statusCategories.length) {
|
||||
promises.push(dispatch(fetchStatusesCategories()));
|
||||
}
|
||||
|
||||
// Wait for all data to load
|
||||
await Promise.all(promises);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, [dispatch, projectId, groupBy, projectView, search, archived]);
|
||||
|
||||
// Create sensors with memoization to prevent unnecessary re-renders
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
// Require the mouse to move by 10 pixels before activating
|
||||
@@ -394,18 +416,16 @@ const ProjectViewBoard = () => {
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
// Track analytics event on component mount
|
||||
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'>
|
||||
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
|
||||
@@ -105,8 +105,8 @@ const OverLoggedTasksTable = () => {
|
||||
{
|
||||
key: 'overLoggedTime',
|
||||
title: 'Over Logged Time',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>{record.overlogged_time}</Typography.Text>
|
||||
render: (_, record: IInsightTasks) => (
|
||||
<Typography.Text>{record.overlogged_time_string}</Typography.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { format } from 'date-fns';
|
||||
import html2canvas from 'html2canvas';
|
||||
import jsPDF from 'jspdf';
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import logo from '@/assets/images/worklenz-light-mode.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';
|
||||
|
||||
|
||||
@@ -263,7 +263,7 @@ const ProjectViewMembers = () => {
|
||||
>
|
||||
{members?.total === 0 ? (
|
||||
<EmptyListPlaceholder
|
||||
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
|
||||
imageHeight={120}
|
||||
text={t('emptyText')}
|
||||
/>
|
||||
|
||||
@@ -22,7 +22,7 @@ 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 { setProject, setImportTaskTemplateDrawerOpen, setRefreshTimestamp, getProject } 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';
|
||||
@@ -70,6 +70,7 @@ const ProjectViewHeader = () => {
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (!projectId) return;
|
||||
dispatch(getProject(projectId));
|
||||
switch (tab) {
|
||||
case 'tasks-list':
|
||||
dispatch(fetchTaskListColumns(projectId));
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useState, useMemo, useCallback } 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';
|
||||
@@ -43,6 +43,14 @@ const ProjectView = () => {
|
||||
const [pinnedTab, setPinnedTab] = useState<string>(searchParams.get('pinned_tab') || '');
|
||||
const [taskid, setTaskId] = useState<string>(searchParams.get('task') || '');
|
||||
|
||||
const resetProjectData = useCallback(() => {
|
||||
dispatch(setProjectId(null));
|
||||
dispatch(resetStatuses());
|
||||
dispatch(deselectAll());
|
||||
dispatch(resetTaskListData());
|
||||
dispatch(resetBoardData());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
dispatch(setProjectId(projectId));
|
||||
@@ -59,9 +67,13 @@ const ProjectView = () => {
|
||||
dispatch(setSelectedTaskId(taskid || ''));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}
|
||||
}, [dispatch, navigate, projectId, taskid]);
|
||||
|
||||
const pinToDefaultTab = async (itemKey: string) => {
|
||||
return () => {
|
||||
resetProjectData();
|
||||
};
|
||||
}, [dispatch, navigate, projectId, taskid, resetProjectData]);
|
||||
|
||||
const pinToDefaultTab = useCallback(async (itemKey: string) => {
|
||||
if (!itemKey || !projectId) return;
|
||||
|
||||
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
|
||||
@@ -88,9 +100,9 @@ const ProjectView = () => {
|
||||
}).toString(),
|
||||
});
|
||||
}
|
||||
};
|
||||
}, [projectId, activeTab, navigate]);
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
const handleTabChange = useCallback((key: string) => {
|
||||
setActiveTab(key);
|
||||
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
|
||||
navigate({
|
||||
@@ -100,9 +112,9 @@ const ProjectView = () => {
|
||||
pinned_tab: pinnedTab,
|
||||
}).toString(),
|
||||
});
|
||||
};
|
||||
}, [dispatch, location.pathname, navigate, pinnedTab]);
|
||||
|
||||
const tabMenuItems = tabItems.map(item => ({
|
||||
const tabMenuItems = useMemo(() => tabItems.map(item => ({
|
||||
key: item.key,
|
||||
label: (
|
||||
<Flex align="center" style={{ color: colors.skyBlue }}>
|
||||
@@ -144,21 +156,17 @@ const ProjectView = () => {
|
||||
</Flex>
|
||||
),
|
||||
children: item.element,
|
||||
}));
|
||||
})), [pinnedTab, pinToDefaultTab]);
|
||||
|
||||
const resetProjectData = () => {
|
||||
dispatch(setProjectId(null));
|
||||
dispatch(resetStatuses());
|
||||
dispatch(deselectAll());
|
||||
dispatch(resetTaskListData());
|
||||
dispatch(resetBoardData());
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
resetProjectData();
|
||||
};
|
||||
}, []);
|
||||
const portalElements = useMemo(() => (
|
||||
<>
|
||||
{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')}
|
||||
</>
|
||||
), []);
|
||||
|
||||
return (
|
||||
<div style={{ marginBlockStart: 80, marginBlockEnd: 24, minHeight: '80vh' }}>
|
||||
@@ -169,34 +177,12 @@ const ProjectView = () => {
|
||||
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>
|
||||
// }
|
||||
destroyOnHidden={true}
|
||||
/>
|
||||
|
||||
{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')}
|
||||
{portalElements}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectView;
|
||||
export default React.memo(ProjectView);
|
||||
|
||||
@@ -0,0 +1,241 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
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 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 { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import Collapsible from '@/components/collapsible/collapsible';
|
||||
import TaskListTable from '../../task-list-table/task-list-table';
|
||||
import { IGroupBy, updateTaskGroupColor } from '@/features/tasks/tasks.slice';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
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 { 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';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
interface TaskGroupProps {
|
||||
taskGroup: ITaskListGroup;
|
||||
groupBy: string;
|
||||
color: string;
|
||||
activeId?: string | null;
|
||||
}
|
||||
|
||||
const TaskGroup: React.FC<TaskGroupProps> = ({
|
||||
taskGroup,
|
||||
groupBy,
|
||||
color,
|
||||
activeId
|
||||
}) => {
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const dispatch = useAppDispatch();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const isProjectManager = useIsProjectManager();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(true);
|
||||
const [isRenaming, setIsRenaming] = useState(false);
|
||||
const [groupName, setGroupName] = useState(taskGroup.name || '');
|
||||
|
||||
const { projectId } = useAppSelector((state: any) => state.projectReducer);
|
||||
const themeMode = useAppSelector((state: any) => state.themeReducer.mode);
|
||||
|
||||
// Memoize droppable configuration
|
||||
const { setNodeRef } = useDroppable({
|
||||
id: taskGroup.id,
|
||||
data: {
|
||||
type: 'group',
|
||||
groupId: taskGroup.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Memoize task count
|
||||
const taskCount = useMemo(() => taskGroup.tasks?.length || 0, [taskGroup.tasks]);
|
||||
|
||||
// Memoize dropdown items
|
||||
const dropdownItems: MenuProps['items'] = useMemo(() => {
|
||||
if (groupBy !== IGroupBy.STATUS || !isProjectManager) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
key: 'rename',
|
||||
label: t('renameText'),
|
||||
icon: <EditOutlined />,
|
||||
onClick: () => setIsRenaming(true),
|
||||
},
|
||||
{
|
||||
key: 'change-category',
|
||||
label: t('changeCategoryText'),
|
||||
icon: <RetweetOutlined />,
|
||||
children: [
|
||||
{
|
||||
key: 'todo',
|
||||
label: t('todoText'),
|
||||
onClick: () => handleStatusCategoryChange('0'),
|
||||
},
|
||||
{
|
||||
key: 'doing',
|
||||
label: t('doingText'),
|
||||
onClick: () => handleStatusCategoryChange('1'),
|
||||
},
|
||||
{
|
||||
key: 'done',
|
||||
label: t('doneText'),
|
||||
onClick: () => handleStatusCategoryChange('2'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
}, [groupBy, isProjectManager, t]);
|
||||
|
||||
const handleStatusCategoryChange = async (category: string) => {
|
||||
if (!projectId || !taskGroup.id) return;
|
||||
|
||||
try {
|
||||
await statusApiService.updateStatus({
|
||||
id: taskGroup.id,
|
||||
category_id: category,
|
||||
project_id: projectId,
|
||||
});
|
||||
|
||||
dispatch(fetchStatuses());
|
||||
trackMixpanelEvent(evt_project_board_column_setting_click, {
|
||||
column_id: taskGroup.id,
|
||||
action: 'change_category',
|
||||
category,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating status category:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRename = async () => {
|
||||
if (!projectId || !taskGroup.id || !groupName.trim()) return;
|
||||
|
||||
try {
|
||||
if (groupBy === IGroupBy.STATUS) {
|
||||
await statusApiService.updateStatus({
|
||||
id: taskGroup.id,
|
||||
name: groupName.trim(),
|
||||
project_id: projectId,
|
||||
});
|
||||
dispatch(fetchStatuses());
|
||||
} else if (groupBy === IGroupBy.PHASE) {
|
||||
const phaseData: ITaskPhase = {
|
||||
id: taskGroup.id,
|
||||
name: groupName.trim(),
|
||||
project_id: projectId,
|
||||
color_code: taskGroup.color_code,
|
||||
};
|
||||
await phasesApiService.updatePhase(phaseData);
|
||||
dispatch(fetchPhasesByProjectId(projectId));
|
||||
}
|
||||
|
||||
setIsRenaming(false);
|
||||
} catch (error) {
|
||||
logger.error('Error renaming group:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleColorChange = async (newColor: string) => {
|
||||
if (!projectId || !taskGroup.id) return;
|
||||
|
||||
try {
|
||||
const baseColor = newColor.endsWith(ALPHA_CHANNEL)
|
||||
? newColor.slice(0, -ALPHA_CHANNEL.length)
|
||||
: newColor;
|
||||
|
||||
if (groupBy === IGroupBy.PHASE) {
|
||||
const phaseData: ITaskPhase = {
|
||||
id: taskGroup.id,
|
||||
name: taskGroup.name || '',
|
||||
project_id: projectId,
|
||||
color_code: baseColor,
|
||||
};
|
||||
await phasesApiService.updatePhase(phaseData);
|
||||
dispatch(fetchPhasesByProjectId(projectId));
|
||||
}
|
||||
|
||||
dispatch(updateTaskGroupColor({
|
||||
groupId: taskGroup.id,
|
||||
color: baseColor,
|
||||
}));
|
||||
} catch (error) {
|
||||
logger.error('Error updating group color:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef}>
|
||||
<Flex vertical>
|
||||
{/* Group Header */}
|
||||
<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,
|
||||
minWidth: 200,
|
||||
}}
|
||||
icon={<RightOutlined rotate={isExpanded ? 90 : 0} />}
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<Input
|
||||
size="small"
|
||||
value={groupName}
|
||||
onChange={e => setGroupName(e.target.value)}
|
||||
onBlur={handleRename}
|
||||
onPressEnter={handleRename}
|
||||
onClick={e => e.stopPropagation()}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text style={{ fontSize: 14, fontWeight: 600 }}>
|
||||
{taskGroup.name} ({taskCount})
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{dropdownItems.length > 0 && !isRenaming && (
|
||||
<Dropdown menu={{ items: dropdownItems }} trigger={['click']}>
|
||||
<Button icon={<EllipsisOutlined />} className="borderless-icon-btn" />
|
||||
</Dropdown>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
{/* Task List */}
|
||||
<Collapsible isOpen={isExpanded}>
|
||||
<TaskListTable
|
||||
taskList={taskGroup.tasks || []}
|
||||
tableId={taskGroup.id}
|
||||
groupBy={groupBy}
|
||||
color={color}
|
||||
activeId={activeId}
|
||||
/>
|
||||
</Collapsible>
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TaskGroup);
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useEffect, useState, useMemo } 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 TaskGroupWrapperOptimized from './task-group-wrapper-optimized';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { fetchTaskGroups, fetchTaskListColumns } from '@/features/tasks/tasks.slice';
|
||||
@@ -17,48 +17,99 @@ const ProjectViewTaskList = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { projectView } = useTabSearchParam();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
|
||||
|
||||
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);
|
||||
// Split selectors to prevent unnecessary rerenders
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
const taskGroups = useAppSelector(state => state.taskReducer.taskGroups);
|
||||
const loadingGroups = useAppSelector(state => state.taskReducer.loadingGroups);
|
||||
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
|
||||
const archived = useAppSelector(state => state.taskReducer.archived);
|
||||
const fields = useAppSelector(state => state.taskReducer.fields);
|
||||
const search = useAppSelector(state => state.taskReducer.search);
|
||||
|
||||
const statusCategories = useAppSelector(state => state.taskStatusReducer.statusCategories);
|
||||
const loadingStatusCategories = useAppSelector(state => state.taskStatusReducer.loading);
|
||||
|
||||
const loadingPhases = useAppSelector(state => state.phaseReducer.loadingPhases);
|
||||
|
||||
// Single source of truth for loading state - EXCLUDE labels loading from skeleton
|
||||
// Labels loading should not block the main task list display
|
||||
const isLoading = useMemo(() =>
|
||||
loadingGroups || loadingPhases || loadingStatusCategories || !initialLoadComplete,
|
||||
[loadingGroups, loadingPhases, loadingStatusCategories, initialLoadComplete]
|
||||
);
|
||||
|
||||
// Memoize the empty state check
|
||||
const isEmptyState = useMemo(() =>
|
||||
taskGroups && taskGroups.length === 0 && !isLoading,
|
||||
[taskGroups, isLoading]
|
||||
);
|
||||
|
||||
// Handle view type changes
|
||||
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);
|
||||
const newParams = new URLSearchParams(searchParams);
|
||||
newParams.set('tab', 'tasks-list');
|
||||
newParams.set('pinned_tab', 'tasks-list');
|
||||
setSearchParams(newParams);
|
||||
}
|
||||
}, [projectView, searchParams, setSearchParams]);
|
||||
}, [projectView, setSearchParams, searchParams]);
|
||||
|
||||
// Batch initial data fetching - core data only
|
||||
useEffect(() => {
|
||||
if (projectId && groupBy) {
|
||||
if (!loadingColumns) dispatch(fetchTaskListColumns(projectId));
|
||||
if (!loadingPhases) dispatch(fetchPhasesByProjectId(projectId));
|
||||
if (!loadingGroups && projectView === 'list') {
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
const fetchInitialData = async () => {
|
||||
if (!projectId || !groupBy || initialLoadComplete) return;
|
||||
|
||||
try {
|
||||
// Batch only essential API calls for initial load
|
||||
// Filter data (labels, assignees, etc.) will load separately and not block the UI
|
||||
await Promise.allSettled([
|
||||
dispatch(fetchTaskListColumns(projectId)),
|
||||
dispatch(fetchPhasesByProjectId(projectId)),
|
||||
dispatch(fetchStatusesCategories()),
|
||||
]);
|
||||
setInitialLoadComplete(true);
|
||||
} catch (error) {
|
||||
console.error('Error fetching initial data:', error);
|
||||
setInitialLoadComplete(true); // Still mark as complete to prevent infinite loading
|
||||
}
|
||||
}
|
||||
if (!statusCategories.length) {
|
||||
dispatch(fetchStatusesCategories());
|
||||
}
|
||||
}, [dispatch, projectId, groupBy, fields, search, archived]);
|
||||
};
|
||||
|
||||
fetchInitialData();
|
||||
}, [projectId, groupBy, dispatch, initialLoadComplete]);
|
||||
|
||||
// Fetch task groups with dependency on initial load completion
|
||||
useEffect(() => {
|
||||
const fetchTasks = async () => {
|
||||
if (!projectId || !groupBy || projectView !== 'list' || !initialLoadComplete) return;
|
||||
|
||||
try {
|
||||
await dispatch(fetchTaskGroups(projectId));
|
||||
} catch (error) {
|
||||
console.error('Error fetching task groups:', error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchTasks();
|
||||
}, [projectId, groupBy, projectView, dispatch, fields, search, archived, initialLoadComplete]);
|
||||
|
||||
// Memoize the task groups to prevent unnecessary re-renders
|
||||
const memoizedTaskGroups = useMemo(() => taskGroups || [], [taskGroups]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||
{/* Filters load independently and don't block the main content */}
|
||||
<TaskListFilters position="list" />
|
||||
|
||||
{(taskGroups.length === 0 && !loadingGroups) ? (
|
||||
{isEmptyState ? (
|
||||
<Empty description="No tasks group found" />
|
||||
) : (
|
||||
<Skeleton active loading={loadingGroups} className='mt-4 p-4'>
|
||||
<TaskGroupWrapper taskGroups={taskGroups} groupBy={groupBy} />
|
||||
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
||||
<TaskGroupWrapperOptimized
|
||||
taskGroups={memoizedTaskGroups}
|
||||
groupBy={groupBy}
|
||||
/>
|
||||
</Skeleton>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Flex from 'antd/es/flex';
|
||||
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
pointerWithin,
|
||||
} from '@dnd-kit/core';
|
||||
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
import TaskListTableWrapper from './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 { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||
import { useTaskDragAndDrop } from '@/hooks/useTaskDragAndDrop';
|
||||
|
||||
interface TaskGroupWrapperOptimizedProps {
|
||||
taskGroups: ITaskListGroup[];
|
||||
groupBy: string;
|
||||
}
|
||||
|
||||
const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOptimizedProps) => {
|
||||
const themeMode = useAppSelector((state: any) => state.themeReducer.mode);
|
||||
|
||||
// Use extracted hooks
|
||||
useTaskSocketHandlers();
|
||||
const {
|
||||
activeId,
|
||||
sensors,
|
||||
handleDragStart,
|
||||
handleDragEnd,
|
||||
handleDragOver,
|
||||
resetTaskRowStyles,
|
||||
} = useTaskDragAndDrop({ taskGroups, groupBy });
|
||||
|
||||
// Memoize task groups with colors
|
||||
const taskGroupsWithColors = useMemo(() =>
|
||||
taskGroups?.map(taskGroup => ({
|
||||
...taskGroup,
|
||||
displayColor: themeMode === 'dark' ? taskGroup.color_code_dark : taskGroup.color_code,
|
||||
})) || [],
|
||||
[taskGroups, themeMode]
|
||||
);
|
||||
|
||||
// Add drag styles
|
||||
useEffect(() => {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
.task-row[data-is-dragging="true"] {
|
||||
opacity: 0.5 !important;
|
||||
transform: rotate(5deg) !important;
|
||||
z-index: 1000 !important;
|
||||
position: relative !important;
|
||||
}
|
||||
.task-row {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
return () => {
|
||||
document.head.removeChild(style);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle animation cleanup after drag ends
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (activeId === null) {
|
||||
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>
|
||||
{taskGroupsWithColors.map(taskGroup => (
|
||||
<TaskListTableWrapper
|
||||
key={taskGroup.id}
|
||||
taskList={taskGroup.tasks}
|
||||
tableId={taskGroup.id}
|
||||
name={taskGroup.name}
|
||||
groupBy={groupBy}
|
||||
statusCategory={taskGroup.category_id}
|
||||
color={taskGroup.displayColor}
|
||||
activeId={activeId}
|
||||
/>
|
||||
))}
|
||||
|
||||
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
|
||||
|
||||
{createPortal(
|
||||
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
||||
document.body,
|
||||
'task-template-drawer'
|
||||
)}
|
||||
</Flex>
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default React.memo(TaskGroupWrapperOptimized);
|
||||
@@ -7,6 +7,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
|
||||
import {
|
||||
fetchLabelsByProject,
|
||||
fetchTaskAssignees,
|
||||
@@ -33,23 +34,49 @@ const TaskListFilters: React.FC<TaskListFiltersProps> = ({ position }) => {
|
||||
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());
|
||||
|
||||
// Load filter data asynchronously and non-blocking
|
||||
// This runs independently of the main task list loading
|
||||
useEffect(() => {
|
||||
const fetchInitialData = async () => {
|
||||
if (!priorities.length) await dispatch(fetchPriorities());
|
||||
if (projectId) {
|
||||
await dispatch(fetchLabelsByProject(projectId));
|
||||
await dispatch(fetchTaskAssignees(projectId));
|
||||
const loadFilterData = async () => {
|
||||
try {
|
||||
// Load priorities first (usually cached/fast)
|
||||
if (!priorities.length) {
|
||||
dispatch(fetchPriorities());
|
||||
}
|
||||
|
||||
// Load project-specific filter data in parallel, but don't await
|
||||
// This allows the main task list to load while filters are still loading
|
||||
if (projectId) {
|
||||
// Fire and forget - these will update the UI when ready
|
||||
dispatch(fetchLabelsByProject(projectId));
|
||||
dispatch(fetchTaskAssignees(projectId));
|
||||
}
|
||||
|
||||
// Load team members (usually needed for member filters)
|
||||
dispatch(getTeamMembers({
|
||||
index: 0,
|
||||
size: 100,
|
||||
field: null,
|
||||
order: null,
|
||||
search: null,
|
||||
all: true
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Error loading filter data:', error);
|
||||
// Don't throw - filter loading errors shouldn't break the main UI
|
||||
}
|
||||
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
||||
};
|
||||
|
||||
fetchInitialData();
|
||||
// Use setTimeout to ensure this runs after the main component render
|
||||
// This prevents filter loading from blocking the initial render
|
||||
const timeoutId = setTimeout(loadFilterData, 0);
|
||||
|
||||
return () => clearTimeout(timeoutId);
|
||||
}, [dispatch, priorities.length, projectId]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -2,7 +2,7 @@ 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 { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Flex from 'antd/es/flex';
|
||||
import useIsomorphicLayoutEffect from '@/hooks/useIsomorphicLayoutEffect';
|
||||
@@ -87,16 +87,22 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
const loadingAssignees = useAppSelector(state => state.taskReducer.loadingAssignees);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
// Move useSensors to top level and memoize its configuration
|
||||
const sensorConfig = useMemo(
|
||||
() => ({
|
||||
activationConstraint: { distance: 8 },
|
||||
})
|
||||
}),
|
||||
[]
|
||||
);
|
||||
|
||||
const pointerSensor = useSensor(PointerSensor, sensorConfig);
|
||||
const sensors = useSensors(pointerSensor);
|
||||
|
||||
useEffect(() => {
|
||||
setGroups(taskGroups);
|
||||
}, [taskGroups]);
|
||||
|
||||
// Memoize resetTaskRowStyles to prevent unnecessary re-renders
|
||||
const resetTaskRowStyles = useCallback(() => {
|
||||
document.querySelectorAll<HTMLElement>('.task-row').forEach(row => {
|
||||
row.style.transition = 'transform 0.2s ease, opacity 0.2s ease';
|
||||
@@ -106,21 +112,18 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Socket handler for assignee updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => {
|
||||
// Memoize socket event handlers
|
||||
const handleAssigneesUpdate = useCallback(
|
||||
(data: ITaskAssigneesUpdateResponse) => {
|
||||
if (!data) return;
|
||||
|
||||
const updatedAssignees = data.assignees.map(assignee => ({
|
||||
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(
|
||||
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))
|
||||
@@ -136,47 +139,41 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(setTaskAssignee(data));
|
||||
dispatch(
|
||||
setTaskAssignee({
|
||||
...data,
|
||||
manual_progress: false,
|
||||
} as IProjectTask)
|
||||
);
|
||||
|
||||
if (currentSession?.team_id && !loadingAssignees) {
|
||||
dispatch(fetchTaskAssignees(currentSession.team_id));
|
||||
}
|
||||
}
|
||||
};
|
||||
},
|
||||
[groups, dispatch, currentSession?.team_id, loadingAssignees]
|
||||
);
|
||||
|
||||
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) => {
|
||||
// Memoize socket event handlers
|
||||
const handleLabelsChange = useCallback(
|
||||
async (labels: ILabelsChangeResponse) => {
|
||||
if (!labels) return;
|
||||
|
||||
await Promise.all([
|
||||
dispatch(updateTaskLabel(labels)),
|
||||
dispatch(setTaskLabels(labels)),
|
||||
dispatch(fetchLabels()),
|
||||
projectId && dispatch(fetchLabelsByProject(projectId)),
|
||||
]);
|
||||
};
|
||||
},
|
||||
[dispatch, projectId]
|
||||
);
|
||||
|
||||
socket.on(SocketEvents.TASK_LABELS_CHANGE.toString(), handleLabelsChange);
|
||||
socket.on(SocketEvents.CREATE_LABEL.toString(), handleLabelsChange);
|
||||
// Memoize socket event handlers
|
||||
const handleTaskStatusChange = useCallback(
|
||||
(response: ITaskListStatusChangeResponse) => {
|
||||
if (!response) return;
|
||||
|
||||
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',
|
||||
@@ -186,11 +183,14 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
}
|
||||
|
||||
dispatch(updateTaskStatus(response));
|
||||
// dispatch(setTaskStatus(response));
|
||||
dispatch(deselectAll());
|
||||
};
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleTaskProgress = (data: {
|
||||
// Memoize socket event handlers
|
||||
const handleTaskProgress = useCallback(
|
||||
(data: {
|
||||
id: string;
|
||||
status: string;
|
||||
complete_ratio: number;
|
||||
@@ -198,6 +198,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
total_tasks_count: number;
|
||||
parent_task: string;
|
||||
}) => {
|
||||
if (!data) return;
|
||||
|
||||
dispatch(
|
||||
updateTaskProgress({
|
||||
taskId: data.parent_task || data.id,
|
||||
@@ -206,187 +208,233 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
completedCount: data.completed_count,
|
||||
})
|
||||
);
|
||||
};
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
socket.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
|
||||
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
||||
// Memoize socket event handlers
|
||||
const handlePriorityChange = useCallback(
|
||||
(response: ITaskListPriorityChangeResponse) => {
|
||||
if (!response) return;
|
||||
|
||||
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());
|
||||
};
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
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: {
|
||||
// Memoize socket event handlers
|
||||
const handleEndDateChange = useCallback(
|
||||
(task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
end_date: string;
|
||||
}) => {
|
||||
dispatch(updateTaskEndDate({ task }));
|
||||
dispatch(setTaskEndDate(task));
|
||||
};
|
||||
if (!task) return;
|
||||
|
||||
socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
|
||||
const taskWithProgress = {
|
||||
...task,
|
||||
manual_progress: false,
|
||||
} as IProjectTask;
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
dispatch(updateTaskEndDate({ task: taskWithProgress }));
|
||||
dispatch(setTaskEndDate(taskWithProgress));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Socket handler for task name updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
// Memoize socket event handlers
|
||||
const handleTaskNameChange = useCallback(
|
||||
(data: { id: string; parent_task: string; name: string }) => {
|
||||
if (!data) return;
|
||||
|
||||
const handleTaskNameChange = (data: { id: string; parent_task: string; name: string }) => {
|
||||
dispatch(updateTaskName(data));
|
||||
};
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
socket.on(SocketEvents.TASK_NAME_CHANGE.toString(), handleTaskNameChange);
|
||||
// Memoize socket event handlers
|
||||
const handlePhaseChange = useCallback(
|
||||
(data: ITaskPhaseChangeResponse) => {
|
||||
if (!data) return;
|
||||
|
||||
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());
|
||||
};
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
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: {
|
||||
// Memoize socket event handlers
|
||||
const handleStartDateChange = useCallback(
|
||||
(task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
start_date: string;
|
||||
}) => {
|
||||
dispatch(updateTaskStartDate({ task }));
|
||||
dispatch(setStartDate(task));
|
||||
};
|
||||
if (!task) return;
|
||||
|
||||
socket.on(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
|
||||
const taskWithProgress = {
|
||||
...task,
|
||||
manual_progress: false,
|
||||
} as IProjectTask;
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
dispatch(updateTaskStartDate({ task: taskWithProgress }));
|
||||
dispatch(setStartDate(taskWithProgress));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Socket handler for task subscribers updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
// Memoize socket event handlers
|
||||
const handleTaskSubscribersChange = useCallback(
|
||||
(data: InlineMember[]) => {
|
||||
if (!data) return;
|
||||
|
||||
const handleTaskSubscribersChange = (data: InlineMember[]) => {
|
||||
dispatch(setTaskSubscribers(data));
|
||||
};
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
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: {
|
||||
// Memoize socket event handlers
|
||||
const handleEstimationChange = useCallback(
|
||||
(task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
estimation: number;
|
||||
}) => {
|
||||
dispatch(updateTaskEstimation({ task }));
|
||||
};
|
||||
if (!task) return;
|
||||
|
||||
socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
|
||||
const taskWithProgress = {
|
||||
...task,
|
||||
manual_progress: false,
|
||||
} as IProjectTask;
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
dispatch(updateTaskEstimation({ task: taskWithProgress }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Socket handler for task description updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleTaskDescriptionChange = (data: {
|
||||
// Memoize socket event handlers
|
||||
const handleTaskDescriptionChange = useCallback(
|
||||
(data: {
|
||||
id: string;
|
||||
parent_task: string;
|
||||
description: string;
|
||||
}) => {
|
||||
if (!data) return;
|
||||
|
||||
dispatch(updateTaskDescription(data));
|
||||
};
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
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) => {
|
||||
// Memoize socket event handlers
|
||||
const handleNewTaskReceived = useCallback(
|
||||
(data: IProjectTask) => {
|
||||
if (!data) return;
|
||||
|
||||
if (data.parent_task_id) {
|
||||
dispatch(updateSubTasks(data));
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Memoize socket event handlers
|
||||
const handleTaskProgressUpdated = useCallback(
|
||||
(data: {
|
||||
task_id: string;
|
||||
progress_value?: number;
|
||||
weight?: number;
|
||||
}) => {
|
||||
if (!data || !taskGroups) return;
|
||||
|
||||
if (data.progress_value !== undefined) {
|
||||
for (const group of taskGroups) {
|
||||
const task = group.tasks?.find(task => task.id === data.task_id);
|
||||
if (task) {
|
||||
dispatch(
|
||||
updateTaskProgress({
|
||||
taskId: data.task_id,
|
||||
progress: data.progress_value,
|
||||
totalTasksCount: task.total_tasks_count || 0,
|
||||
completedCount: task.completed_count || 0,
|
||||
})
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[dispatch, taskGroups]
|
||||
);
|
||||
|
||||
// Set up socket event listeners
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const eventHandlers = {
|
||||
[SocketEvents.QUICK_ASSIGNEES_UPDATE.toString()]: handleAssigneesUpdate,
|
||||
[SocketEvents.TASK_LABELS_CHANGE.toString()]: handleLabelsChange,
|
||||
[SocketEvents.CREATE_LABEL.toString()]: handleLabelsChange,
|
||||
[SocketEvents.TASK_STATUS_CHANGE.toString()]: handleTaskStatusChange,
|
||||
[SocketEvents.GET_TASK_PROGRESS.toString()]: handleTaskProgress,
|
||||
[SocketEvents.TASK_PRIORITY_CHANGE.toString()]: handlePriorityChange,
|
||||
[SocketEvents.TASK_END_DATE_CHANGE.toString()]: handleEndDateChange,
|
||||
[SocketEvents.TASK_NAME_CHANGE.toString()]: handleTaskNameChange,
|
||||
[SocketEvents.TASK_PHASE_CHANGE.toString()]: handlePhaseChange,
|
||||
[SocketEvents.TASK_START_DATE_CHANGE.toString()]: handleStartDateChange,
|
||||
[SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString()]: handleTaskSubscribersChange,
|
||||
[SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString()]: handleEstimationChange,
|
||||
[SocketEvents.TASK_DESCRIPTION_CHANGE.toString()]: handleTaskDescriptionChange,
|
||||
[SocketEvents.QUICK_TASK.toString()]: handleNewTaskReceived,
|
||||
[SocketEvents.TASK_PROGRESS_UPDATED.toString()]: handleTaskProgressUpdated,
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
|
||||
// Register all event handlers
|
||||
Object.entries(eventHandlers).forEach(([event, handler]) => {
|
||||
if (handler) {
|
||||
socket.on(event, handler);
|
||||
}
|
||||
});
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
socket.off(SocketEvents.QUICK_TASK.toString(), handleNewTaskReceived);
|
||||
Object.entries(eventHandlers).forEach(([event, handler]) => {
|
||||
if (handler) {
|
||||
socket.off(event, handler);
|
||||
}
|
||||
});
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
}, [
|
||||
socket,
|
||||
handleAssigneesUpdate,
|
||||
handleLabelsChange,
|
||||
handleTaskStatusChange,
|
||||
handleTaskProgress,
|
||||
handlePriorityChange,
|
||||
handleEndDateChange,
|
||||
handleTaskNameChange,
|
||||
handlePhaseChange,
|
||||
handleStartDateChange,
|
||||
handleTaskSubscribersChange,
|
||||
handleEstimationChange,
|
||||
handleTaskDescriptionChange,
|
||||
handleNewTaskReceived,
|
||||
handleTaskProgressUpdated,
|
||||
]);
|
||||
|
||||
// Memoize drag handlers
|
||||
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';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Memoize drag handlers
|
||||
const handleDragEnd = useCallback(
|
||||
async ({ active, over }: DragEndEvent) => {
|
||||
setActiveId(null);
|
||||
@@ -405,10 +453,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
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) {
|
||||
@@ -420,7 +466,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Update task properties based on target group
|
||||
switch (groupBy) {
|
||||
case IGroupBy.STATUS:
|
||||
task.status = overGroupId;
|
||||
@@ -433,35 +478,29 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
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
|
||||
? targetGroup.color_code.slice(0, -ALPHA_CHANNEL.length)
|
||||
: targetGroup.color_code;
|
||||
task.phase_id = overGroupId;
|
||||
task.phase_color = baseColor; // Set the cleaned color
|
||||
task.phase_color = baseColor;
|
||||
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);
|
||||
@@ -479,7 +518,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Different groups - transfer between arrays
|
||||
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
|
||||
const updatedTargetTasks = [...targetGroup.tasks];
|
||||
|
||||
@@ -505,7 +543,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
});
|
||||
}
|
||||
|
||||
// Emit socket event
|
||||
socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
|
||||
project_id: projectId,
|
||||
from_index: sourceGroup.tasks[fromIndex].sort_order,
|
||||
@@ -514,13 +551,11 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
from_group: sourceGroup.id,
|
||||
to_group: targetGroup.id,
|
||||
group_by: groupBy,
|
||||
task: sourceGroup.tasks[fromIndex], // Send original task to maintain references
|
||||
task: sourceGroup.tasks[fromIndex],
|
||||
team_id: currentSession?.team_id,
|
||||
});
|
||||
|
||||
// Reset styles
|
||||
setTimeout(resetTaskRowStyles, 0);
|
||||
|
||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||
},
|
||||
[
|
||||
@@ -535,6 +570,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
]
|
||||
);
|
||||
|
||||
// Memoize drag handlers
|
||||
const handleDragOver = useCallback(
|
||||
({ active, over }: DragEndEvent) => {
|
||||
if (!over) return;
|
||||
@@ -554,12 +590,9 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
|
||||
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);
|
||||
@@ -577,10 +610,8 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
},
|
||||
});
|
||||
} else {
|
||||
// Different groups - transfer between arrays
|
||||
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
|
||||
const updatedTargetTasks = [...targetGroup.tasks];
|
||||
|
||||
updatedTargetTasks.splice(toIndex, 0, task);
|
||||
|
||||
dispatch({
|
||||
@@ -628,7 +659,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
||||
// Handle animation cleanup after drag ends
|
||||
useIsomorphicLayoutEffect(() => {
|
||||
if (activeId === null) {
|
||||
// Final cleanup after React updates DOM
|
||||
const timeoutId = setTimeout(resetTaskRowStyles, 50);
|
||||
return () => clearTimeout(timeoutId);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,54 @@
|
||||
import React from 'react';
|
||||
import { Progress, Tooltip } from 'antd';
|
||||
import './task-list-progress-cell.css';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
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}`}>
|
||||
const { project } = useAppSelector(state => state.projectReducer);
|
||||
const isManualProgressEnabled = (task.project_use_manual_progress || task.project_use_weighted_progress || task.project_use_time_progress);;
|
||||
const isSubtask = task.is_sub_task;
|
||||
const hasManualProgress = task.manual_progress;
|
||||
|
||||
// Handle different cases:
|
||||
// 1. For subtasks when manual progress is enabled, show the progress
|
||||
// 2. For parent tasks, always show progress
|
||||
// 3. For subtasks when manual progress is not enabled, don't show progress (null)
|
||||
|
||||
if (isSubtask && !isManualProgressEnabled) {
|
||||
return null; // Don't show progress for subtasks when manual progress is disabled
|
||||
}
|
||||
|
||||
// For parent tasks, show completion ratio with task count tooltip
|
||||
if (!isSubtask) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
// For subtasks with manual progress enabled, show the progress
|
||||
return (
|
||||
<Tooltip
|
||||
title={hasManualProgress ? `Manual: ${task.progress_value || 0}%` : `${task.progress || 0}%`}
|
||||
>
|
||||
<Progress
|
||||
percent={task.complete_ratio || 0}
|
||||
percent={hasManualProgress ? (task.progress_value || 0) : (task.progress || 0)}
|
||||
type="circle"
|
||||
size={24}
|
||||
size={22} // Slightly smaller for subtasks
|
||||
style={{ cursor: 'default' }}
|
||||
strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7}
|
||||
strokeWidth={(task.progress || 0) >= 100 ? 9 : 7}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
@@ -86,7 +86,7 @@ const TaskListTaskCell = ({
|
||||
isSubTask: boolean,
|
||||
subTasksCount: number
|
||||
) => {
|
||||
if (subTasksCount > 0) {
|
||||
if (subTasksCount > 0 && !isSubTask) {
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleToggleExpansion(taskId)}
|
||||
@@ -112,23 +112,21 @@ const TaskListTaskCell = ({
|
||||
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>
|
||||
)
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1548,7 +1548,6 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
||||
};
|
||||
|
||||
const handleCustomColumnSettings = (columnKey: string) => {
|
||||
console.log('columnKey', columnKey);
|
||||
if (!columnKey) return;
|
||||
setEditColumnKey(columnKey);
|
||||
dispatch(setCustomColumnModalAttributes({modalType: 'edit', columnId: columnKey}));
|
||||
|
||||
@@ -6,9 +6,14 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import CustomTableTitle from '@/components/CustomTableTitle';
|
||||
import TasksProgressCell from './tablesCells/tasksProgressCell/TasksProgressCell';
|
||||
import MemberCell from './tablesCells/memberCell/MemberCell';
|
||||
import { fetchMembersData, toggleMembersReportsDrawer } from '@/features/reporting/membersReports/membersReportsSlice';
|
||||
import {
|
||||
fetchMembersData,
|
||||
setPagination,
|
||||
toggleMembersReportsDrawer,
|
||||
} from '@/features/reporting/membersReports/membersReportsSlice';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import MembersReportsDrawer from '@/features/reporting/membersReports/membersReportsDrawer/members-reports-drawer';
|
||||
import { PaginationConfig } from 'antd/es/pagination';
|
||||
|
||||
const MembersReportsTable = () => {
|
||||
const { t } = useTranslation('reporting-members');
|
||||
@@ -16,7 +21,9 @@ const MembersReportsTable = () => {
|
||||
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
|
||||
const { membersList, isLoading, total, archived, searchQuery } = useAppSelector(state => state.membersReportsReducer);
|
||||
const { membersList, isLoading, total, archived, searchQuery, index, pageSize } = useAppSelector(
|
||||
state => state.membersReportsReducer
|
||||
);
|
||||
|
||||
// function to handle drawer toggle
|
||||
const handleDrawerOpen = (id: string) => {
|
||||
@@ -24,6 +31,10 @@ const MembersReportsTable = () => {
|
||||
dispatch(toggleMembersReportsDrawer());
|
||||
};
|
||||
|
||||
const handleOnChange = (pagination: any, filters: any, sorter: any, extra: any) => {
|
||||
dispatch(setPagination({ index: pagination.current, pageSize: pagination.pageSize }));
|
||||
};
|
||||
|
||||
const columns: TableColumnsType = [
|
||||
{
|
||||
key: 'member',
|
||||
@@ -40,7 +51,7 @@ const MembersReportsTable = () => {
|
||||
title: <CustomTableTitle title={t('tasksProgressColumn')} />,
|
||||
render: record => {
|
||||
const { todo, doing, done } = record.tasks_stat;
|
||||
return (todo || doing || done) ? <TasksProgressCell tasksStat={record.tasks_stat} /> : '-';
|
||||
return todo || doing || done ? <TasksProgressCell tasksStat={record.tasks_stat} /> : '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -95,7 +106,7 @@ const MembersReportsTable = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) dispatch(fetchMembersData({ duration, dateRange }));
|
||||
}, [dispatch, archived, searchQuery, dateRange]);
|
||||
}, [dispatch, archived, searchQuery, dateRange, index, pageSize]);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
@@ -113,6 +124,7 @@ const MembersReportsTable = () => {
|
||||
dataSource={membersList}
|
||||
rowKey={record => record.id}
|
||||
pagination={{ showSizeChanger: true, defaultPageSize: 10, total: total }}
|
||||
onChange={(pagination, filters, sorter, extra) => handleOnChange(pagination, filters, sorter, extra)}
|
||||
scroll={{ x: 'max-content' }}
|
||||
loading={isLoading}
|
||||
onRow={record => {
|
||||
|
||||
@@ -25,9 +25,7 @@ const MembersReports = () => {
|
||||
useDocumentTitle('Reporting - Members');
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
const { archived, searchQuery } = useAppSelector(
|
||||
state => state.membersReportsReducer,
|
||||
);
|
||||
const { archived, searchQuery, total } = useAppSelector(state => state.membersReportsReducer);
|
||||
const { duration, dateRange } = useAppSelector(state => state.reportingReducer);
|
||||
|
||||
|
||||
@@ -44,7 +42,7 @@ const MembersReports = () => {
|
||||
return (
|
||||
<Flex vertical>
|
||||
<CustomPageHeader
|
||||
title={`Members`}
|
||||
title={`Members (${total})`}
|
||||
children={
|
||||
<Space>
|
||||
<Button>
|
||||
|
||||
@@ -81,6 +81,22 @@ const MembersTimeSheet = forwardRef<MembersTimeSheetRef>((_, ref) => {
|
||||
display: false,
|
||||
position: 'top' as const,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context: any) {
|
||||
const idx = context.dataIndex;
|
||||
const member = jsonData[idx];
|
||||
const hours = member?.utilized_hours || '0.00';
|
||||
const percent = member?.utilization_percent || '0.00';
|
||||
const overUnder = member?.over_under_utilized_hours || '0.00';
|
||||
return [
|
||||
`${context.dataset.label}: ${hours} h`,
|
||||
`Utilization: ${percent}%`,
|
||||
`Over/Under Utilized: ${overUnder} h`
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
backgroundColor: 'black',
|
||||
indexAxis: 'y' as const,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Card, Divider, Flex, Switch, Typography } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { toggleTheme } from '@/features/theme/themeSlice';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { MoonOutlined, SunOutlined } from '@ant-design/icons';
|
||||
|
||||
const AppearanceSettings = () => {
|
||||
const { t } = useTranslation('settings/appearance');
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useDocumentTitle(t('title'));
|
||||
|
||||
const handleThemeToggle = () => {
|
||||
dispatch(toggleTheme());
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={{ width: '100%' }}>
|
||||
<Flex vertical gap={4}>
|
||||
<Flex gap={8} align="center">
|
||||
<Switch
|
||||
checked={themeMode === 'dark'}
|
||||
onChange={handleThemeToggle}
|
||||
checkedChildren={<MoonOutlined />}
|
||||
unCheckedChildren={<SunOutlined />}
|
||||
/>
|
||||
<Typography.Title level={4} style={{ marginBlockEnd: 0 }}>
|
||||
{t('darkMode')}
|
||||
</Typography.Title>
|
||||
</Flex>
|
||||
<Typography.Text
|
||||
style={{ fontSize: 14, color: themeMode === 'dark' ? '#9CA3AF' : '#00000073' }}
|
||||
>
|
||||
{t('darkModeDescription')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppearanceSettings;
|
||||
@@ -51,7 +51,7 @@ const ProjectTemplatesSettings = () => {
|
||||
style={{ display: 'flex', gap: '10px', justifyContent: 'right' }}
|
||||
className="button-visibilty"
|
||||
>
|
||||
<Tooltip title={t('editToolTip')}>
|
||||
{/* <Tooltip title={t('editToolTip')}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
@@ -60,7 +60,7 @@ const ProjectTemplatesSettings = () => {
|
||||
>
|
||||
<EditOutlined />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Tooltip> */}
|
||||
<Tooltip title={t('deleteToolTip')}>
|
||||
<Popconfirm
|
||||
title={
|
||||
|
||||
Reference in New Issue
Block a user