Merge branch 'feature/project-list-grouping' into upstream/feature/project-groupby

This commit is contained in:
Chamika J
2025-06-13 13:02:17 +05:30
committed by GitHub
162 changed files with 11294 additions and 3349 deletions

View File

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

View File

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

View File

@@ -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',

View File

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

View File

@@ -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')}
/>
) : (

View File

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

View File

@@ -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>
),
},
];

View File

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

View File

@@ -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')}
/>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 => {

View File

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

View File

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

View File

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

View File

@@ -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={