Merge branch 'main' of https://github.com/Worklenz/worklenz into feature/task-activities-by-user

This commit is contained in:
chamikaJ
2025-07-14 12:46:18 +05:30
1166 changed files with 82289 additions and 15230 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';
@@ -52,7 +52,8 @@ const AccountSetup: React.FC = () => {
trackMixpanelEvent(evt_account_setup_visit);
const verifyAuthStatus = async () => {
try {
const response = (await dispatch(verifyAuthentication()).unwrap()).payload as IAuthorizeResponse;
const response = (await dispatch(verifyAuthentication()).unwrap())
.payload as IAuthorizeResponse;
if (response?.authenticated) {
setSession(response.user);
dispatch(setUser(response.user));
@@ -152,9 +153,7 @@ const AccountSetup: React.FC = () => {
const model: IAccountSetupRequest = {
team_name: sanitizeInput(organizationName),
project_name: sanitizeInput(projectName),
tasks: tasks
.map(task => sanitizeInput(task.value.trim()))
.filter(task => task !== ''),
tasks: tasks.map(task => sanitizeInput(task.value.trim())).filter(task => task !== ''),
team_members: skip
? []
: teamMembers

View File

@@ -5,12 +5,12 @@ import {
TeamOutlined,
UserOutlined,
} from '@ant-design/icons';
import React, { ReactNode } from 'react';
import Overview from './overview/overview';
import Users from './users/users';
import Teams from './teams/teams';
import Billing from './billing/billing';
import Projects from './projects/projects';
import React, { ReactNode, lazy } from 'react';
const Overview = lazy(() => import('./overview/overview'));
const Users = lazy(() => import('./users/users'));
const Teams = lazy(() => import('./teams/teams'));
const Billing = lazy(() => import('./billing/billing'));
const Projects = lazy(() => import('./projects/projects'));
// type of a menu item in admin center sidebar
type AdminCenterMenuItems = {

View File

@@ -6,7 +6,7 @@ import Configuration from '@/components/admin-center/configuration/configuration
import { useTranslation } from 'react-i18next';
const Billing: React.FC = () => {
const { t } = useTranslation('admin-center/current-bill');;
const { t } = useTranslation('admin-center/current-bill');
const items: TabsProps['items'] = [
{

View File

@@ -61,9 +61,7 @@ const Users: React.FC = () => {
key: 'email',
render: text => (
<span className="email-hover">
<Typography.Text copyable={{ text }}>
{text}
</Typography.Text>
<Typography.Text copyable={{ text }}>{text}</Typography.Text>
</span>
),
},

View File

@@ -9,7 +9,10 @@ import PageHeader from '@components/AuthPageHeader';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { evt_forgot_password_page_visit, evt_reset_password_click } from '@/shared/worklenz-analytics-events';
import {
evt_forgot_password_page_visit,
evt_reset_password_click,
} from '@/shared/worklenz-analytics-events';
import { resetPassword, verifyAuthentication } from '@features/auth/authSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setSession } from '@/utils/session-helper';

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

@@ -68,7 +68,10 @@ const SignupPage = () => {
};
const enableGoogleLogin = import.meta.env.VITE_ENABLE_GOOGLE_LOGIN === 'true' || false;
const enableRecaptcha = import.meta.env.VITE_ENABLE_RECAPTCHA === 'true' && import.meta.env.VITE_RECAPTCHA_SITE_KEY && import.meta.env.VITE_RECAPTCHA_SITE_KEY !== 'recaptcha-site-key';
const enableRecaptcha =
import.meta.env.VITE_ENABLE_RECAPTCHA === 'true' &&
import.meta.env.VITE_RECAPTCHA_SITE_KEY &&
import.meta.env.VITE_RECAPTCHA_SITE_KEY !== 'recaptcha-site-key';
useEffect(() => {
trackMixpanelEvent(evt_signup_page_visit);
@@ -94,10 +97,12 @@ const SignupPage = () => {
if (enableRecaptcha && import.meta.env.VITE_RECAPTCHA_SITE_KEY) {
// Check if site key is not the placeholder value
if (import.meta.env.VITE_RECAPTCHA_SITE_KEY === 'recaptcha-site-key') {
console.warn('Using placeholder reCAPTCHA site key. Please set a valid key in your environment variables.');
console.warn(
'Using placeholder reCAPTCHA site key. Please set a valid key in your environment variables.'
);
return;
}
const script = document.createElement('script');
script.src = `https://www.google.com/recaptcha/api.js?render=${import.meta.env.VITE_RECAPTCHA_SITE_KEY}`;
script.async = true;
@@ -108,7 +113,7 @@ const SignupPage = () => {
if (script && script.parentNode) {
script.parentNode.removeChild(script);
}
const recaptchaElements = document.getElementsByClassName('grecaptcha-badge');
while (recaptchaElements.length > 0) {
const element = recaptchaElements[0];
@@ -130,23 +135,26 @@ const SignupPage = () => {
const getRecaptchaToken = async () => {
if (!enableRecaptcha) return '';
// Check if site key is valid
if (!import.meta.env.VITE_RECAPTCHA_SITE_KEY || import.meta.env.VITE_RECAPTCHA_SITE_KEY === 'recaptcha-site-key') {
if (
!import.meta.env.VITE_RECAPTCHA_SITE_KEY ||
import.meta.env.VITE_RECAPTCHA_SITE_KEY === 'recaptcha-site-key'
) {
console.warn('Invalid reCAPTCHA site key. Skipping reCAPTCHA verification.');
return 'skip-verification';
}
try {
return new Promise<string>((resolve, reject) => {
if (!window.grecaptcha) {
reject('reCAPTCHA not loaded');
return;
}
window.grecaptcha.ready(() => {
window.grecaptcha!
.execute(import.meta.env.VITE_RECAPTCHA_SITE_KEY, { action: 'signup' })
window
.grecaptcha!.execute(import.meta.env.VITE_RECAPTCHA_SITE_KEY, { action: 'signup' })
.then((token: string) => {
resolve(token);
})
@@ -165,17 +173,20 @@ const SignupPage = () => {
const onFinish = async (values: IUserSignUpRequest) => {
try {
setValidating(true);
if (enableRecaptcha) {
try {
const token = await getRecaptchaToken();
if (!token) {
logger.error('Failed to get reCAPTCHA token');
alertService.error(t('reCAPTCHAVerificationError'), t('reCAPTCHAVerificationErrorMessage'));
alertService.error(
t('reCAPTCHAVerificationError'),
t('reCAPTCHAVerificationErrorMessage')
);
return;
}
// Skip verification if we're using the special token due to invalid site key
if (token !== 'skip-verification') {
const verifyToken = await authApiService.verifyRecaptchaToken(token);
@@ -191,7 +202,10 @@ const SignupPage = () => {
if (import.meta.env.DEV) {
console.warn('Continuing signup despite reCAPTCHA error in development mode');
} else {
alertService.error(t('reCAPTCHAVerificationError'), t('reCAPTCHAVerificationErrorMessage'));
alertService.error(
t('reCAPTCHAVerificationError'),
t('reCAPTCHAVerificationErrorMessage')
);
return;
}
}
@@ -352,17 +366,14 @@ const SignupPage = () => {
<Form.Item>
<Typography.Paragraph style={{ fontSize: 14 }}>
{t('bySigningUpText')}{' '}
<a
href="https://worklenz.com/privacy/"
target="_blank"
rel="noopener noreferrer"
>{t('privacyPolicyLink')}</a>{' '}
<a href="https://worklenz.com/privacy/" target="_blank" rel="noopener noreferrer">
{t('privacyPolicyLink')}
</a>{' '}
{t('andText')}{' '}
<a
href="https://worklenz.com/terms/"
target="_blank"
rel="noopener noreferrer"
>{t('termsOfUseLink')}</a>.
<a href="https://worklenz.com/terms/" target="_blank" rel="noopener noreferrer">
{t('termsOfUseLink')}
</a>
.
</Typography.Paragraph>
</Form.Item>

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import React, { useEffect, memo, useMemo, useCallback } from 'react';
import { useMediaQuery } from 'react-responsive';
import Col from 'antd/es/col';
import Flex from 'antd/es/flex';
@@ -21,48 +21,89 @@ import { fetchProjectCategories } from '@/features/projects/lookups/projectCateg
import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/projectHealthSlice';
import { fetchProjects } from '@/features/home-page/home-page.slice';
import { createPortal } from 'react-dom';
import React from 'react';
import UserActivityFeed from './user-activity-feed/user-activity-feed';
const DESKTOP_MIN_WIDTH = 1024;
const TASK_LIST_MIN_WIDTH = 500;
const SIDEBAR_MAX_WIDTH = 400;
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
const HomePage = () => {
// Lazy load heavy components
const TaskDrawer = React.lazy(() => import('@/components/task-drawer/task-drawer'));
const HomePage = memo(() => {
const dispatch = useAppDispatch();
const isDesktop = useMediaQuery({ query: `(min-width: ${DESKTOP_MIN_WIDTH}px)` });
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
useDocumentTitle('Home');
// Preload TaskDrawer component to prevent dynamic import failures
useEffect(() => {
const fetchLookups = async () => {
const fetchPromises = [
dispatch(fetchProjectHealth()),
dispatch(fetchProjectCategories()),
dispatch(fetchProjectStatuses()),
dispatch(fetchProjects()),
].filter(Boolean);
await Promise.all(fetchPromises);
const preloadTaskDrawer = async () => {
try {
await import('@/components/task-drawer/task-drawer');
} catch (error) {
console.warn('Failed to preload TaskDrawer:', error);
}
};
fetchLookups();
preloadTaskDrawer();
}, []);
// Memoize fetch function to prevent recreation on every render
const fetchLookups = useCallback(async () => {
const fetchPromises = [
dispatch(fetchProjectHealth()),
dispatch(fetchProjectCategories()),
dispatch(fetchProjectStatuses()),
dispatch(fetchProjects()),
].filter(Boolean);
await Promise.all(fetchPromises);
}, [dispatch]);
const CreateProjectButtonComponent = () =>
isDesktop ? (
useEffect(() => {
fetchLookups();
}, [fetchLookups]);
// Memoize project drawer close handler
const handleProjectDrawerClose = useCallback(() => {}, []);
// Memoize desktop flex styles to prevent object recreation
const desktopFlexStyle = useMemo(
() => ({
minWidth: TASK_LIST_MIN_WIDTH,
width: '100%',
}),
[]
);
const sidebarFlexStyle = useMemo(
() => ({
width: '100%',
maxWidth: SIDEBAR_MAX_WIDTH,
}),
[]
);
// Memoize components to prevent unnecessary re-renders
const CreateProjectButtonComponent = useMemo(() => {
if (!isOwnerOrAdmin) return null;
return isDesktop ? (
<div className="absolute right-0 top-1/2 -translate-y-1/2">
{isOwnerOrAdmin && <CreateProjectButton />}
<CreateProjectButton />
</div>
) : (
isOwnerOrAdmin && <CreateProjectButton />
<CreateProjectButton />
);
}, [isDesktop, isOwnerOrAdmin]);
return (
<div className="my-24 min-h-[90vh]">
<Col className="flex flex-col gap-6">
<GreetingWithTime />
<CreateProjectButtonComponent />
{CreateProjectButtonComponent}
</Col>
<Row gutter={[24, 24]} className="mt-12">
@@ -89,6 +130,8 @@ const HomePage = () => {
{createPortal(<ProjectDrawer onClose={() => {}} />, document.body, 'project-drawer')}
</div>
);
};
});
HomePage.displayName = 'HomePage';
export default HomePage;

View File

@@ -23,7 +23,7 @@ const MY_PROJECTS_FILTER_KEY = 'my-dashboard-active-projects-filter';
const RecentAndFavouriteProjectList = () => {
const { t } = useTranslation('home');
const navigate = useNavigate();
const [projectSegment, setProjectSegment] = useState<'Recent' | 'Favourites'>('Recent');
const getActiveProjectsFilter = useCallback(() => {
@@ -77,7 +77,9 @@ const RecentAndFavouriteProjectList = () => {
<Typography.Paragraph
key={record.id}
style={{ margin: 0, paddingInlineEnd: 6, cursor: 'pointer' }}
onClick={() => navigate(`/worklenz/projects/${record.id}?tab=tasks-list&pinned_tab=tasks-list`)}
onClick={() =>
navigate(`/worklenz/projects/${record.id}?tab=tasks-list&pinned_tab=tasks-list`)
}
>
<Badge color={record.color_code} style={{ marginInlineEnd: 4 }} />
{record.name}
@@ -119,7 +121,7 @@ const RecentAndFavouriteProjectList = () => {
<Segmented<'Recent' | 'Favourites'>
options={[
{ value: 'Recent', label: t('projects.recent') },
{ value: 'Favourites', label: t('projects.favourites') }
{ value: 'Favourites', label: t('projects.favourites') },
]}
defaultValue={getActiveProjectsFilter() === 0 ? 'Recent' : 'Favourites'}
onChange={handleSegmentChange}
@@ -132,7 +134,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

@@ -57,20 +57,31 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
},
];
const calculateEndDate = (dueDate: string): Date | undefined => {
const calculateEndDate = (dueDate: string): string | undefined => {
const today = new Date();
let targetDate: Date;
switch (dueDate) {
case 'Today':
return today;
targetDate = new Date(today);
break;
case 'Tomorrow':
return new Date(today.setDate(today.getDate() + 1));
targetDate = new Date(today);
targetDate.setDate(today.getDate() + 1);
break;
case 'Next Week':
return new Date(today.setDate(today.getDate() + 7));
targetDate = new Date(today);
targetDate.setDate(today.getDate() + 7);
break;
case 'Next Month':
return new Date(today.setMonth(today.getMonth() + 1));
targetDate = new Date(today);
targetDate.setMonth(today.getMonth() + 1);
break;
default:
return undefined;
}
return targetDate.toISOString().split('T')[0]; // Returns YYYY-MM-DD format
};
const projectOptions = [
@@ -82,12 +93,16 @@ const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
];
const handleTaskSubmit = (values: { name: string; project: string; dueDate: string }) => {
const newTask: IHomeTaskCreateRequest = {
const endDate = calendarView
? homeTasksConfig.selected_date?.format('YYYY-MM-DD')
: calculateEndDate(values.dueDate);
const newTask = {
name: values.name,
project_id: values.project,
reporter_id: currentSession?.id,
team_id: currentSession?.team_id,
end_date: (calendarView ? homeTasksConfig.selected_date?.format('YYYY-MM-DD') : calculateEndDate(values.dueDate)),
end_date: endDate || new Date().toISOString().split('T')[0], // Fallback to today if undefined
};
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(newTask));

View File

@@ -16,7 +16,6 @@ import {
import React, { useState, useMemo, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import ListView from './list-view';
import CalendarView from './calendar-view';
import { useAppSelector } from '@/hooks/useAppSelector';
@@ -25,7 +24,11 @@ import EmptyListPlaceholder from '@components/EmptyListPlaceholder';
import { colors } from '@/styles/colors';
import { setHomeTasksConfig } from '@/features/home-page/home-page.slice';
import { IMyTask } from '@/types/home/my-tasks.types';
import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice';
import {
setSelectedTaskId,
setShowTaskDrawer,
fetchTask,
} from '@/features/task-drawer/task-drawer.slice';
import { useGetMyTasksQuery } from '@/api/home-page/home-page.api.service';
import { IHomeTasksModel } from '@/types/home/home-page.types';
import './tasks-list.css';
@@ -36,7 +39,6 @@ import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
import { setProjectId } from '@/features/project/project.slice';
import { getTeamMembers } from '@/features/team-members/team-members.slice';
const TasksList: React.FC = React.memo(() => {
const dispatch = useAppDispatch();
@@ -56,7 +58,7 @@ const TasksList: React.FC = React.memo(() => {
skip: skipAutoRefetch,
refetchOnMountOrArgChange: false,
refetchOnReconnect: false,
refetchOnFocus: false
refetchOnFocus: false,
});
const { t } = useTranslation('home');
@@ -86,16 +88,21 @@ const TasksList: React.FC = React.memo(() => {
useEffect(() => {
dispatch(fetchLabels());
dispatch(fetchPriorities());
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
dispatch(
getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true })
);
}, [dispatch]);
const handleSelectTask = useCallback((task : IMyTask) => {
dispatch(setSelectedTaskId(task.id || ''));
dispatch(fetchTask({ taskId: task.id || '', projectId: task.project_id || '' }));
dispatch(setProjectId(task.project_id || ''));
dispatch(setShowTaskDrawer(true));
dispatch(setHomeTasksConfig({ ...homeTasksConfig, selected_task_id: task.id || '' }));
}, [dispatch, setSelectedTaskId, setShowTaskDrawer, fetchTask, homeTasksConfig]);
const handleSelectTask = useCallback(
(task: IMyTask) => {
dispatch(setSelectedTaskId(task.id || ''));
dispatch(fetchTask({ taskId: task.id || '', projectId: task.project_id || '' }));
dispatch(setProjectId(task.project_id || ''));
dispatch(setShowTaskDrawer(true));
dispatch(setHomeTasksConfig({ ...homeTasksConfig, selected_task_id: task.id || '' }));
},
[dispatch, setSelectedTaskId, setShowTaskDrawer, fetchTask, homeTasksConfig]
);
const refetch = useCallback(() => {
setSkipAutoRefetch(false);
@@ -116,14 +123,11 @@ const TasksList: React.FC = React.memo(() => {
<span>{t('tasks.name')}</span>
</Flex>
),
width: '150px',
width: '40%',
render: (_, record) => (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Tooltip title={record.name}>
<Typography.Text
ellipsis={{ tooltip: true }}
style={{ maxWidth: 150 }}
>
<Typography.Text style={{ flex: 1, marginRight: 8 }}>
{record.name}
</Typography.Text>
</Tooltip>
@@ -151,12 +155,14 @@ const TasksList: React.FC = React.memo(() => {
{
key: 'project',
title: t('tasks.project'),
width: '120px',
width: '25%',
render: (_, record) => {
return (
<Tooltip title={record.project_name}>
<Typography.Paragraph style={{ margin: 0, paddingInlineEnd: 6, maxWidth:120 }} ellipsis={{ tooltip: true }}>
<Badge color={record.phase_color || 'blue'} style={{ marginInlineEnd: 4 }} />
<Typography.Paragraph
style={{ margin: 0, paddingInlineEnd: 6 }}
>
<Badge color={record.project_color || 'blue'} style={{ marginInlineEnd: 4 }} />
{record.project_name}
</Typography.Paragraph>
</Tooltip>
@@ -166,7 +172,7 @@ const TasksList: React.FC = React.memo(() => {
{
key: 'status',
title: t('tasks.status'),
width: '180px',
width: '20%',
render: (_, record) => (
<HomeTasksStatusDropdown task={record} teamId={record.team_id || ''} />
),
@@ -174,11 +180,9 @@ const TasksList: React.FC = React.memo(() => {
{
key: 'dueDate',
title: t('tasks.dueDate'),
width: '180px',
width: '15%',
dataIndex: 'end_date',
render: (_, record) => (
<HomeTasksDatePicker record={record} />
),
render: (_, record) => <HomeTasksDatePicker record={record} />,
},
],
[t, data?.body?.total, currentPage, pageSize, handlePageChange]
@@ -201,7 +205,9 @@ const TasksList: React.FC = React.memo(() => {
useEffect(() => {
dispatch(fetchLabels());
dispatch(fetchPriorities());
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
dispatch(
getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true })
);
}, [dispatch]);
return (
@@ -231,7 +237,7 @@ const TasksList: React.FC = React.memo(() => {
<Segmented<'List' | 'Calendar'>
options={[
{ value: 'List', label: t('tasks.list') },
{ value: 'Calendar', label: t('tasks.calendar') }
{ value: 'Calendar', label: t('tasks.calendar') },
]}
defaultValue="List"
onChange={handleSegmentChange}
@@ -259,23 +265,34 @@ 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."
/>
) : (
<>
<Table
className="custom-two-colors-row-table"
dataSource={data?.body.tasks ? data.body.tasks.slice((currentPage - 1) * pageSize, currentPage * pageSize) : []}
dataSource={
data?.body.tasks
? data.body.tasks.slice((currentPage - 1) * pageSize, currentPage * pageSize)
: []
}
rowKey={record => record.id || ''}
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' }}>
<div
style={{
marginTop: 16,
textAlign: 'right',
display: 'flex',
justifyContent: 'flex-end',
}}
>
<Pagination
current={currentPage}
pageSize={pageSize}

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

@@ -8,21 +8,23 @@ const LicenseExpired = () => {
const navigate = useNavigate();
const { t } = useTranslation('license-expired');
const authService = useAuthService();
// Direct fallback content in case of translation issues
const fallbackTitle = "Your Worklenz trial has expired!";
const fallbackSubtitle = "Please upgrade now.";
const fallbackButton = "Upgrade now";
const fallbackTitle = 'Your Worklenz trial has expired!';
const fallbackSubtitle = 'Please upgrade now.';
const fallbackButton = 'Upgrade now';
return (
<div style={{
marginBlock: 65,
minHeight: '90vh',
padding: '20px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center'
}}>
<div
style={{
marginBlock: 65,
minHeight: '90vh',
padding: '20px',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Result
status="warning"
title={t('title') || fallbackTitle}

View File

@@ -25,3 +25,84 @@
:where(.css-dev-only-do-not-override-17sis5b).ant-tabs-bottom > div > .ant-tabs-nav::before {
border: none;
}
.project-group-container {
margin-top: 16px;
}
.project-group {
margin-bottom: 32px;
}
.project-group-header {
display: flex;
align-items: center;
margin-bottom: 16px;
gap: 8px;
}
.group-color-indicator {
width: 12px;
height: 12px;
border-radius: 50%;
display: inline-block;
}
.group-stats {
margin-left: 8px;
font-size: 14px;
font-weight: normal;
}
.project-card {
height: 100%;
overflow: hidden;
}
.project-card .ant-card-cover {
height: 4px;
}
.project-status-bar {
width: 100%;
height: 100%;
}
.project-card-content {
padding: 8px;
}
.project-title {
margin-bottom: 8px !important;
min-height: 44px;
}
.project-client {
display: block;
margin-bottom: 12px;
font-size: 12px;
}
.project-progress {
margin-bottom: 12px;
}
.project-meta {
display: flex;
justify-content: space-between;
font-size: 12px;
color: #666;
margin-bottom: 8px;
}
.project-meta span {
display: flex;
align-items: center;
gap: 4px;
}
.project-status-tag {
margin-top: 8px;
width: 100%;
text-align: center;
}

View File

@@ -1,26 +1,43 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ProjectViewType, ProjectGroupBy } from '@/types/project/project.types';
import { setViewMode, setGroupBy } from '@features/project/project-view-slice';
import debounce from 'lodash/debounce';
import {
Button,
Card,
Empty,
Flex,
Input,
Pagination,
Segmented,
Skeleton,
Select,
Table,
TablePaginationConfig,
Tooltip,
} from 'antd';
import { PageHeader } from '@ant-design/pro-components';
import { SearchOutlined, SyncOutlined } from '@ant-design/icons';
import {
SearchOutlined,
SyncOutlined,
UnorderedListOutlined,
AppstoreOutlined,
} from '@ant-design/icons';
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
import TableColumns from '@/components/project-list/TableColumns';
import { ColumnsType } from 'antd/es/table';
import { ColumnFilterItem } from 'antd/es/table/interface';
import Avatars from '@/components/avatars/avatars';
import { ActionButtons } from '@/components/project-list/project-list-table/project-list-actions/project-list-actions';
import { CategoryCell } from '@/components/project-list/project-list-table/project-list-category/project-list-category';
import { ProgressListProgress } from '@/components/project-list/project-list-table/project-list-progress/progress-list-progress';
import { ProjectListUpdatedAt } from '@/components/project-list/project-list-table/project-list-updated-at/project-list-updated';
import { ProjectNameCell } from '@/components/project-list/project-list-table/project-name/project-name-cell';
import { ProjectRateCell } from '@/components/project-list/project-list-table/project-list-favorite/project-rate-cell';
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
import { useGetProjectsQuery } from '@/api/projects/projects.v1.api.service';
@@ -43,6 +60,8 @@ import {
setFilteredCategories,
setFilteredStatuses,
setRequestParams,
setGroupedRequestParams,
fetchGroupedProjects,
} from '@/features/projects/projectsSlice';
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
import { fetchProjectCategories } from '@/features/projects/lookups/projectCategories/projectCategoriesSlice';
@@ -50,12 +69,25 @@ import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/pr
import { setProjectId, setStatuses } from '@/features/project/project.slice';
import { setProject } from '@/features/project/project.slice';
import { createPortal } from 'react-dom';
import { evt_projects_page_visit, evt_projects_refresh_click, evt_projects_search } from '@/shared/worklenz-analytics-events';
import {
evt_projects_page_visit,
evt_projects_refresh_click,
evt_projects_search,
} from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import ProjectGroupList from '@/components/project-list/project-group/project-group-list';
const createFilters = (items: { id: string; name: string }[]) =>
items.map(item => ({ text: item.name, value: item.id })) as ColumnFilterItem[];
const ProjectList: React.FC = () => {
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
const [isLoading, setIsLoading] = useState(false);
const [searchValue, setSearchValue] = useState('');
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastQueryParamsRef = useRef<string>('');
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const { t } = useTranslation('all-project-list');
const dispatch = useAppDispatch();
const navigate = useNavigate();
@@ -63,6 +95,140 @@ const ProjectList: React.FC = () => {
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const { trackMixpanelEvent } = useMixpanelTracking();
// Get view state from Redux
const { mode: viewMode, groupBy } = useAppSelector(state => state.projectViewReducer);
const { requestParams, groupedRequestParams, groupedProjects } = useAppSelector(
state => state.projectsReducer
);
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
const { projectHealths } = useAppSelector(state => state.projectHealthReducer);
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
const { filteredCategories, filteredStatuses } = useAppSelector(state => state.projectsReducer);
// Optimize query parameters to prevent unnecessary re-renders
const optimizedQueryParams = useMemo(() => {
const params = {
index: requestParams.index,
size: requestParams.size,
field: requestParams.field,
order: requestParams.order,
search: requestParams.search,
filter: requestParams.filter,
statuses: requestParams.statuses,
categories: requestParams.categories,
};
// Create a stable key for comparison
const paramsKey = JSON.stringify(params);
// Only return new params if they've actually changed
if (paramsKey !== lastQueryParamsRef.current) {
lastQueryParamsRef.current = paramsKey;
return params;
}
// Return the previous params to maintain reference stability
return JSON.parse(lastQueryParamsRef.current || '{}');
}, [requestParams]);
// Use the optimized query with better error handling and caching
const {
data: projectsData,
isLoading: loadingProjects,
isFetching: isFetchingProjects,
refetch: refetchProjects,
error: projectsError,
} = useGetProjectsQuery(optimizedQueryParams, {
// Enable caching and reduce unnecessary refetches
refetchOnMountOrArgChange: 30, // Refetch if data is older than 30 seconds
refetchOnFocus: false, // Don't refetch on window focus
refetchOnReconnect: true, // Refetch on network reconnect
// Skip query if we're in group view mode
skip: viewMode === ProjectViewType.GROUP,
});
// Add performance monitoring
const performanceRef = useRef<{ startTime: number | null }>({ startTime: null });
// Monitor query performance
useEffect(() => {
if (loadingProjects && !performanceRef.current.startTime) {
performanceRef.current.startTime = performance.now();
} else if (!loadingProjects && performanceRef.current.startTime) {
performanceRef.current.startTime = null;
}
}, [loadingProjects]);
// Optimized debounced search with better cleanup and performance
const debouncedSearch = useCallback(
debounce((searchTerm: string) => {
// Clear any error messages when starting a new search
setErrorMessage(null);
if (viewMode === ProjectViewType.LIST) {
dispatch(setRequestParams({
search: searchTerm,
index: 1 // Reset to first page on search
}));
} else if (viewMode === ProjectViewType.GROUP) {
const newGroupedParams = {
...groupedRequestParams,
search: searchTerm,
index: 1,
};
dispatch(setGroupedRequestParams(newGroupedParams));
// Add timeout for grouped search to prevent rapid API calls
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
searchTimeoutRef.current = setTimeout(() => {
dispatch(fetchGroupedProjects(newGroupedParams));
}, 100);
}
}, 500), // Increased debounce time for better performance
[dispatch, viewMode, groupedRequestParams]
);
// Enhanced cleanup with better timeout management
useEffect(() => {
return () => {
debouncedSearch.cancel();
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
searchTimeoutRef.current = null;
}
};
}, [debouncedSearch]);
// Improved search change handler with better validation
const handleSearchChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newSearchValue = e.target.value;
// Validate input length to prevent excessive API calls
if (newSearchValue.length > 100) {
return; // Prevent extremely long search terms
}
setSearchValue(newSearchValue);
trackMixpanelEvent(evt_projects_search);
// Clear any existing timeout
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
searchTimeoutRef.current = null;
}
// Debounce the actual search execution
debouncedSearch(newSearchValue);
},
[debouncedSearch, trackMixpanelEvent]
);
const getFilterIndex = useCallback(() => {
return +(localStorage.getItem(FILTER_INDEX_KEY) || 0);
}, []);
@@ -76,104 +242,74 @@ const ProjectList: React.FC = () => {
localStorage.setItem(PROJECT_SORT_ORDER, order);
}, []);
const { requestParams } = useAppSelector(state => state.projectsReducer);
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
const { projectHealths } = useAppSelector(state => state.projectHealthReducer);
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
const {
data: projectsData,
isLoading: loadingProjects,
isFetching: isFetchingProjects,
refetch: refetchProjects,
} = useGetProjectsQuery(requestParams);
const filters = useMemo(() => Object.values(IProjectFilter), []);
// Create translated segment options for the filters
const segmentOptions = useMemo(() => {
return filters.map(filter => ({
value: filter,
label: t(filter.toLowerCase())
label: t(filter.toLowerCase()),
}));
}, [filters, t]);
useEffect(() => {
setIsLoading(loadingProjects || isFetchingProjects);
}, [loadingProjects, isFetchingProjects]);
useEffect(() => {
const filterIndex = getFilterIndex();
dispatch(setRequestParams({ filter: filterIndex }));
}, [dispatch, getFilterIndex]);
useEffect(() => {
trackMixpanelEvent(evt_projects_page_visit);
refetchProjects();
}, [requestParams, refetchProjects]);
const handleTableChange = useCallback(
(
newPagination: TablePaginationConfig,
filters: Record<string, FilterValue | null>,
sorter: SorterResult<IProjectViewModel> | SorterResult<IProjectViewModel>[]
) => {
const newParams: Partial<typeof requestParams> = {};
if (!filters?.status_id) {
newParams.statuses = null;
dispatch(setFilteredStatuses([]));
} else {
// dispatch(setFilteredStatuses(filters.status_id as Array<string>));
newParams.statuses = filters.status_id.join(' ');
}
if (!filters?.category_id) {
newParams.categories = null;
dispatch(setFilteredCategories([]));
} else {
// dispatch(setFilteredCategories(filters.category_id as Array<string>));
newParams.categories = filters.category_id.join(' ');
}
const newOrder = Array.isArray(sorter) ? sorter[0].order : sorter.order;
const newField = (Array.isArray(sorter) ? sorter[0].columnKey : sorter.columnKey) as string;
if (newOrder && newField) {
newParams.order = newOrder ?? 'ascend';
newParams.field = newField ?? 'name';
setSortingValues(newParams.field, newParams.order);
}
newParams.index = newPagination.current || 1;
newParams.size = newPagination.pageSize || DEFAULT_PAGE_SIZE;
dispatch(setRequestParams(newParams));
setFilteredInfo(filters);
},
[setSortingValues]
const viewToggleOptions = useMemo(
() => [
{
value: ProjectViewType.LIST,
label: (
<Tooltip title={t('listView')}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<UnorderedListOutlined />
<span>{t('list')}</span>
</div>
</Tooltip>
),
},
{
value: ProjectViewType.GROUP,
label: (
<Tooltip title={t('groupView')}>
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<AppstoreOutlined />
<span>{t('group')}</span>
</div>
</Tooltip>
),
},
],
[t]
);
const handleRefresh = useCallback(() => {
trackMixpanelEvent(evt_projects_refresh_click);
refetchProjects();
}, [refetchProjects, requestParams]);
const handleSegmentChange = useCallback(
(value: IProjectFilter) => {
const newFilterIndex = filters.indexOf(value);
setFilterIndex(newFilterIndex);
dispatch(setRequestParams({ filter: newFilterIndex }));
refetchProjects();
},
[filters, setFilterIndex, refetchProjects]
const groupByOptions = useMemo(
() => [
{
value: ProjectGroupBy.CATEGORY,
label: t('groupBy.category'),
},
{
value: ProjectGroupBy.CLIENT,
label: t('groupBy.client'),
},
],
[t]
);
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
trackMixpanelEvent(evt_projects_search);
const value = e.target.value;
dispatch(setRequestParams({ search: value }));
}, []);
// Memoize category filters to prevent unnecessary recalculations
const categoryFilters = useMemo(
() =>
createFilters(
projectCategories.map(category => ({ id: category.id || '', name: category.name || '' }))
),
[projectCategories]
);
// Memoize status filters to prevent unnecessary recalculations
const statusFilters = useMemo(
() =>
createFilters(
projectStatuses.map(status => ({ id: status.id || '', name: status.name || '' }))
),
[projectStatuses]
);
const paginationConfig = useMemo(
() => ({
@@ -188,27 +324,488 @@ const ProjectList: React.FC = () => {
[requestParams.index, requestParams.size, projectsData?.body?.total]
);
const handleDrawerClose = () => {
const groupedPaginationConfig = useMemo(
() => ({
current: groupedRequestParams.index,
pageSize: groupedRequestParams.size,
showSizeChanger: true,
defaultPageSize: DEFAULT_PAGE_SIZE,
pageSizeOptions: PAGE_SIZE_OPTIONS,
size: 'small' as const,
total: groupedProjects.data?.total_groups || 0,
}),
[groupedRequestParams.index, groupedRequestParams.size, groupedProjects.data?.total_groups]
);
// Memoize the project count calculation for the header
const projectCount = useMemo(() => {
if (viewMode === ProjectViewType.LIST) {
return projectsData?.body?.total || 0;
} else {
return (
groupedProjects.data?.data?.reduce((total, group) => total + group.project_count, 0) || 0
);
}
}, [viewMode, projectsData?.body?.total, groupedProjects.data?.data]);
// Memoize the grouped projects data transformation
const transformedGroupedProjects = useMemo(() => {
return (
groupedProjects.data?.data?.map(group => ({
groupKey: group.group_key,
groupName: group.group_name,
groupColor: group.group_color,
projects: group.projects,
count: group.project_count,
totalProgress: 0,
totalTasks: 0,
})) || []
);
}, [groupedProjects.data?.data]);
// Memoize the table data source
const tableDataSource = useMemo(() => projectsData?.body?.data || [], [projectsData?.body?.data]);
// Handle query errors
useEffect(() => {
if (projectsError) {
setErrorMessage('Failed to load projects. Please try again.');
} else {
setErrorMessage(null);
}
}, [projectsError]);
// Optimized refresh handler with better error handling
const handleRefresh = useCallback(async () => {
try {
trackMixpanelEvent(evt_projects_refresh_click);
setIsLoading(true);
setErrorMessage(null);
if (viewMode === ProjectViewType.LIST) {
await refetchProjects();
} else if (viewMode === ProjectViewType.GROUP && groupBy) {
await dispatch(fetchGroupedProjects(groupedRequestParams)).unwrap();
}
} catch (error) {
setErrorMessage('Failed to refresh projects. Please try again.');
} finally {
setIsLoading(false);
}
}, [trackMixpanelEvent, refetchProjects, viewMode, groupBy, dispatch, groupedRequestParams]);
// Enhanced empty text with error handling
const emptyContent = useMemo(() => {
if (errorMessage) {
return (
<Empty
description={
<div>
<p>{errorMessage}</p>
<Button type="primary" onClick={handleRefresh} loading={isLoading}>
Retry
</Button>
</div>
}
/>
);
}
return <Empty description={t('noProjects')} />;
}, [errorMessage, handleRefresh, isLoading, t]);
// Memoize the pagination show total function
const paginationShowTotal = useMemo(
() => (total: number, range: [number, number]) => `${range[0]}-${range[1]} of ${total} groups`,
[]
);
const handleTableChange = useCallback(
(
newPagination: TablePaginationConfig,
filters: Record<string, FilterValue | null>,
sorter: SorterResult<IProjectViewModel> | SorterResult<IProjectViewModel>[]
) => {
// Batch all parameter updates to reduce re-renders
const updates: Partial<typeof requestParams> = {};
let hasChanges = false;
// Handle status filters
if (filters?.status_id !== filteredInfo.status_id) {
if (!filters?.status_id) {
updates.statuses = null;
dispatch(setFilteredStatuses([]));
} else {
updates.statuses = filters.status_id.join(' ');
}
hasChanges = true;
}
// Handle category filters
if (filters?.category_id !== filteredInfo.category_id) {
if (!filters?.category_id) {
updates.categories = null;
dispatch(setFilteredCategories([]));
} else {
updates.categories = filters.category_id.join(' ');
}
hasChanges = true;
}
// Handle sorting
const newOrder = Array.isArray(sorter) ? sorter[0].order : sorter.order;
const newField = (Array.isArray(sorter) ? sorter[0].columnKey : sorter.columnKey) as string;
if (newOrder && newField && (newOrder !== requestParams.order || newField !== requestParams.field)) {
updates.order = newOrder ?? 'ascend';
updates.field = newField ?? 'name';
setSortingValues(updates.field, updates.order);
hasChanges = true;
}
// Handle pagination
if (newPagination.current !== requestParams.index || newPagination.pageSize !== requestParams.size) {
updates.index = newPagination.current || 1;
updates.size = newPagination.pageSize || DEFAULT_PAGE_SIZE;
hasChanges = true;
}
// Only dispatch if there are actual changes
if (hasChanges) {
dispatch(setRequestParams(updates));
// Also update grouped request params to keep them in sync
dispatch(
setGroupedRequestParams({
...groupedRequestParams,
...updates,
})
);
}
setFilteredInfo(filters);
},
[dispatch, setSortingValues, groupedRequestParams, filteredInfo, requestParams]
);
// Optimized grouped table change handler
const handleGroupedTableChange = useCallback(
(newPagination: TablePaginationConfig) => {
const newParams: Partial<typeof groupedRequestParams> = {
index: newPagination.current || 1,
size: newPagination.pageSize || DEFAULT_PAGE_SIZE,
};
// Only update if values actually changed
if (newParams.index !== groupedRequestParams.index || newParams.size !== groupedRequestParams.size) {
dispatch(setGroupedRequestParams(newParams));
}
},
[dispatch, groupedRequestParams]
);
// Optimized segment change handler with better state management
const handleSegmentChange = useCallback(
(value: IProjectFilter) => {
const newFilterIndex = filters.indexOf(value);
setFilterIndex(newFilterIndex);
// Batch updates to reduce re-renders
const baseUpdates = { filter: newFilterIndex, index: 1 };
dispatch(setRequestParams(baseUpdates));
dispatch(setGroupedRequestParams({
...groupedRequestParams,
...baseUpdates,
}));
// Only trigger data fetch for group view (list view will auto-refetch via query)
if (viewMode === ProjectViewType.GROUP && groupBy) {
dispatch(fetchGroupedProjects({
...groupedRequestParams,
...baseUpdates,
}));
}
},
[filters, setFilterIndex, dispatch, groupedRequestParams, viewMode, groupBy]
);
const handleViewToggle = useCallback(
(value: ProjectViewType) => {
dispatch(setViewMode(value));
if (value === ProjectViewType.GROUP) {
// Initialize grouped request params when switching to group view
const newGroupedParams = {
...groupedRequestParams,
groupBy: groupBy || ProjectGroupBy.CATEGORY,
search: requestParams.search,
filter: requestParams.filter,
statuses: requestParams.statuses,
categories: requestParams.categories,
};
dispatch(setGroupedRequestParams(newGroupedParams));
// Fetch grouped data immediately
dispatch(fetchGroupedProjects(newGroupedParams));
}
},
[dispatch, groupBy, groupedRequestParams, requestParams]
);
const handleGroupByChange = useCallback(
(value: ProjectGroupBy) => {
dispatch(setGroupBy(value));
const newGroupedParams = {
...groupedRequestParams,
groupBy: value,
index: 1, // Reset to first page when changing grouping
};
dispatch(setGroupedRequestParams(newGroupedParams));
// Fetch new grouped data
dispatch(fetchGroupedProjects(newGroupedParams));
},
[dispatch, groupedRequestParams]
);
const handleDrawerClose = useCallback(() => {
dispatch(setProject({} as IProjectViewModel));
dispatch(setProjectId(null));
};
const navigateToProject = (project_id: string | undefined, default_view: string | undefined) => {
if (project_id) {
navigate(`/worklenz/projects/${project_id}?tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}&pinned_tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}`); // Update the route as per your project structure
}
};
}, [dispatch]);
const navigateToProject = useCallback(
(project_id: string | undefined, default_view: string | undefined) => {
if (project_id) {
navigate(
`/worklenz/projects/${project_id}?tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}&pinned_tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}`
);
}
},
[navigate]
);
// Preload project view components on hover for smoother navigation
const handleProjectHover = useCallback((project_id: string | undefined) => {
if (project_id) {
// Preload the project view route to reduce loading time
import('@/pages/projects/projectView/project-view').catch(() => {
// Silently fail if preload doesn't work
});
// Also preload critical task management components
import('@/components/task-management/task-list-board').catch(() => {
// Silently fail if preload doesn't work
});
}
}, []);
// Define table columns directly in the component to avoid hooks order issues
const tableColumns: ColumnsType<IProjectViewModel> = useMemo(
() => [
{
title: '',
dataIndex: 'favorite',
key: 'favorite',
render: (text: string, record: IProjectViewModel) => (
<ProjectRateCell key={record.id} t={t} record={record} />
),
},
{
title: t('name'),
dataIndex: 'name',
key: 'name',
sorter: true,
showSorterTooltip: false,
defaultSortOrder: 'ascend',
render: (text: string, record: IProjectViewModel) => (
<ProjectNameCell navigate={navigate} key={record.id} t={t} record={record} />
),
},
{
title: t('client'),
dataIndex: 'client_name',
key: 'client_name',
sorter: true,
showSorterTooltip: false,
},
{
title: t('category'),
dataIndex: 'category_name',
key: 'category_id',
filters: categoryFilters,
filteredValue: filteredInfo.category_id || filteredCategories || [],
filterMultiple: true,
render: (text: string, record: IProjectViewModel) => (
<CategoryCell key={record.id} t={t} record={record} />
),
sorter: true,
},
{
title: t('status'),
dataIndex: 'status',
key: 'status_id',
filters: statusFilters,
filteredValue: filteredInfo.status_id || filteredStatuses || [],
filterMultiple: true,
sorter: true,
},
{
title: t('tasksProgress'),
dataIndex: 'tasksProgress',
key: 'tasksProgress',
render: (_: string, record: IProjectViewModel) => <ProgressListProgress record={record} />,
},
{
title: t('updated_at'),
dataIndex: 'updated_at',
key: 'updated_at',
sorter: true,
showSorterTooltip: false,
render: (_: string, record: IProjectViewModel) => <ProjectListUpdatedAt record={record} />,
},
{
title: t('members'),
dataIndex: 'names',
key: 'members',
render: (members: InlineMember[]) => <Avatars members={members} />,
},
{
title: '',
key: 'button',
dataIndex: '',
render: (record: IProjectViewModel) => (
<ActionButtons
t={t}
record={record}
dispatch={dispatch}
isOwnerOrAdmin={isOwnerOrAdmin}
/>
),
},
],
[
t,
categoryFilters,
statusFilters,
filteredInfo,
filteredCategories,
filteredStatuses,
navigate,
dispatch,
isOwnerOrAdmin,
]
);
// Optimize useEffect hooks to reduce unnecessary API calls
useEffect(() => {
if (projectStatuses.length === 0) dispatch(fetchProjectStatuses());
if (projectCategories.length === 0) dispatch(fetchProjectCategories());
if (projectHealths.length === 0) dispatch(fetchProjectHealth());
}, [requestParams]);
const filterIndex = getFilterIndex();
const initialParams = { filter: filterIndex };
// Only update if values are different
if (requestParams.filter !== filterIndex) {
dispatch(setRequestParams(initialParams));
}
// Initialize grouped request params with proper groupBy value
if (!groupedRequestParams.groupBy) {
const initialGroupBy = groupBy || ProjectGroupBy.CATEGORY;
dispatch(setGroupedRequestParams({
filter: filterIndex,
index: 1,
size: DEFAULT_PAGE_SIZE,
field: 'name',
order: 'ascend',
search: '',
groupBy: initialGroupBy,
statuses: null,
categories: null,
}));
}
}, [dispatch, getFilterIndex, groupBy]); // Add groupBy to deps to handle initial state
// Separate effect for tracking page visits - only run once
useEffect(() => {
trackMixpanelEvent(evt_projects_page_visit);
}, [trackMixpanelEvent]);
// Enhanced effect for grouped projects - fetch data when in group view
useEffect(() => {
// Fetch grouped projects when:
// 1. View mode is GROUP
// 2. We have a groupBy value (either from Redux or default)
if (viewMode === ProjectViewType.GROUP && groupBy) {
// Always ensure grouped request params are properly set with current groupBy
const shouldUpdateParams = !groupedRequestParams.groupBy || groupedRequestParams.groupBy !== groupBy;
if (shouldUpdateParams) {
const updatedParams = {
...groupedRequestParams,
groupBy: groupBy,
// Ensure we have all required params for the API call
index: groupedRequestParams.index || 1,
size: groupedRequestParams.size || DEFAULT_PAGE_SIZE,
field: groupedRequestParams.field || 'name',
order: groupedRequestParams.order || 'ascend',
};
dispatch(setGroupedRequestParams(updatedParams));
dispatch(fetchGroupedProjects(updatedParams));
} else if (groupedRequestParams.groupBy === groupBy && !groupedProjects.data) {
// Params are set correctly but we don't have data yet - fetch it
dispatch(fetchGroupedProjects(groupedRequestParams));
}
}
}, [dispatch, viewMode, groupBy, groupedRequestParams, groupedProjects.data]);
// Optimize lookups loading - only fetch once
useEffect(() => {
const loadLookups = async () => {
const promises = [];
if (projectStatuses.length === 0) {
promises.push(dispatch(fetchProjectStatuses()));
}
if (projectCategories.length === 0) {
promises.push(dispatch(fetchProjectCategories()));
}
if (projectHealths.length === 0) {
promises.push(dispatch(fetchProjectHealth()));
}
// Load all lookups in parallel
if (promises.length > 0) {
await Promise.allSettled(promises);
}
};
loadLookups();
}, [dispatch]); // Remove length dependencies to avoid re-runs
// Sync search input value with Redux state
useEffect(() => {
const currentSearch = viewMode === ProjectViewType.LIST ? requestParams.search : groupedRequestParams.search;
if (searchValue !== (currentSearch || '')) {
setSearchValue(currentSearch || '');
}
}, [requestParams.search, groupedRequestParams.search, viewMode]); // Remove searchValue from deps to prevent loops
// Optimize loading state management
useEffect(() => {
let newLoadingState = false;
if (viewMode === ProjectViewType.LIST) {
newLoadingState = loadingProjects || isFetchingProjects;
} else {
newLoadingState = groupedProjects.loading;
}
// Only update if loading state actually changed
if (isLoading !== newLoadingState) {
setIsLoading(newLoadingState);
}
}, [loadingProjects, isFetchingProjects, viewMode, groupedProjects.loading, isLoading]);
return (
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
<PageHeader
className="site-page-header"
title={`${projectsData?.body?.total || 0} ${t('projects')}`}
title={`${projectCount} ${t('projects')}`}
style={{ padding: '16px 0' }}
extra={
<Flex gap={8} align="center">
@@ -225,38 +822,74 @@ const ProjectList: React.FC = () => {
defaultValue={filters[getFilterIndex()] ?? filters[0]}
onChange={handleSegmentChange}
/>
<Segmented options={viewToggleOptions} value={viewMode} onChange={handleViewToggle} />
{viewMode === ProjectViewType.GROUP && (
<Select
value={groupBy}
onChange={handleGroupByChange}
options={groupByOptions}
style={{ width: 150 }}
/>
)}
<Input
placeholder={t('placeholder')}
suffix={<SearchOutlined />}
type="text"
value={requestParams.search}
value={searchValue}
onChange={handleSearchChange}
aria-label="Search projects"
allowClear
onClear={() => {
setSearchValue('');
debouncedSearch('');
}}
/>
{isOwnerOrAdmin && <CreateProjectButton />}
</Flex>
}
/>
<Card className="project-card">
<Skeleton active loading={isLoading} className='mt-4 p-4'>
{viewMode === ProjectViewType.LIST ? (
<Table<IProjectViewModel>
columns={TableColumns({
navigate,
filteredInfo,
})}
dataSource={projectsData?.body?.data || []}
columns={tableColumns}
dataSource={tableDataSource}
rowKey={record => record.id || ''}
loading={loadingProjects}
loading={loadingProjects || isFetchingProjects}
size="small"
onChange={handleTableChange}
pagination={paginationConfig}
locale={{ emptyText: <Empty description={t('noProjects')} /> }}
locale={{ emptyText: emptyContent }}
onRow={record => ({
onClick: () => navigateToProject(record.id, record.team_member_default_view), // Navigate to project on row click
onClick: () => navigateToProject(record.id, record.team_member_default_view),
onMouseEnter: () => handleProjectHover(record.id),
})}
/>
</Skeleton>
) : (
<div>
<ProjectGroupList
groups={transformedGroupedProjects}
navigate={navigate}
onProjectSelect={id => navigateToProject(id, undefined)}
onArchive={() => {}}
isOwnerOrAdmin={isOwnerOrAdmin}
loading={groupedProjects.loading}
t={t}
/>
{!groupedProjects.loading &&
groupedProjects.data?.data &&
groupedProjects.data.data.length > 0 && (
<div style={{ marginTop: '24px', textAlign: 'center' }}>
<Pagination
{...groupedPaginationConfig}
onChange={(page, pageSize) =>
handleGroupedTableChange({ current: page, pageSize })
}
showTotal={paginationShowTotal}
/>
</div>
)}
</div>
)}
</Card>
{createPortal(<ProjectDrawer onClose={handleDrawerClose} />, document.body, 'project-drawer')}

View File

@@ -21,7 +21,7 @@ const ProjectViewRoadmap = () => {
<Flex>
{/* table */}
<div className="after:content relative h-fit w-full max-w-[500px] after:absolute after:-right-3 after:top-0 after:z-10 after:min-h-full after:w-3 after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent">
<div className="after:content relative h-fit w-full max-w-[500px] after:absolute after:-right-3 after:top-0 after:z-10 after:min-h-full after:w-3 after:bg-linear-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent">
<RoadmapTable />
</div>

View File

@@ -109,9 +109,9 @@ const RoadmapTable = () => {
}));
// Layout styles for table and columns
const customHeaderColumnStyles = `border px-2 h-[50px] text-left z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
const customHeaderColumnStyles = `border px-2 h-[50px] text-left z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] after:w-1.5 after:bg-transparent after:bg-linear-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
const customBodyColumnStyles = `border px-2 h-[50px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent ${themeMode === 'dark' ? 'bg-transparent border-[#303030]' : 'bg-transparent'}`;
const customBodyColumnStyles = `border px-2 h-[50px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-h-[40px] after:w-1.5 after:bg-transparent after:bg-linear-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent ${themeMode === 'dark' ? 'bg-transparent border-[#303030]' : 'bg-transparent'}`;
const rowBackgroundStyles =
themeMode === 'dark' ? 'even:bg-[#1b1b1b] odd:bg-[#141414]' : 'even:bg-[#f4f4f4] odd:bg-white';

View File

@@ -24,7 +24,7 @@ const RoadmapTaskCell = ({ task, isSubtask = false }: RoadmapTaskCellProps) => {
return (
<button
onClick={() => dispatch(toggleTaskExpansion(id))}
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
className="hover flex h-4 w-4 items-center justify-center rounded-sm text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
>
{task.isExpanded ? <DownOutlined /> : <RightOutlined />}
</button>
@@ -36,7 +36,7 @@ const RoadmapTaskCell = ({ task, isSubtask = false }: RoadmapTaskCellProps) => {
return !isSubtask ? (
<button
onClick={() => dispatch(toggleTaskExpansion(id))}
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
className="hover flex h-4 w-4 items-center justify-center rounded-sm text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
>
{task.isExpanded ? <DownOutlined /> : <RightOutlined />}
</button>

View File

@@ -1,12 +1,103 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { Checkbox, Flex, Tag, Tooltip } from 'antd';
import { useVirtualizer } from '@tanstack/react-virtual';
import { HolderOutlined } from '@ant-design/icons';
import {
DndContext,
DragEndEvent,
DragStartEvent,
DragOverlay,
PointerSensor,
useSensor,
useSensors,
KeyboardSensor,
TouchSensor,
UniqueIdentifier,
} from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { SocketEvents } from '@/shared/socket-events';
import { reorderTasks } from '@/features/tasks/tasks.slice';
import { evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics-events';
// Draggable Row Component
interface DraggableRowProps {
task: IProjectTask;
visibleColumns: Array<{ key: string; width: number }>;
renderCell: (
columnKey: string | number,
task: IProjectTask,
isSubtask?: boolean
) => React.ReactNode;
hoverRow: string | null;
onRowHover: (taskId: string | null) => void;
isSubtask?: boolean;
}
const DraggableRow = ({
task,
visibleColumns,
renderCell,
hoverRow,
onRowHover,
isSubtask = false,
}: DraggableRowProps) => {
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: task.id as UniqueIdentifier,
data: {
type: 'task',
task,
},
disabled: isSubtask, // Disable drag for subtasks
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 1000 : 'auto',
};
return (
<div
ref={setNodeRef}
style={style}
className="flex w-full border-b hover:bg-gray-50 dark:hover:bg-gray-800"
onMouseEnter={() => onRowHover(task.id)}
onMouseLeave={() => onRowHover(null)}
>
<div className="sticky left-0 z-10 w-8 flex items-center justify-center">
{!isSubtask && (
<div {...attributes} {...listeners} className="cursor-grab active:cursor-grabbing">
<HolderOutlined />
</div>
)}
</div>
{visibleColumns.map(column => (
<div
key={column.key}
className={`flex items-center px-3 border-r ${
hoverRow === task.id ? 'bg-gray-50 dark:bg-gray-800' : ''
}`}
style={{ width: column.width }}
>
{renderCell(column.key, task, isSubtask)}
</div>
))}
</div>
);
};
const TaskListTable = ({
taskListGroup,
tableId,
visibleColumns,
onTaskSelect,
onTaskExpand,
@@ -18,11 +109,38 @@ const TaskListTable = ({
onTaskExpand?: (taskId: string) => void;
}) => {
const [hoverRow, setHoverRow] = useState<string | null>(null);
const [activeId, setActiveId] = useState<UniqueIdentifier | null>(null);
const tableRef = useRef<HTMLDivElement | null>(null);
const parentRef = useRef<HTMLDivElement | null>(null);
const themeMode = useAppSelector(state => state.themeReducer.mode);
// Memoize all tasks including subtasks for virtualization
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { projectId } = useAppSelector(state => state.projectReducer);
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
const dispatch = useAppDispatch();
const { socket } = useSocket();
const currentSession = useAuthService().getCurrentSession();
const { trackMixpanelEvent } = useMixpanelTracking();
// Configure sensors for drag and drop
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 5,
},
})
);
// Memoize all tasks including subtasks
const flattenedTasks = useMemo(() => {
return taskListGroup.tasks.reduce((acc: IProjectTask[], task: IProjectTask) => {
acc.push(task);
@@ -33,13 +151,10 @@ const TaskListTable = ({
}, []);
}, [taskListGroup.tasks]);
// Virtual row renderer
const rowVirtualizer = useVirtualizer({
count: flattenedTasks.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 42, // row height
overscan: 5,
});
// Get only main tasks for sortable context (exclude subtasks)
const mainTasks = useMemo(() => {
return taskListGroup.tasks.filter(task => !task.isSubtask);
}, [taskListGroup.tasks]);
// Memoize cell render functions
const renderCell = useCallback(
@@ -54,7 +169,7 @@ const TaskListTable = ({
);
},
task: () => (
<Flex align="center" className="pl-2">
<Flex align="center" className={isSubtask ? 'pl-6' : 'pl-2'}>
{task.name}
</Flex>
),
@@ -66,6 +181,82 @@ const TaskListTable = ({
[]
);
// Handle drag start
const handleDragStart = useCallback((event: DragStartEvent) => {
setActiveId(event.active.id);
document.body.style.cursor = 'grabbing';
}, []);
// Handle drag end with socket integration
const handleDragEnd = useCallback(
(event: DragEndEvent) => {
const { active, over } = event;
setActiveId(null);
document.body.style.cursor = '';
if (!over || active.id === over.id) {
return;
}
const activeIndex = mainTasks.findIndex(task => task.id === active.id);
const overIndex = mainTasks.findIndex(task => task.id === over.id);
if (activeIndex !== -1 && overIndex !== -1) {
const activeTask = mainTasks[activeIndex];
const overTask = mainTasks[overIndex];
// Create updated task arrays
const updatedTasks = [...mainTasks];
updatedTasks.splice(activeIndex, 1);
updatedTasks.splice(overIndex, 0, activeTask);
// Dispatch Redux action for optimistic update
dispatch(
reorderTasks({
activeGroupId: tableId,
overGroupId: tableId,
fromIndex: activeIndex,
toIndex: overIndex,
task: activeTask,
updatedSourceTasks: updatedTasks,
updatedTargetTasks: updatedTasks,
})
);
// Emit socket event for backend persistence
if (socket && projectId && currentSession?.team_id) {
const toPos = overTask?.sort_order || mainTasks[mainTasks.length - 1]?.sort_order || -1;
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
project_id: projectId,
from_index: activeTask.sort_order,
to_index: toPos,
to_last_index: overIndex === mainTasks.length - 1,
from_group: tableId,
to_group: tableId,
group_by: groupBy,
task: activeTask,
team_id: currentSession.team_id,
});
// Track analytics event
trackMixpanelEvent(evt_project_task_list_drag_and_move);
}
}
},
[
mainTasks,
tableId,
dispatch,
socket,
projectId,
currentSession?.team_id,
groupBy,
trackMixpanelEvent,
]
);
// Memoize header rendering
const TableHeader = useMemo(
() => (
@@ -94,48 +285,54 @@ const TaskListTable = ({
target.classList.toggle('show-shadow', hasHorizontalShadow);
}, []);
return (
<div ref={parentRef} className="h-[400px] overflow-auto" onScroll={handleScroll}>
{TableHeader}
// Find active task for drag overlay
const activeTask = activeId ? flattenedTasks.find(task => task.id === activeId) : null;
<div
ref={tableRef}
style={{
height: `${rowVirtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
return (
<DndContext sensors={sensors} onDragStart={handleDragStart} onDragEnd={handleDragEnd}>
<div ref={parentRef} className="h-[400px] overflow-auto" onScroll={handleScroll}>
{TableHeader}
<SortableContext
items={mainTasks.map(task => task.id)}
strategy={verticalListSortingStrategy}
>
<div ref={tableRef} style={{ width: '100%' }}>
{flattenedTasks.map((task, index) => (
<DraggableRow
key={task.id}
task={task}
visibleColumns={visibleColumns}
renderCell={renderCell}
hoverRow={hoverRow}
onRowHover={setHoverRow}
isSubtask={task.isSubtask}
/>
))}
</div>
</SortableContext>
</div>
<DragOverlay
dropAnimation={{
duration: 200,
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
}}
>
{rowVirtualizer.getVirtualItems().map(virtualRow => {
const task = flattenedTasks[virtualRow.index];
return (
<div
key={task.id}
className="absolute top-0 left-0 flex w-full border-b hover:bg-gray-50 dark:hover:bg-gray-800"
style={{
height: 42,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div className="sticky left-0 z-10 w-8 flex items-center justify-center">
{/* <Checkbox checked={task.selected} /> */}
</div>
{visibleColumns.map(column => (
<div
key={column.key}
className={`flex items-center px-3 border-r ${
hoverRow === task.id ? 'bg-gray-50 dark:bg-gray-800' : ''
}`}
style={{ width: column.width }}
>
{renderCell(column.key, task, task.is_sub_task)}
</div>
))}
</div>
);
})}
</div>
</div>
{activeTask && (
<div className="bg-white dark:bg-gray-800 shadow-lg rounded-sm border">
<DraggableRow
task={activeTask}
visibleColumns={visibleColumns}
renderCell={renderCell}
hoverRow={null}
onRowHover={() => {}}
isSubtask={activeTask.isSubtask}
/>
</div>
)}
</DragOverlay>
</DndContext>
);
};

View File

@@ -111,6 +111,24 @@ export const createColumns = ({
),
}),
columnHelper.accessor('status', {
header: 'Status',
id: COLUMN_KEYS.STATUS,
size: 120,
enablePinning: false,
cell: ({ row }) => (
<StatusDropdown
key={`${row.original.id}-status`}
statusList={statuses}
task={row.original}
teamId={getCurrentSession()?.team_id || ''}
onChange={statusId => {
console.log('Status changed:', statusId);
}}
/>
),
}),
columnHelper.accessor('names', {
header: 'Assignees',
id: COLUMN_KEYS.ASSIGNEES,
@@ -163,24 +181,6 @@ export const createColumns = ({
cell: ({ row }) => <TaskRowDueTime dueTime={row.original.due_time || ''} />,
}),
columnHelper.accessor('status', {
header: 'Status',
id: COLUMN_KEYS.STATUS,
size: 120,
enablePinning: false,
cell: ({ row }) => (
<StatusDropdown
key={`${row.original.id}-status`}
statusList={statuses}
task={row.original}
teamId={getCurrentSession()?.team_id || ''}
onChange={statusId => {
console.log('Status changed:', statusId);
}}
/>
),
}),
columnHelper.accessor('labels', {
header: 'Labels',
id: COLUMN_KEYS.LABELS,

View File

@@ -10,7 +10,6 @@ import {
Row,
Column,
} from '@tanstack/react-table';
import { useVirtualizer } from '@tanstack/react-virtual';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { useAppSelector } from '@/hooks/useAppSelector';
import React from 'react';
@@ -78,19 +77,6 @@ const TaskListCustom: React.FC<TaskListCustomProps> = ({ tasks, color, groupId,
const { rows } = table.getRowModel();
const rowVirtualizer = useVirtualizer({
count: rows.length,
getScrollElement: () => tableContainerRef.current,
estimateSize: () => 50,
overscan: 20,
});
const virtualRows = rowVirtualizer.getVirtualItems();
const totalSize = rowVirtualizer.getTotalSize();
const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0;
const paddingBottom =
virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0;
const columnToggleItems = columns.map(column => ({
key: column.id as string,
label: (
@@ -125,6 +111,7 @@ const TaskListCustom: React.FC<TaskListCustomProps> = ({ tasks, color, groupId,
flex: 1,
minHeight: 0,
overflowX: 'auto',
overflowY: 'auto',
maxHeight: '100%',
}}
>
@@ -161,80 +148,75 @@ const TaskListCustom: React.FC<TaskListCustomProps> = ({ tasks, color, groupId,
))}
</div>
<div className="table-body">
{paddingTop > 0 && <div style={{ height: `${paddingTop}px` }} />}
{virtualRows.map(virtualRow => {
const row = rows[virtualRow.index];
return (
<React.Fragment key={row.id}>
<div
className="table-row"
style={{
'&:hover div': {
background: `${token.colorFillAlter} !important`,
},
}}
>
{row.getVisibleCells().map((cell, index) => (
<div
key={cell.id}
className={`${cell.column.getIsPinned() === 'left' ? 'sticky left-0 z-10' : ''}`}
style={{
width: cell.column.getSize(),
position: index < 2 ? 'sticky' : 'relative',
left: 'auto',
background: token.colorBgContainer,
color: token.colorText,
height: '42px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
borderRight: `1px solid ${token.colorBorderSecondary}`,
padding: '8px 0px 8px 8px',
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
))}
</div>
{expandedRows[row.id] &&
row.original.sub_tasks?.map(subTask => (
<div
key={subTask.task_key}
className="table-row"
style={{
'&:hover div': {
background: `${token.colorFillAlter} !important`,
},
}}
>
{columns.map((col, index) => (
<div
key={`${subTask.task_key}-${col.id}`}
style={{
width: col.getSize(),
position: index < 2 ? 'sticky' : 'relative',
left: index < 2 ? `${index * col.getSize()}px` : 'auto',
background: token.colorBgContainer,
color: token.colorText,
height: '42px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
borderRight: `1px solid ${token.colorBorderSecondary}`,
paddingLeft: index === 3 ? '32px' : '8px',
paddingRight: '8px',
}}
>
{flexRender(col.cell, {
getValue: () => subTask[col.id as keyof typeof subTask] ?? null,
row: { original: subTask } as Row<IProjectTask>,
column: col as Column<IProjectTask>,
table,
})}
</div>
))}
</div>
))}
</React.Fragment>
);
})}
{paddingBottom > 0 && <div style={{ height: `${paddingBottom}px` }} />}
{rows.map(row => (
<React.Fragment key={row.id}>
<div
className="table-row"
style={{
'&:hover div': {
background: `${token.colorFillAlter} !important`,
},
}}
>
{row.getVisibleCells().map((cell, index) => (
<div
key={cell.id}
className={`${cell.column.getIsPinned() === 'left' ? 'sticky left-0 z-10' : ''}`}
style={{
width: cell.column.getSize(),
position: index < 2 ? 'sticky' : 'relative',
left: 'auto',
background: token.colorBgContainer,
color: token.colorText,
height: '42px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
borderRight: `1px solid ${token.colorBorderSecondary}`,
padding: '8px 0px 8px 8px',
}}
>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</div>
))}
</div>
{expandedRows[row.id] &&
row.original.sub_tasks?.map(subTask => (
<div
key={subTask.task_key}
className="table-row"
style={{
'&:hover div': {
background: `${token.colorFillAlter} !important`,
},
}}
>
{columns.map((col, index) => (
<div
key={`${subTask.task_key}-${col.id}`}
style={{
width: col.getSize(),
position: index < 2 ? 'sticky' : 'relative',
left: index < 2 ? `${index * col.getSize()}px` : 'auto',
background: token.colorBgContainer,
color: token.colorText,
height: '42px',
borderBottom: `1px solid ${token.colorBorderSecondary}`,
borderRight: `1px solid ${token.colorBorderSecondary}`,
paddingLeft: index === 3 ? '32px' : '8px',
paddingRight: '8px',
}}
>
{flexRender(col.cell, {
getValue: () => subTask[col.id as keyof typeof subTask] ?? null,
row: { original: subTask } as Row<IProjectTask>,
column: col as Column<IProjectTask>,
table,
})}
</div>
))}
</div>
))}
</React.Fragment>
))}
</div>
</div>
</div>

View File

@@ -131,7 +131,7 @@ const TaskListInstantTaskInput = ({
return (
<div
className={`border-t border-b-[1px] border-r-[1px]`}
className={`border-t border-b border-r`}
style={{ borderColor: token.colorBorderSecondary }}
>
{isEdit ? (

View File

@@ -149,10 +149,10 @@ const TaskListTable = ({
const customBorderColor = themeMode === 'dark' && ' border-[#303030]';
const customHeaderColumnStyles = (key: string) =>
`border px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
`border px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-linear-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
const customBodyColumnStyles = (key: string) =>
`border px-2 ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-h-[40px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#141414] border-[#303030]' : 'bg-white'}`;
`border px-2 ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-h-[40px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-linear-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#141414] border-[#303030]' : 'bg-white'}`;
// function to render the column content based on column key
const renderColumnContent = (

View File

@@ -4,12 +4,12 @@ import { TaskType } from '@/types/task.types';
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
import { colors } from '@/styles/colors';
import './task-list-table-wrapper.css';
import TaskListTable from '../task-list-table-old/task-list-table-old';
import TaskListTable from '../table-v2';
import { MenuProps } from 'antd/lib';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import TaskListCustom from '../task-list-custom';
import { columnList as defaultColumnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList';
type TaskListTableWrapperProps = {
taskList: ITaskListGroup;
@@ -37,6 +37,22 @@ const TaskListTableWrapper = ({
// localization
const { t } = useTranslation('task-list-table');
// Get column visibility from Redux
const columnVisibilityList = useAppSelector(
state => state.projectViewTaskListColumnsReducer.columnList
);
// Filter visible columns and format them for table-v2
const visibleColumns = defaultColumnList
.filter(column => {
const visibilityConfig = columnVisibilityList.find(col => col.key === column.key);
return visibilityConfig?.isVisible ?? false;
})
.map(column => ({
key: column.key,
width: column.width,
}));
// function to handle toggle expand
const handlToggleExpand = () => {
setIsExpanded(!isExpanded);
@@ -98,6 +114,14 @@ const TaskListTableWrapper = ({
},
];
const handleTaskSelect = (taskId: string) => {
console.log('Task selected:', taskId);
};
const handleTaskExpand = (taskId: string) => {
console.log('Task expanded:', taskId);
};
return (
<ConfigProvider
wave={{ disabled: true }}
@@ -161,7 +185,7 @@ const TaskListTableWrapper = ({
</Flex>
<Collapse
collapsible="header"
className="border-l-[4px]"
className="border-l-4"
bordered={false}
ghost={true}
expandIcon={() => null}
@@ -172,11 +196,13 @@ const TaskListTableWrapper = ({
key: groupId || '1',
className: `custom-collapse-content-box relative after:content after:absolute after:h-full after:w-1 ${color} after:z-10 after:top-0 after:left-0`,
children: (
<TaskListCustom
<TaskListTable
key={groupId}
groupId={groupId}
tasks={taskList.tasks}
color={color || ''}
taskListGroup={taskList}
tableId={groupId || ''}
visibleColumns={visibleColumns}
onTaskSelect={handleTaskSelect}
onTaskExpand={handleTaskExpand}
/>
),
},

View File

@@ -6,9 +6,7 @@ import { ITaskListConfigV2, ITaskListGroup } from '@/types/tasks/taskList.types'
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { fetchTaskGroups } from '@/features/tasks/taskSlice';
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import { columnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList';
import StatusGroupTables from '../taskList/statusTables/StatusGroupTables';
import TaskListTableWrapper from './task-list-table-wrapper/task-list-table-wrapper';
const TaskList = () => {
const dispatch = useAppDispatch();
@@ -31,6 +29,7 @@ const TaskList = () => {
const onTaskExpand = (taskId: string) => {
console.log('taskId:', taskId);
};
useEffect(() => {
if (projectId) {
const config: ITaskListConfigV2 = {
@@ -54,9 +53,15 @@ const TaskList = () => {
<Flex vertical gap={16}>
<TaskListFilters position="list" />
<Skeleton active loading={loadingGroups}>
{/* {taskGroups.map((group: ITaskListGroup) => (
))} */}
{taskGroups.map((group: ITaskListGroup) => (
<TaskListTableWrapper
key={group.id}
taskList={group}
groupId={group.id}
name={group.name}
color={group.color_code}
/>
))}
</Skeleton>
</Flex>
);

View File

@@ -1,54 +1,46 @@
import { useEffect } from 'react';
import { Flex } from 'antd';
import TaskListFilters from './taskListFilters/TaskListFilters';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import { fetchTaskGroups } from '@/features/tasks/tasks.slice';
import { ITaskListConfigV2 } from '@/types/tasks/taskList.types';
import TanStackTable from '../task-list/task-list-custom';
import TaskListCustom from '../task-list/task-list-custom';
import TaskListTableWrapper from '../task-list/task-list-table-wrapper/task-list-table-wrapper';
import { fetchTasksV3 } from '@/features/task-management/task-management.slice';
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
import TaskListBoard from '@/components/task-management/task-list-board';
const ProjectViewTaskList = () => {
// sample data from task reducer
const dispatch = useAppDispatch();
const { taskGroups, loadingGroups } = useAppSelector(state => state.taskReducer);
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
const projectId = useAppSelector(state => state.projectReducer.projectId);
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
useEffect(() => {
if (projectId) {
const config: ITaskListConfigV2 = {
id: projectId,
field: 'id',
order: 'desc',
search: '',
statuses: '',
members: '',
projects: '',
isSubtasksInclude: true,
};
dispatch(fetchTaskGroups(config));
// Use the optimized V3 API for faster loading
dispatch(fetchTasksV3(projectId));
}
if (!statusCategories.length) {
dispatch(fetchStatusesCategories());
}
}, [dispatch, projectId]);
// Cleanup effect - reset values when component is destroyed
useEffect(() => {
return () => {
// Clear any selected tasks when component unmounts
dispatch(deselectAll());
};
}, [dispatch]);
if (!projectId) {
return (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
<div>No project selected</div>
</Flex>
);
}
return (
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
<TaskListFilters position="list" />
{taskGroups.map(group => (
<TaskListTableWrapper
key={group.id}
taskList={group}
name={group.name || ''}
color={group.color_code || ''}
groupId={group.id || ''}
/>
))}
<TaskListBoard projectId={projectId} className="task-list-board" />
</Flex>
);
};

View File

@@ -47,10 +47,10 @@ const StatusGroupTables = ({ group }: { group: ITaskListGroup }) => {
{/* bulk action container ==> used tailwind to recreate the animation */}
{createPortal(
<div
className={`absolute bottom-0 left-1/2 z-20 -translate-x-1/2 ${selectedTaskIdsList.length > 0 ? 'overflow-visible' : 'h-[1px] overflow-hidden'}`}
className={`absolute bottom-0 left-1/2 z-20 -translate-x-1/2 ${selectedTaskIdsList.length > 0 ? 'overflow-visible' : 'h-px overflow-hidden'}`}
>
<div
className={`${selectedTaskIdsList.length > 0 ? 'bottom-4' : 'bottom-0'} absolute left-1/2 z-[999] -translate-x-1/2 transition-all duration-300`}
className={`${selectedTaskIdsList.length > 0 ? 'bottom-4' : 'bottom-0'} absolute left-1/2 z-999 -translate-x-1/2 transition-all duration-300`}
>
<BulkTasksActionContainer
selectedTaskIds={selectedTaskIdsList}

View File

@@ -28,10 +28,7 @@ const MembersFilterDropdown = () => {
const { t } = useTranslation('task-list-filters');
const membersList = [
...members,
useAppSelector(state => state.memberReducer.owner),
];
const membersList = [...members, useAppSelector(state => state.memberReducer.owner)];
const themeMode = useAppSelector(state => state.themeReducer.mode);

View File

@@ -24,7 +24,7 @@ const ShowFieldsFilterDropdown = () => {
key: col.key,
columnHeader: col.custom_column_obj.columnHeader,
isCustomColumn: col.custom_column,
}))
})),
];
const columnsVisibility = useAppSelector(
@@ -77,7 +77,7 @@ const ShowFieldsFilterDropdown = () => {
}
/>
{col.custom_column
? col.columnHeader
? col.columnHeader
: t(col.key === 'phases' ? 'phasesText' : `${col.columnHeader}Text`)}
</Space>
</List.Item>

View File

@@ -50,7 +50,9 @@ const TaskListTable = ({
const selectedProject = useSelectedProject();
// get columns list details
const columnsVisibility = useAppSelector( state => state.projectViewTaskListColumnsReducer.columnList );
const columnsVisibility = useAppSelector(
state => state.projectViewTaskListColumnsReducer.columnList
);
const visibleColumns = columnList.filter(
column => columnsVisibility[column.key as keyof typeof columnsVisibility]
);
@@ -133,10 +135,10 @@ const TaskListTable = ({
const customBorderColor = themeMode === 'dark' && ' border-[#303030]';
const customHeaderColumnStyles = (key: string) =>
`border px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
`border px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-linear-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
const customBodyColumnStyles = (key: string) =>
`border px-2 ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-h-[40px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#141414] border-[#303030]' : 'bg-white'}`;
`border px-2 ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-h-[40px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-linear-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#141414] border-[#303030]' : 'bg-white'}`;
// function to render the column content based on column key
const renderColumnContent = (

View File

@@ -194,7 +194,7 @@ const TaskListTableWrapper = ({
</Flex>
<Collapse
collapsible="header"
className="border-l-[4px]"
className="border-l-4"
bordered={false}
ghost={true}
expandIcon={() => null}

View File

@@ -22,6 +22,11 @@ export const columnList: CustomTableColumnsType[] = [
columnHeader: 'progress',
width: 60,
},
{
key: 'status',
columnHeader: 'status',
width: 120,
},
{
key: 'members',
columnHeader: 'members',
@@ -37,11 +42,6 @@ export const columnList: CustomTableColumnsType[] = [
columnHeader: phaseHeader,
width: 150,
},
{
key: 'status',
columnHeader: 'status',
width: 120,
},
{
key: 'priority',
columnHeader: 'priority',

View File

@@ -39,7 +39,7 @@ const TaskCell = ({
return (
<button
onClick={() => toggleTaskExpansion(taskId)}
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
className="hover flex h-4 w-4 items-center justify-center rounded-sm text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
>
{expandedTasks.includes(taskId) ? <DownOutlined /> : <RightOutlined />}
</button>
@@ -51,7 +51,7 @@ const TaskCell = ({
return !isSubTask ? (
<button
onClick={() => toggleTaskExpansion(taskId)}
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
className="hover flex h-4 w-4 items-center justify-center rounded-sm text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
>
{expandedTasks.includes(taskId) ? <DownOutlined /> : <RightOutlined />}
</button>

View File

@@ -1,5 +1,18 @@
import { Button, ConfigProvider, Flex, Form, Mentions, Skeleton, Space, Tooltip, Typography } from 'antd';
import { useEffect, useState, useCallback } from 'react';
import {
Button,
ConfigProvider,
Flex,
Form,
Mentions,
Skeleton,
Space,
Tooltip,
Typography,
Dropdown,
Menu,
Popconfirm,
} from 'antd';
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import DOMPurify from 'dompurify';
import { useParams } from 'react-router-dom';
@@ -10,7 +23,6 @@ import {
IMentionMemberSelectOption,
IMentionMemberViewModel,
} from '@/types/project/projectComments.types';
import { projectsApiService } from '@/api/projects/projects.api.service';
import { projectCommentsApiService } from '@/api/projects/comments/project-comments.api.service';
import { IProjectUpdateCommentViewModel } from '@/types/project/project.types';
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
@@ -21,6 +33,16 @@ import { DeleteOutlined } from '@ant-design/icons';
const MAX_COMMENT_LENGTH = 2000;
// Compile RegExp once for linkify
const urlRegex = /((https?:\/\/|www\.)[\w\-._~:/?#[\]@!$&'()*+,;=%]+)/gi;
function linkify(text: string): string {
return text.replace(urlRegex, url => {
const href = url.startsWith('http') ? url : `https://${url}`;
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${url}</a>`;
});
}
const ProjectViewUpdates = () => {
const { projectId } = useParams();
const [characterLength, setCharacterLength] = useState<number>(0);
@@ -42,7 +64,14 @@ const ProjectViewUpdates = () => {
if (!projectId) return;
try {
setIsLoading(true);
const res = await projectCommentsApiService.getMentionMembers(projectId, 1, 15, null, null, null);
const res = await projectCommentsApiService.getMentionMembers(
projectId,
1,
15,
null,
null,
null
);
if (res.done) {
setMembers(res.body as IMentionMemberViewModel[]);
}
@@ -68,12 +97,12 @@ const ProjectViewUpdates = () => {
}
}, [projectId]);
const handleAddComment = async () => {
const handleAddComment = useCallback(async () => {
if (!projectId || characterLength === 0) return;
try {
setIsSubmitting(true);
if (!commentValue) {
console.error('Comment content is empty');
return;
@@ -83,12 +112,24 @@ const ProjectViewUpdates = () => {
project_id: projectId,
team_id: getUserSession()?.team_id,
content: commentValue.trim(),
mentions: selectedMembers
mentions: selectedMembers,
};
const res = await projectCommentsApiService.createProjectComment(body);
if (res.done) {
await getComments();
setComments(prev => [
...prev,
{
...(res.body as IProjectUpdateCommentViewModel),
created_by: getUserSession()?.name || '',
created_at: new Date().toISOString(),
content: commentValue.trim(),
mentions: (res.body as IProjectUpdateCommentViewModel).mentions ?? [
undefined,
undefined,
],
},
]);
handleCancel();
}
} catch (error) {
@@ -96,15 +137,13 @@ const ProjectViewUpdates = () => {
} finally {
setIsSubmitting(false);
setCommentValue('');
}
};
}, [projectId, characterLength, commentValue, selectedMembers, getComments]);
useEffect(() => {
void getMembers();
void getComments();
}, [getMembers, getComments,refreshTimestamp]);
}, [getMembers, getComments, refreshTimestamp]);
const handleCancel = useCallback(() => {
form.resetFields(['comment']);
@@ -113,14 +152,18 @@ const ProjectViewUpdates = () => {
setSelectedMembers([]);
}, [form]);
const mentionsOptions =
members?.map(member => ({
value: member.id,
label: member.name,
})) ?? [];
const mentionsOptions = useMemo(
() =>
members?.map(member => ({
value: member.id,
label: member.name,
})) ?? [],
[members]
);
const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => {
if (!member?.value || !member?.label) return;
setSelectedMembers(prev =>
prev.some(mention => mention.id === member.value)
? prev
@@ -131,13 +174,11 @@ const ProjectViewUpdates = () => {
const parts = prev.split('@');
const lastPart = parts[parts.length - 1];
const mentionText = member.label;
// Keep only the part before the @ and add the new mention
return prev.slice(0, prev.length - lastPart.length) + mentionText;
});
}, []);
const handleCommentChange = useCallback((value: string) => {
// Only update the value without trying to replace mentions
setCommentValue(value);
setCharacterLength(value.trim().length);
}, []);
@@ -157,56 +198,101 @@ const ProjectViewUpdates = () => {
[getComments]
);
// Memoize link click handler for comment links
const handleCommentLinkClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
if (target.tagName === 'A') {
e.preventDefault();
const href = (target as HTMLAnchorElement).getAttribute('href');
if (href) {
window.open(href, '_blank', 'noopener,noreferrer');
}
}
}, []);
const configProviderTheme = useMemo(
() => ({
components: {
Button: {
defaultColor: colors.lightGray,
defaultHoverColor: colors.darkGray,
},
},
}),
[]
);
// Context menu for each comment (memoized)
const getCommentMenu = useCallback(
(commentId: string) => (
<Menu>
<Menu.Item key="delete">
<Popconfirm
title="Are you sure you want to delete this comment?"
onConfirm={() => handleDeleteComment(commentId)}
okText="Yes"
cancelText="No"
>
Delete
</Popconfirm>
</Menu.Item>
</Menu>
),
[handleDeleteComment]
);
const renderComment = useCallback(
(comment: IProjectUpdateCommentViewModel) => {
const linkifiedContent = linkify(comment.content || '');
const sanitizedContent = DOMPurify.sanitize(linkifiedContent);
const timeDifference = calculateTimeDifference(comment.created_at || '');
const themeClass = theme === 'dark' ? 'dark' : 'light';
return (
<Dropdown
key={comment.id ?? ''}
overlay={getCommentMenu(comment.id ?? '')}
trigger={['contextMenu']}
>
<div>
<Flex gap={8}>
<CustomAvatar avatarName={comment.created_by || ''} />
<Flex vertical flex={1}>
<Space>
<Typography.Text strong style={{ fontSize: 13, color: colors.lightGray }}>
{comment.created_by || ''}
</Typography.Text>
<Tooltip title={comment.created_at}>
<Typography.Text style={{ fontSize: 13, color: colors.deepLightGray }}>
{timeDifference}
</Typography.Text>
</Tooltip>
</Space>
<Typography.Paragraph
style={{ margin: '8px 0' }}
ellipsis={{ rows: 3, expandable: true }}
>
<div
className={`mentions-${themeClass}`}
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
onClick={handleCommentLinkClick}
/>
</Typography.Paragraph>
</Flex>
</Flex>
</div>
</Dropdown>
);
},
[theme, configProviderTheme, handleDeleteComment, handleCommentLinkClick]
);
const commentsList = useMemo(() => comments.map(renderComment), [comments, renderComment]);
return (
<Flex gap={24} vertical>
<Flex vertical gap={16}>
{
isLoadingComments ? (
<Skeleton active />
):
comments.map(comment => (
<Flex key={comment.id} gap={8}>
<CustomAvatar avatarName={comment.created_by || ''} />
<Flex vertical flex={1}>
<Space>
<Typography.Text strong style={{ fontSize: 13, color: colors.lightGray }}>
{comment.created_by || ''}
</Typography.Text>
<Tooltip title={comment.created_at}>
<Typography.Text style={{ fontSize: 13, color: colors.deepLightGray }}>
{calculateTimeDifference(comment.created_at || '')}
</Typography.Text>
</Tooltip>
</Space>
<Typography.Paragraph
style={{ margin: '8px 0' }}
ellipsis={{ rows: 3, expandable: true }}
>
<div className={`mentions-${theme === 'dark' ? 'dark' : 'light'}`} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(comment.content || '') }} />
</Typography.Paragraph>
<ConfigProvider
wave={{ disabled: true }}
theme={{
components: {
Button: {
defaultColor: colors.lightGray,
defaultHoverColor: colors.darkGray,
},
},
}}
>
<Button
icon={<DeleteOutlined />}
shape="circle"
type="text"
size='small'
onClick={() => handleDeleteComment(comment.id)}
/>
</ConfigProvider>
</Flex>
</Flex>
))}
{isLoadingComments ? <Skeleton active /> : commentsList}
</Flex>
<Form onFinish={handleAddComment}>
@@ -218,11 +304,16 @@ const ProjectViewUpdates = () => {
options={mentionsOptions}
autoSize
maxLength={MAX_COMMENT_LENGTH}
onSelect={option => memberSelectHandler(option as IMentionMemberSelectOption)}
onSelect={(option, prefix) => memberSelectHandler(option as IMentionMemberSelectOption)}
onClick={() => setIsCommentBoxExpand(true)}
onChange={handleCommentChange}
prefix="@"
split=""
filterOption={(input, option) => {
if (!input) return true;
const optionLabel = (option as any)?.label || '';
return optionLabel.toLowerCase().includes(input.toLowerCase());
}}
style={{
minHeight: isCommentBoxExpand ? 180 : 60,
paddingBlockEnd: 24,

View File

@@ -17,30 +17,32 @@ import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service
const BoardCreateSectionCard = () => {
const { t } = useTranslation('kanban-board');
const themeMode = useAppSelector((state) => state.themeReducer.mode);
const { projectId } = useAppSelector((state) => state.projectReducer);
const groupBy = useAppSelector((state) => state.boardReducer.groupBy);
const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { projectId } = useAppSelector(state => state.projectReducer);
const groupBy = useAppSelector(state => state.boardReducer.groupBy);
const { statusCategories, status: existingStatuses } = useAppSelector(
state => state.taskStatusReducer
);
const dispatch = useAppDispatch();
const getUniqueSectionName = (baseName: string): string => {
// Check if the base name already exists
const existingNames = existingStatuses.map(status => status.name?.toLowerCase());
if (!existingNames.includes(baseName.toLowerCase())) {
return baseName;
}
// If the base name exists, add a number suffix
let counter = 1;
let newName = `${baseName.trim()} (${counter})`;
while (existingNames.includes(newName.toLowerCase())) {
counter++;
newName = `${baseName.trim()} (${counter})`;
}
return newName;
};
@@ -48,14 +50,14 @@ const BoardCreateSectionCard = () => {
const sectionId = nanoid();
const baseNameSection = 'Untitled section';
const sectionName = getUniqueSectionName(baseNameSection);
if (groupBy === IGroupBy.STATUS && projectId) {
// Find the "To do" category
const todoCategory = statusCategories.find(category =>
category.name?.toLowerCase() === 'to do' ||
category.name?.toLowerCase() === 'todo'
const todoCategory = statusCategories.find(
category =>
category.name?.toLowerCase() === 'to do' || category.name?.toLowerCase() === 'todo'
);
if (todoCategory && todoCategory.id) {
// Create a new status
const body = {
@@ -63,21 +65,25 @@ const BoardCreateSectionCard = () => {
project_id: projectId,
category_id: todoCategory.id,
};
try {
// Create the status
const response = await dispatch(createStatus({ body, currentProjectId: projectId })).unwrap();
const response = await dispatch(
createStatus({ body, currentProjectId: projectId })
).unwrap();
if (response.done && response.body) {
dispatch(
addBoardSectionCard({
id: response.body.id as string,
name: sectionName,
colorCode: (response.body.color_code || todoCategory.color_code || '#d8d7d8') + ALPHA_CHANNEL,
colorCode:
(response.body.color_code || todoCategory.color_code || '#d8d7d8') +
ALPHA_CHANNEL,
colorCodeDark: '#989898',
})
);
// Refresh the board to show the new section
dispatch(fetchBoardTaskGroups(projectId));
// Refresh statuses
@@ -97,16 +103,11 @@ const BoardCreateSectionCard = () => {
})
);
}
}
}
if (groupBy === IGroupBy.PHASE && projectId) {
const body = {
name: sectionName,
project_id: projectId,
};
try {
const response = await phasesApiService.addPhaseOption(projectId);
try {
const response = await phasesApiService.addPhaseOption(projectId, sectionName);
if (response.done && response.body) {
dispatch(fetchBoardTaskGroups(projectId));
}

View File

@@ -50,7 +50,10 @@ import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.t
import { update } from 'lodash';
import logger from '@/utils/errorLogger';
import { toggleDrawer } from '@/features/projects/status/StatusSlice';
import { deleteStatusToggleDrawer, seletedStatusCategory } from '@/features/projects/status/DeleteStatusSlice';
import {
deleteStatusToggleDrawer,
seletedStatusCategory,
} from '@/features/projects/status/DeleteStatusSlice';
interface BoardSectionCardHeaderProps {
groupId: string;
@@ -110,20 +113,20 @@ const BoardSectionCardHeader: React.FC<BoardSectionCardHeaderProps> = ({
const getUniqueSectionName = (baseName: string): string => {
// Check if the base name already exists
const existingNames = status.map(status => status.name?.toLowerCase());
if (!existingNames.includes(baseName.toLowerCase())) {
return baseName;
}
// If the base name exists, add a number suffix
let counter = 1;
let newName = `${baseName.trim()} (${counter})`;
while (existingNames.includes(newName.toLowerCase())) {
counter++;
newName = `${baseName.trim()} (${counter})`;
}
return newName;
};
@@ -198,11 +201,18 @@ const BoardSectionCardHeader: React.FC<BoardSectionCardHeaderProps> = ({
if (groupBy === IGroupBy.STATUS) {
const replacingStatusId = '';
const res = await statusApiService.deleteStatus(groupId, projectId, replacingStatusId);
if (res.message === 'At least one status should exists under each category.') return
if (res.message === 'At least one status should exists under each category.') return;
if (res.done) {
dispatch(deleteSection({ sectionId: groupId }));
} else {
dispatch(seletedStatusCategory({ id: groupId, name: name, category_id: categoryId ?? '', message: res.message ?? '' }));
dispatch(
seletedStatusCategory({
id: groupId,
name: name,
category_id: categoryId ?? '',
message: res.message ?? '',
})
);
dispatch(deleteStatusToggleDrawer());
}
} else if (groupBy === IGroupBy.PHASE) {
@@ -373,5 +383,3 @@ const BoardSectionCardHeader: React.FC<BoardSectionCardHeaderProps> = ({
};
export default BoardSectionCardHeader;

View File

@@ -3,7 +3,7 @@ import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortabl
import BoardSectionCard from './board-section-card/board-section-card';
import BoardCreateSectionCard from './board-section-card/board-create-section-card';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { useEffect } from 'react';
import React, { useEffect } from 'react';
import { setTaskAssignee, setTaskEndDate } from '@/features/task-drawer/task-drawer.slice';
import { fetchTaskAssignees } from '@/features/taskAttributes/taskMemberSlice';
import { SocketEvents } from '@/shared/socket-events';
@@ -99,7 +99,7 @@ const BoardSectionCardContainer = ({
<Flex
gap={16}
align="flex-start"
className="max-w-screen max-h-[620px] min-h-[620px] overflow-x-scroll p-[1px]"
className="max-w-screen max-h-[620px] min-h-[620px] overflow-x-scroll p-px"
>
<SortableContext
items={datasource?.map((section: any) => section.id)}
@@ -108,9 +108,9 @@ const BoardSectionCardContainer = ({
{datasource?.map((data: any) => <BoardSectionCard key={data.id} taskGroup={data} />)}
</SortableContext>
{(group !== 'priority' && (isOwnerorAdmin || isProjectManager)) && <BoardCreateSectionCard />}
{group !== 'priority' && (isOwnerorAdmin || isProjectManager) && <BoardCreateSectionCard />}
</Flex>
);
};
export default BoardSectionCardContainer;
export default React.memo(BoardSectionCardContainer);

View File

@@ -85,7 +85,7 @@ const BoardCreateSubtaskCard = ({
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => {
if (!task) return;
dispatch(updateSubtask({ sectionId, subtask: task, mode: 'add' }));
setCreatingTask(false);
// Clear the input field after successful task creation
@@ -96,16 +96,19 @@ const BoardCreateSubtaskCard = ({
}, 0);
if (task.parent_task_id) {
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
socket?.once(SocketEvents.GET_TASK_PROGRESS.toString(), (data: {
id: string;
complete_ratio: number;
completed_count: number;
total_tasks_count: number;
parent_task: string;
}) => {
if (!data.parent_task) data.parent_task = task.parent_task_id || '';
dispatch(updateTaskProgress(data));
});
socket?.once(
SocketEvents.GET_TASK_PROGRESS.toString(),
(data: {
id: string;
complete_ratio: number;
completed_count: number;
total_tasks_count: number;
parent_task: string;
}) => {
if (!data.parent_task) data.parent_task = task.parent_task_id || '';
dispatch(updateTaskProgress(data));
}
);
}
});
} catch (error) {
@@ -148,7 +151,7 @@ const BoardCreateSubtaskCard = ({
cursor: 'pointer',
overflow: 'hidden',
}}
className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline`}
className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline-solid`}
onBlur={handleCancelNewCard}
>
<Input

View File

@@ -1,11 +1,30 @@
import { useState } from 'react';
import { useCallback, useState } from 'react';
import dayjs, { Dayjs } from 'dayjs';
import { Col, Flex, Typography, List } from 'antd';
import { Col, Flex, Typography, List, Dropdown, MenuProps, Popconfirm } from 'antd';
import {
UserAddOutlined,
DeleteOutlined,
ExclamationCircleFilled,
InboxOutlined,
} from '@ant-design/icons';
import CustomAvatarGroup from '@/components/board/custom-avatar-group';
import CustomDueDatePicker from '@/components/board/custom-due-date-picker';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import { useTranslation } from 'react-i18next';
import { colors } from '@/styles/colors';
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import {
evt_project_task_list_context_menu_assign_me,
evt_project_task_list_context_menu_delete,
evt_project_task_list_context_menu_archive,
} from '@/shared/worklenz-analytics-events';
import logger from '@/utils/errorLogger';
import { useAppSelector } from '@/hooks/useAppSelector';
import { deleteBoardTask, updateBoardTaskAssignee } from '@features/board/board-slice';
import { IBulkAssignRequest } from '@/types/tasks/bulk-action-bar.types';
interface IBoardSubTaskCardProps {
subtask: IProjectTask;
@@ -14,48 +33,156 @@ interface IBoardSubTaskCardProps {
const BoardSubTaskCard = ({ subtask, sectionId }: IBoardSubTaskCardProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation('kanban-board');
const { trackMixpanelEvent } = useMixpanelTracking();
const projectId = useAppSelector(state => state.projectReducer.projectId);
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
const [subtaskDueDate, setSubtaskDueDate] = useState<Dayjs | null>(
subtask?.end_date ? dayjs(subtask?.end_date) : null
);
const handleCardClick = (e: React.MouseEvent, id: string) => {
// Prevent the event from propagating to parent elements
e.stopPropagation();
// Add a small delay to ensure it's a click and not the start of a drag
const clickTimeout = setTimeout(() => {
dispatch(setSelectedTaskId(id));
dispatch(setShowTaskDrawer(true));
}, 50);
return () => clearTimeout(clickTimeout);
};
return (
<List.Item
key={subtask.id}
className="group"
style={{
width: '100%',
}}
onClick={e => handleCardClick(e, subtask.id || '')}
>
<Col span={10}>
<Typography.Text
style={{ fontWeight: 500, fontSize: 14 }}
delete={subtask.status === 'done'}
ellipsis={{ expanded: false }}
const handleAssignToMe = useCallback(async () => {
if (!projectId || !subtask.id || updatingAssignToMe) return;
try {
setUpdatingAssignToMe(true);
const body: IBulkAssignRequest = {
tasks: [subtask.id],
project_id: projectId,
};
const res = await taskListBulkActionsApiService.assignToMe(body);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_context_menu_assign_me);
dispatch(
updateBoardTaskAssignee({
body: res.body,
sectionId,
taskId: subtask.id,
})
);
}
} catch (error) {
logger.error('Error assigning task to me:', error);
} finally {
setUpdatingAssignToMe(false);
}
}, [projectId, subtask.id, updatingAssignToMe, dispatch, trackMixpanelEvent, sectionId]);
// const handleArchive = async () => {
// if (!projectId || !subtask.id) return;
// try {
// const res = await taskListBulkActionsApiService.archiveTasks(
// {
// tasks: [subtask.id],
// project_id: projectId,
// },
// false
// );
// if (res.done) {
// trackMixpanelEvent(evt_project_task_list_context_menu_archive);
// dispatch(deleteBoardTask({ sectionId, taskId: subtask.id }));
// }
// } catch (error) {
// logger.error('Error archiving subtask:', error);
// }
// };
const handleDelete = async () => {
if (!projectId || !subtask.id) return;
try {
const res = await taskListBulkActionsApiService.deleteTasks(
{ tasks: [subtask.id] },
projectId
);
if (res.done) {
trackMixpanelEvent(evt_project_task_list_context_menu_delete);
dispatch(deleteBoardTask({ sectionId, taskId: subtask.id }));
}
} catch (error) {
logger.error('Error deleting subtask:', error);
}
};
const items: MenuProps['items'] = [
{
label: (
<span>
<UserAddOutlined />
&nbsp;
<Typography.Text>{t('assignToMe')}</Typography.Text>
</span>
),
key: '1',
onClick: () => handleAssignToMe(),
disabled: updatingAssignToMe,
},
// {
// label: (
// <span>
// <InboxOutlined />
// &nbsp;
// <Typography.Text>{t('archive')}</Typography.Text>
// </span>
// ),
// key: '2',
// onClick: () => handleArchive(),
// },
{
label: (
<Popconfirm
title={t('deleteConfirmationTitle')}
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText={t('deleteConfirmationOk')}
cancelText={t('deleteConfirmationCancel')}
onConfirm={() => handleDelete()}
>
{subtask.name}
</Typography.Text>
</Col>
<DeleteOutlined />
&nbsp;
{t('delete')}
</Popconfirm>
),
key: '3',
},
];
<Flex gap={8} justify="end" style={{ width: '100%' }}>
<CustomAvatarGroup task={subtask} sectionId={sectionId} />
return (
<Dropdown menu={{ items }} trigger={['contextMenu']}>
<List.Item
key={subtask.id}
className="group"
style={{
width: '100%',
}}
onClick={e => handleCardClick(e, subtask.id || '')}
>
<Col span={10}>
<Typography.Text
style={{ fontWeight: 500, fontSize: 14 }}
delete={subtask.status === 'done'}
ellipsis={{ expanded: false }}
>
{subtask.name}
</Typography.Text>
</Col>
<CustomDueDatePicker task={subtask} onDateChange={setSubtaskDueDate} />
</Flex>
</List.Item>
<Flex gap={8} justify="end" style={{ width: '100%' }}>
<CustomAvatarGroup task={subtask} sectionId={sectionId} />
<CustomDueDatePicker task={subtask} onDateChange={setSubtaskDueDate} />
</Flex>
</List.Item>
</Dropdown>
);
};

View File

@@ -62,7 +62,7 @@ const BoardViewCreateTaskCard = ({
const createRequestBody = (): ITaskCreateRequest | null => {
if (!projectId || !currentSession) return null;
const body: ITaskCreateRequest = {
project_id: projectId,
name: newTaskName.trim(),
@@ -108,7 +108,7 @@ const BoardViewCreateTaskCard = ({
const eventHandler = (task: IProjectTask) => {
// Set creating task to false
setCreatingTask(false);
// Add the task to the state at the top of the section
dispatch(
addTaskCardToTheTop({
@@ -121,17 +121,17 @@ const BoardViewCreateTaskCard = ({
},
})
);
// Remove the event listener to prevent memory leaks
socket?.off(SocketEvents.QUICK_TASK.toString(), eventHandler);
// Reset the form
resetForm();
};
// Register the event handler before emitting
socket?.once(SocketEvents.QUICK_TASK.toString(), eventHandler);
// Emit the event
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
} catch (error) {
@@ -152,7 +152,7 @@ const BoardViewCreateTaskCard = ({
const eventHandler = (task: IProjectTask) => {
// Set creating task to false
setCreatingTask(false);
// Add the task to the state at the bottom of the section
dispatch(
addTaskCardToTheBottom({
@@ -165,17 +165,17 @@ const BoardViewCreateTaskCard = ({
},
})
);
// Remove the event listener to prevent memory leaks
socket?.off(SocketEvents.QUICK_TASK.toString(), eventHandler);
// Reset the form
resetForm();
};
// Register the event handler before emitting
socket?.once(SocketEvents.QUICK_TASK.toString(), eventHandler);
// Emit the event
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
} catch (error) {
@@ -207,7 +207,7 @@ const BoardViewCreateTaskCard = ({
cursor: 'pointer',
overflow: 'hidden',
}}
className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline`}
className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline-solid`}
onBlur={handleCancelNewCard}
>
<Input
@@ -225,15 +225,12 @@ const BoardViewCreateTaskCard = ({
/>
{newTaskName.trim() && (
<Flex gap={8} justify="flex-end">
<Button
size="small"
onClick={() => setShowNewCard(false)}
>
<Button size="small" onClick={() => setShowNewCard(false)}>
{t('cancel')}
</Button>
<Button
type="primary"
size="small"
<Button
type="primary"
size="small"
onClick={position === 'bottom' ? handleAddTaskToTheBottom : handleAddTaskToTheTop}
loading={creatingTask}
>

View File

@@ -55,6 +55,8 @@ import {
evt_project_task_list_context_menu_delete,
} from '@/shared/worklenz-analytics-events';
import logger from '@/utils/errorLogger';
import { useAuthService } from '@/hooks/useAuth';
import PrioritySection from '@/components/board/taskCard/priority-section/priority-section';
interface IBoardViewTaskCardProps {
task: IProjectTask;
@@ -65,7 +67,7 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation('kanban-board');
const { trackMixpanelEvent } = useMixpanelTracking();
const currentSession = useAuthService().getCurrentSession();
const themeMode = useAppSelector(state => state.themeReducer.mode);
const projectId = useAppSelector(state => state.projectReducer.projectId);
const [isSubTaskShow, setIsSubTaskShow] = useState(false);
@@ -84,27 +86,33 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
},
});
const style = useMemo(() => ({
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}), [transform, transition, isDragging]);
const style = useMemo(
() => ({
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
}),
[transform, transition, isDragging]
);
const handleCardClick = useCallback((e: React.MouseEvent, id: string) => {
// Prevent the event from propagating to parent elements
e.stopPropagation();
const handleCardClick = useCallback(
(e: React.MouseEvent, id: string) => {
// Prevent the event from propagating to parent elements
e.stopPropagation();
// Don't handle click if we're dragging
if (isDragging) return;
// Don't handle click if we're dragging
if (isDragging) return;
// Add a small delay to ensure it's a click and not the start of a drag
const clickTimeout = setTimeout(() => {
dispatch(setSelectedTaskId(id));
dispatch(setShowTaskDrawer(true));
}, 50);
// Add a small delay to ensure it's a click and not the start of a drag
const clickTimeout = setTimeout(() => {
dispatch(setSelectedTaskId(id));
dispatch(setShowTaskDrawer(true));
}, 50);
return () => clearTimeout(clickTimeout);
}, [dispatch, isDragging]);
return () => clearTimeout(clickTimeout);
},
[dispatch, isDragging]
);
const handleAssignToMe = useCallback(async () => {
if (!projectId || !task.id || updatingAssignToMe) return;
@@ -187,96 +195,67 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
setShowNewSubtaskCard(true);
}, []);
const handleSubtaskButtonClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
handleSubTaskExpand();
}, [handleSubTaskExpand]);
const handleSubtaskButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
handleSubTaskExpand();
},
[handleSubTaskExpand]
);
const items: MenuProps['items'] = useMemo(() => [
{
label: (
<span>
<UserAddOutlined />
&nbsp;
<Typography.Text>{t('assignToMe')}</Typography.Text>
</span>
),
key: '1',
onClick: handleAssignToMe,
disabled: updatingAssignToMe,
},
{
label: (
<span>
<InboxOutlined />
&nbsp;
<Typography.Text>{t('archive')}</Typography.Text>
</span>
),
key: '2',
onClick: handleArchive,
},
{
label: (
<Popconfirm
title={t('deleteConfirmationTitle')}
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText={t('deleteConfirmationOk')}
cancelText={t('deleteConfirmationCancel')}
onConfirm={handleDelete}
>
<DeleteOutlined />
&nbsp;
{t('delete')}
</Popconfirm>
),
key: '3',
},
], [t, handleAssignToMe, handleArchive, handleDelete, updatingAssignToMe]);
const priorityIcon = useMemo(() => {
if (task.priority_value === 0) {
return (
<MinusOutlined
style={{
color: '#52c41a',
marginRight: '0.25rem',
}}
/>
);
} else if (task.priority_value === 1) {
return (
<PauseOutlined
style={{
color: '#faad14',
transform: 'rotate(90deg)',
marginRight: '0.25rem',
}}
/>
);
} else {
return (
<DoubleRightOutlined
style={{
color: '#f5222d',
transform: 'rotate(-90deg)',
marginRight: '0.25rem',
}}
/>
);
}
}, [task.priority_value]);
const items: MenuProps['items'] = useMemo(
() => [
{
label: (
<span>
<UserAddOutlined />
&nbsp;
<Typography.Text>{t('assignToMe')}</Typography.Text>
</span>
),
key: '1',
onClick: handleAssignToMe,
disabled: updatingAssignToMe,
},
{
label: (
<span>
<InboxOutlined />
&nbsp;
<Typography.Text>{t('archive')}</Typography.Text>
</span>
),
key: '2',
onClick: handleArchive,
},
{
label: (
<Popconfirm
title={t('deleteConfirmationTitle')}
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText={t('deleteConfirmationOk')}
cancelText={t('deleteConfirmationCancel')}
onConfirm={handleDelete}
>
<DeleteOutlined />
&nbsp;
{t('delete')}
</Popconfirm>
),
key: '3',
},
],
[t, handleAssignToMe, handleArchive, handleDelete, updatingAssignToMe]
);
const renderLabels = useMemo(() => {
if (!task?.labels?.length) return null;
return (
<>
{task.labels.slice(0, 2).map((label: any) => (
<Tag key={label.id} style={{ marginRight: '4px' }} color={label?.color_code}>
<span style={{ color: themeMode === 'dark' ? '#383838' : '' }}>
{label.name}
</span>
<span style={{ color: themeMode === 'dark' ? '#383838' : '' }}>{label.name}</span>
</Tag>
))}
{task.labels.length > 2 && <Tag>+ {task.labels.length - 2}</Tag>}
@@ -285,50 +264,48 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
}, [task.labels, themeMode]);
return (
<Dropdown menu={{ items }} trigger={['contextMenu']}>
<Flex
ref={setNodeRef}
{...attributes}
{...listeners}
vertical
gap={12}
style={{
...style,
width: '100%',
padding: 12,
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
borderRadius: 6,
cursor: 'grab',
overflow: 'hidden',
}}
className={`group outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline board-task-card`}
onClick={e => handleCardClick(e, task.id || '')}
data-id={task.id}
data-dragging={isDragging ? "true" : "false"}
>
{/* Labels and Progress */}
<Flex align="center" justify="space-between">
<Flex>
{renderLabels}
<Flex
ref={setNodeRef}
{...attributes}
{...listeners}
vertical
gap={12}
style={{
...style,
width: '100%',
padding: 12,
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
borderRadius: 6,
cursor: 'grab',
overflow: 'hidden',
}}
className={`group outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline-solid board-task-card`}
data-id={task.id}
data-dragging={isDragging ? 'true' : 'false'}
>
<Dropdown menu={{ items }} trigger={['contextMenu']}>
{/* Task Card */}
<Flex vertical gap={8} onClick={e => handleCardClick(e, task.id || '')}>
{/* Labels and Progress */}
<Flex align="center" justify="space-between">
<Flex>{renderLabels}</Flex>
<Tooltip title={` ${task?.completed_count} / ${task?.total_tasks_count}`}>
<Progress
type="circle"
percent={task?.complete_ratio}
size={26}
strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7}
/>
</Tooltip>
</Flex>
<Flex gap={4} align="center">
{/* Action Icons */}
<PrioritySection task={task} />
<Typography.Text style={{ fontWeight: 500 }} ellipsis={{ tooltip: task.name }}>
{task.name}
</Typography.Text>
</Flex>
<Tooltip title={` ${task?.completed_count} / ${task?.total_tasks_count}`}>
<Progress type="circle" percent={task?.complete_ratio } size={26} strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7} />
</Tooltip>
</Flex>
{/* Action Icons */}
<Flex gap={4}>
{priorityIcon}
<Typography.Text
style={{ fontWeight: 500 }}
ellipsis={{ tooltip: task.name }}
>
{task.name}
</Typography.Text>
</Flex>
<Flex vertical gap={8}>
<Flex
align="center"
justify="space-between"
@@ -366,47 +343,50 @@ const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
</Button>
</Flex>
</Flex>
{isSubTaskShow && (
<Flex vertical>
<Divider style={{ marginBlock: 0 }} />
<List>
{task.sub_tasks_loading && (
<List.Item>
<Skeleton active paragraph={{ rows: 2 }} title={false} style={{ marginTop: 8 }} />
</List.Item>
)}
{!task.sub_tasks_loading && task?.sub_tasks &&
task?.sub_tasks.map((subtask: any) => (
<BoardSubTaskCard key={subtask.id} subtask={subtask} sectionId={sectionId} />
))}
{showNewSubtaskCard && (
<BoardCreateSubtaskCard
sectionId={sectionId}
parentTaskId={task.id || ''}
setShowNewSubtaskCard={setShowNewSubtaskCard}
/>
)}
</List>
<Button
type="text"
style={{
width: 'fit-content',
borderRadius: 6,
boxShadow: 'none',
}}
icon={<PlusOutlined />}
onClick={handleAddSubtaskClick}
>
{t('addSubtask', 'Add Subtask')}
</Button>
</Flex>
)}
</Flex>
</Dropdown>
{/* Subtask Section */}
<Flex vertical gap={8}>
{isSubTaskShow && (
<Flex vertical>
<Divider style={{ marginBlock: 0 }} />
<List>
{task.sub_tasks_loading && (
<List.Item>
<Skeleton active paragraph={{ rows: 2 }} title={false} style={{ marginTop: 8 }} />
</List.Item>
)}
{!task.sub_tasks_loading &&
task?.sub_tasks &&
task?.sub_tasks.map((subtask: any) => (
<BoardSubTaskCard key={subtask.id} subtask={subtask} sectionId={sectionId} />
))}
{showNewSubtaskCard && (
<BoardCreateSubtaskCard
sectionId={sectionId}
parentTaskId={task.id || ''}
setShowNewSubtaskCard={setShowNewSubtaskCard}
/>
)}
</List>
<Button
type="text"
style={{
width: 'fit-content',
borderRadius: 6,
boxShadow: 'none',
}}
icon={<PlusOutlined />}
onClick={handleAddSubtaskClick}
>
{t('addSubtask', 'Add Subtask')}
</Button>
</Flex>
)}
</Flex>
</Dropdown>
</Flex>
);
};

View File

@@ -1,431 +0,0 @@
import { useEffect, useState, useRef } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import TaskListFilters from '../taskList/task-list-filters/task-list-filters';
import { Flex, Skeleton } from 'antd';
import BoardSectionCardContainer from './board-section/board-section-container';
import {
fetchBoardTaskGroups,
reorderTaskGroups,
moveTaskBetweenGroups,
IGroupBy,
updateTaskProgress,
} from '@features/board/board-slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
DndContext,
DragEndEvent,
DragOverEvent,
DragStartEvent,
closestCorners,
DragOverlay,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import BoardViewTaskCard from './board-section/board-task-card/board-view-task-card';
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import useTabSearchParam from '@/hooks/useTabSearchParam';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
import { SocketEvents } from '@/shared/socket-events';
import alertService from '@/services/alerts/alertService';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { evt_project_board_visit, evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics-events';
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import logger from '@/utils/errorLogger';
import { tasksApiService } from '@/api/tasks/tasks.api.service';
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
const ProjectViewBoard = () => {
const dispatch = useAppDispatch();
const { projectView } = useTabSearchParam();
const { socket } = useSocket();
const authService = useAuthService();
const currentSession = authService.getCurrentSession();
const { trackMixpanelEvent } = useMixpanelTracking();
const [ currentTaskIndex, setCurrentTaskIndex] = useState(-1);
const { projectId } = useAppSelector(state => state.projectReducer);
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer);
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
state => state.taskStatusReducer
);
const [activeItem, setActiveItem] = useState<any>(null);
// Store the original source group ID when drag starts
const originalSourceGroupIdRef = useRef<string | null>(null);
useEffect(() => {
if (projectId && groupBy && projectView === 'kanban') {
if (!loadingGroups) {
dispatch(fetchBoardTaskGroups(projectId));
}
}
}, [dispatch, projectId, groupBy, projectView, search, archived]);
const sensors = useSensors(
useSensor(MouseSensor, {
// Require the mouse to move by 10 pixels before activating
activationConstraint: {
distance: 10,
},
}),
useSensor(TouchSensor, {
// Press delay of 250ms, with tolerance of 5px of movement
activationConstraint: {
delay: 250,
tolerance: 5,
},
})
);
const handleTaskProgress = (data: {
id: string;
status: string;
complete_ratio: number;
completed_count: number;
total_tasks_count: number;
parent_task: string;
}) => {
dispatch(updateTaskProgress(data));
};
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
setActiveItem(active.data.current);
setCurrentTaskIndex(active.data.current?.sortable.index);
// Store the original source group ID when drag starts
if (active.data.current?.type === 'task') {
originalSourceGroupIdRef.current = active.data.current.sectionId;
}
};
const handleDragOver = (event: DragOverEvent) => {
const { active, over } = event;
if (!over) return;
const activeId = active.id;
const overId = over.id;
if (activeId === overId) return;
const isActiveTask = active.data.current?.type === 'task';
const isOverTask = over.data.current?.type === 'task';
const isOverSection = over.data.current?.type === 'section';
// Handle task movement between sections
if (isActiveTask && (isOverTask || isOverSection)) {
// If we're over a task, we want to insert at that position
// If we're over a section, we want to append to the end
const activeTaskId = active.data.current?.task.id;
// Use the original source group ID from ref instead of the potentially modified one
const sourceGroupId = originalSourceGroupIdRef.current || active.data.current?.sectionId;
// Fix: Ensure we correctly identify the target group ID
let targetGroupId;
if (isOverTask) {
// If over a task, get its section ID
targetGroupId = over.data.current?.sectionId;
} else if (isOverSection) {
// If over a section directly
targetGroupId = over.id;
} else {
// Fallback
targetGroupId = over.id;
}
// Find the target index
let targetIndex = -1;
if (isOverTask) {
const overTaskId = over.data.current?.task.id;
const targetGroup = taskGroups.find(group => group.id === targetGroupId);
if (targetGroup) {
targetIndex = targetGroup.tasks.findIndex(task => task.id === overTaskId);
}
}
// Dispatch the action to move the task
dispatch(
moveTaskBetweenGroups({
taskId: activeTaskId,
sourceGroupId,
targetGroupId,
targetIndex,
})
);
}
};
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (!over || !projectId) {
setActiveItem(null);
originalSourceGroupIdRef.current = null; // Reset the ref
return;
}
const isActiveTask = active.data.current?.type === 'task';
const isActiveSection = active.data.current?.type === 'section';
// Handle task dragging between columns
if (isActiveTask) {
const task = active.data.current?.task;
// Use the original source group ID from ref instead of the potentially modified one
const sourceGroupId = originalSourceGroupIdRef.current || active.data.current?.sectionId;
// Fix: Ensure we correctly identify the target group ID
let targetGroupId;
if (over.data.current?.type === 'task') {
// If dropping on a task, get its section ID
targetGroupId = over.data.current?.sectionId;
} else if (over.data.current?.type === 'section') {
// If dropping directly on a section
targetGroupId = over.id;
} else {
// Fallback to the over ID if type is not specified
targetGroupId = over.id;
}
// Find source and target groups
const sourceGroup = taskGroups.find(group => group.id === sourceGroupId);
const targetGroup = taskGroups.find(group => group.id === targetGroupId);
if (!sourceGroup || !targetGroup || !task) {
logger.error('Could not find source or target group, or task is undefined');
setActiveItem(null);
originalSourceGroupIdRef.current = null; // Reset the ref
return;
}
if (targetGroupId !== sourceGroupId) {
const canContinue = await checkTaskDependencyStatus(task.id, targetGroupId);
if (!canContinue) {
alertService.error(
'Task is not completed',
'Please complete the task dependencies before proceeding'
);
dispatch(
moveTaskBetweenGroups({
taskId: task.id,
sourceGroupId: targetGroupId, // Current group (where it was moved optimistically)
targetGroupId: sourceGroupId, // Move it back to the original source group
targetIndex: currentTaskIndex !== -1 ? currentTaskIndex : 0, // Original position or append to end
})
);
setActiveItem(null);
originalSourceGroupIdRef.current = null;
return;
}
}
// Find indices
let fromIndex = sourceGroup.tasks.findIndex(t => t.id === task.id);
// Handle case where task is not found in source group (might have been moved already in UI)
if (fromIndex === -1) {
logger.info('Task not found in source group. Using task sort_order from task object.');
// Use the sort_order from the task object itself
const fromSortOrder = task.sort_order;
// Calculate target index and position
let toIndex = -1;
if (over.data.current?.type === 'task') {
const overTaskId = over.data.current?.task.id;
toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
} else {
// If dropping on a section, append to the end
toIndex = targetGroup.tasks.length;
}
// Calculate toPos similar to Angular implementation
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
-1;
// Prepare socket event payload
const body = {
project_id: projectId,
from_index: fromSortOrder,
to_index: toPos,
to_last_index: !toPos,
from_group: sourceGroupId,
to_group: targetGroupId,
group_by: groupBy || 'status',
task,
team_id: currentSession?.team_id
};
logger.error('Emitting socket event with payload (task not found in source):', body);
// Emit socket event
if (socket) {
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
// Set up listener for task progress update
socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => {
if (task.is_sub_task) {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
} else {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
}
});
}
// Track analytics event
trackMixpanelEvent(evt_project_task_list_drag_and_move);
setActiveItem(null);
originalSourceGroupIdRef.current = null; // Reset the ref
return;
}
// Calculate target index and position
let toIndex = -1;
if (over.data.current?.type === 'task') {
const overTaskId = over.data.current?.task.id;
toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
} else {
// If dropping on a section, append to the end
toIndex = targetGroup.tasks.length;
}
// Calculate toPos similar to Angular implementation
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
-1;
// Prepare socket event payload
const body = {
project_id: projectId,
from_index: sourceGroup.tasks[fromIndex].sort_order,
to_index: toPos,
to_last_index: !toPos,
from_group: sourceGroupId, // Use the direct IDs instead of group objects
to_group: targetGroupId, // Use the direct IDs instead of group objects
group_by: groupBy || 'status', // Use the current groupBy value
task,
team_id: currentSession?.team_id
};
// Emit socket event
if (socket) {
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
// Set up listener for task progress update
socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => {
if (task.is_sub_task) {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
} else {
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
}
});
}
// Track analytics event
trackMixpanelEvent(evt_project_task_list_drag_and_move);
}
// Handle column reordering
else if (isActiveSection) {
// Don't allow reordering if groupBy is phases
if (groupBy === IGroupBy.PHASE) {
setActiveItem(null);
originalSourceGroupIdRef.current = null;
return;
}
const sectionId = active.id;
const fromIndex = taskGroups.findIndex(group => group.id === sectionId);
const toIndex = taskGroups.findIndex(group => group.id === over.id);
if (fromIndex !== -1 && toIndex !== -1) {
// Create a new array with the reordered groups
const reorderedGroups = [...taskGroups];
const [movedGroup] = reorderedGroups.splice(fromIndex, 1);
reorderedGroups.splice(toIndex, 0, movedGroup);
// Dispatch action to reorder columns with the new array
dispatch(reorderTaskGroups(reorderedGroups));
// Prepare column order for API
const columnOrder = reorderedGroups.map(group => group.id);
// Call API to update status order
try {
// Use the correct API endpoint based on the Angular code
const requestBody: ITaskStatusCreateRequest = {
status_order: columnOrder
};
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
if (!response.done) {
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
revertedGroups.splice(fromIndex, 0, movedBackGroup);
dispatch(reorderTaskGroups(revertedGroups));
alertService.error('Failed to update column order', 'Please try again');
}
} catch (error) {
// Revert the change if API call fails
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
revertedGroups.splice(fromIndex, 0, movedBackGroup);
dispatch(reorderTaskGroups(revertedGroups));
alertService.error('Failed to update column order', 'Please try again');
}
}
}
setActiveItem(null);
originalSourceGroupIdRef.current = null; // Reset the ref
};
useEffect(() => {
if (socket) {
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
}
return () => {
socket?.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
};
}, [socket]);
useEffect(() => {
trackMixpanelEvent(evt_project_board_visit);
if (!statusCategories.length && projectId) {
dispatch(fetchStatusesCategories());
}
}, [dispatch, projectId]);
return (
<Flex vertical gap={16}>
<TaskListFilters position={'board'} />
<Skeleton active loading={loadingGroups} className='mt-4 p-4'>
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<BoardSectionCardContainer
datasource={taskGroups}
group={groupBy as 'status' | 'priority' | 'phases'}
/>
<DragOverlay>
{activeItem?.type === 'task' && (
<BoardViewTaskCard task={activeItem.task} sectionId={activeItem.sectionId} />
)}
</DragOverlay>
</DndContext>
</Skeleton>
</Flex>
);
};
export default ProjectViewBoard;

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import EnhancedKanbanBoardNativeDnD from '@/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD';
const ProjectViewEnhancedBoard: React.FC = () => {
const { project } = useAppSelector(state => state.projectReducer);
if (!project?.id) {
return <div className="p-4 text-center text-gray-500">Project not found</div>;
}
return (
<div className="project-view-enhanced-board">
<EnhancedKanbanBoardNativeDnD projectId={project.id} />
</div>
);
};
export default ProjectViewEnhancedBoard;

View File

@@ -47,7 +47,6 @@ const ProjectViewFiles = () => {
defaultPageSize: DEFAULT_PAGE_SIZE,
});
const fetchAttachments = async () => {
if (!projectId) return;
try {

View File

@@ -41,7 +41,7 @@ const TaskByMembersTable = () => {
useEffect(() => {
getProjectOverviewMembers();
}, [projectId,refreshTimestamp]);
}, [projectId, refreshTimestamp]);
// toggle members row expansions
const toggleRowExpansion = (memberId: string) => {
@@ -103,7 +103,7 @@ const TaskByMembersTable = () => {
return (
<div className="memberList-container min-h-0 max-w-full overflow-x-auto">
<table className="w-full min-w-max border-collapse rounded">
<table className="w-full min-w-max border-collapse rounded-sm">
<thead
style={{
height: 42,

View File

@@ -17,7 +17,6 @@ const PriorityOverview = () => {
const [loading, setLoading] = useState(false);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getTaskPriorityCounts = async () => {
if (!projectId) return;

View File

@@ -15,7 +15,6 @@ const StatusOverview = () => {
const [stats, setStats] = useState<ITaskStatusCounts[]>([]);
const [loading, setLoading] = useState(false);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getTaskStatusCounts = async () => {
if (!projectId) return;
@@ -38,7 +37,7 @@ const StatusOverview = () => {
useEffect(() => {
getTaskStatusCounts();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
}, [projectId, includeArchivedTasks, refreshTimestamp]);
const options: ChartOptions<'doughnut'> = {
responsive: true,

View File

@@ -18,7 +18,6 @@ const LastUpdatedTasks = () => {
const [loading, setLoading] = useState(false);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getLastUpdatedTasks = async () => {
if (!projectId) return;
setLoading(true);
@@ -39,7 +38,7 @@ const LastUpdatedTasks = () => {
useEffect(() => {
getLastUpdatedTasks();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
}, [projectId, includeArchivedTasks, refreshTimestamp]);
// table columns
const columns: TableProps['columns'] = [

View File

@@ -37,7 +37,7 @@ const ProjectDeadline = () => {
useEffect(() => {
getProjectDeadline();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
}, [projectId, includeArchivedTasks, refreshTimestamp]);
// table columns
const columns: TableProps['columns'] = [

View File

@@ -15,7 +15,6 @@ const OverLoggedTasksTable = () => {
const [loading, setLoading] = useState(true);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getOverLoggedTasks = async () => {
try {
setLoading(true);
@@ -35,7 +34,7 @@ const OverLoggedTasksTable = () => {
useEffect(() => {
getOverLoggedTasks();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
}, [projectId, includeArchivedTasks, refreshTimestamp]);
// table columns
const columns: TableProps['columns'] = [
@@ -105,8 +104,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

@@ -18,7 +18,6 @@ const OverdueTasksTable = ({
const [loading, setLoading] = useState(true);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getOverdueTasks = async () => {
setLoading(true);
try {
@@ -35,7 +34,7 @@ const OverdueTasksTable = ({
useEffect(() => {
getOverdueTasks();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
}, [projectId, includeArchivedTasks, refreshTimestamp]);
// table columns
const columns: TableProps['columns'] = [

View File

@@ -20,7 +20,6 @@ const TaskCompletedEarlyTable = ({
const [loading, setLoading] = useState(true);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getEarlyCompletedTasks = async () => {
try {
setLoading(true);

View File

@@ -18,7 +18,6 @@ const TaskCompletedLateTable = ({
const [lateCompletedTaskList, setLateCompletedTaskList] = useState<IInsightTasks[]>([]);
const [loading, setLoading] = useState(true);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getLateCompletedTasks = async () => {
try {
@@ -39,7 +38,7 @@ const TaskCompletedLateTable = ({
useEffect(() => {
getLateCompletedTasks();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
}, [projectId, includeArchivedTasks, refreshTimestamp]);
// table columns
const columns: TableProps['columns'] = [

View File

@@ -35,7 +35,7 @@ const MemberStats = () => {
useEffect(() => {
fetchMemberStats();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
}, [projectId, includeArchivedTasks, refreshTimestamp]);
return (
<Flex gap={24} className="grid sm:grid-cols-2 sm:grid-rows-2 lg:grid-cols-3 lg:grid-rows-1">

View File

@@ -18,7 +18,6 @@ const ProjectStats = ({ t }: { t: TFunction }) => {
const [loading, setLoading] = useState(false);
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
const getProjectStats = async () => {
if (!projectId) return;
@@ -40,7 +39,7 @@ const ProjectStats = ({ t }: { t: TFunction }) => {
useEffect(() => {
getProjectStats();
}, [projectId, includeArchivedTasks,refreshTimestamp]);
}, [projectId, includeArchivedTasks, refreshTimestamp]);
const tooltipTable = (
<table>

View File

@@ -17,8 +17,12 @@ import {
import { format } from 'date-fns';
import html2canvas from 'html2canvas';
import jsPDF from 'jspdf';
import logo from '@/assets/images/logo.png';
import { evt_project_insights_members_visit, evt_project_insights_overview_visit, evt_project_insights_tasks_visit } from '@/shared/worklenz-analytics-events';
import 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';
type SegmentType = 'Overview' | 'Members' | 'Tasks';
@@ -35,9 +39,7 @@ const ProjectViewInsights = () => {
const { activeSegment, includeArchivedTasks } = useAppSelector(
state => state.projectInsightsReducer
);
const {
project: selectedProject,
} = useAppSelector(state => state.projectReducer);
const { project: selectedProject } = useAppSelector(state => state.projectReducer);
const handleSegmentChange = (value: SegmentType) => {
dispatch(setActiveSegment(value));
@@ -113,7 +115,7 @@ const ProjectViewInsights = () => {
pdf.save(`${activeSegment} ${format(new Date(), 'yyyy-MM-dd')}.pdf`);
};
logoImg.onerror = (error) => {
logoImg.onerror = error => {
pdf.setFontSize(14);
pdf.setTextColor(0, 0, 0, 0.85);
pdf.text(
@@ -127,11 +129,11 @@ const ProjectViewInsights = () => {
};
};
useEffect(()=>{
if(projectId){
useEffect(() => {
if (projectId) {
dispatch(setActiveSegment('Overview'));
}
},[refreshTimestamp])
}, [refreshTimestamp]);
return (
<Flex vertical gap={24}>
@@ -169,9 +171,7 @@ const ProjectViewInsights = () => {
</Button>
</Flex>
</Flex>
<div ref={exportRef}>
{renderSegmentContent()}
</div>
<div ref={exportRef}>{renderSegmentContent()}</div>
</Flex>
);
};

View File

@@ -60,7 +60,7 @@ const ProjectViewMembers = () => {
const { trackMixpanelEvent } = useMixpanelTracking();
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
// State
const [isLoading, setIsLoading] = useState(false);
const [members, setMembers] = useState<IProjectMembersViewModel>();
@@ -138,7 +138,14 @@ const ProjectViewMembers = () => {
// Effects
useEffect(() => {
void getProjectMembers();
}, [refreshTimestamp, projectId, pagination.current, pagination.pageSize, pagination.field, pagination.order]);
}, [
refreshTimestamp,
projectId,
pagination.current,
pagination.pageSize,
pagination.field,
pagination.order,
]);
useEffect(() => {
trackMixpanelEvent(evt_project_members_visit);
@@ -151,9 +158,13 @@ const ProjectViewMembers = () => {
title: t('nameColumn'),
dataIndex: 'name',
sorter: true,
sortOrder: pagination.order === 'ascend' && pagination.field === 'name' ? 'ascend' :
pagination.order === 'descend' && pagination.field === 'name' ? 'descend' : null,
render: (_,record: IProjectMemberViewModel) => (
sortOrder:
pagination.order === 'ascend' && pagination.field === 'name'
? 'ascend'
: pagination.order === 'descend' && pagination.field === 'name'
? 'descend'
: null,
render: (_, record: IProjectMemberViewModel) => (
<Flex gap={8} align="center">
<Avatar size={28} src={record.avatar_url}>
{record.name?.charAt(0)}
@@ -167,8 +178,12 @@ const ProjectViewMembers = () => {
title: t('jobTitleColumn'),
dataIndex: 'job_title',
sorter: true,
sortOrder: pagination.order === 'ascend' && pagination.field === 'job_title' ? 'ascend' :
pagination.order === 'descend' && pagination.field === 'job_title' ? 'descend' : null,
sortOrder:
pagination.order === 'ascend' && pagination.field === 'job_title'
? 'ascend'
: pagination.order === 'descend' && pagination.field === 'job_title'
? 'descend'
: null,
render: (_, record: IProjectMemberViewModel) => (
<Typography.Text style={{ marginInlineStart: 12 }}>
{record?.job_title || '-'}
@@ -180,8 +195,12 @@ const ProjectViewMembers = () => {
title: t('emailColumn'),
dataIndex: 'email',
sorter: true,
sortOrder: pagination.order === 'ascend' && pagination.field === 'email' ? 'ascend' :
pagination.order === 'descend' && pagination.field === 'email' ? 'descend' : null,
sortOrder:
pagination.order === 'ascend' && pagination.field === 'email'
? 'ascend'
: pagination.order === 'descend' && pagination.field === 'email'
? 'descend'
: null,
render: (_, record: IProjectMemberViewModel) => (
<Typography.Text>{record.email}</Typography.Text>
),
@@ -210,8 +229,12 @@ const ProjectViewMembers = () => {
title: t('accessColumn'),
dataIndex: 'access',
sorter: true,
sortOrder: pagination.order === 'ascend' && pagination.field === 'access' ? 'ascend' :
pagination.order === 'descend' && pagination.field === 'access' ? 'descend' : null,
sortOrder:
pagination.order === 'ascend' && pagination.field === 'access'
? 'ascend'
: pagination.order === 'descend' && pagination.field === 'access'
? 'descend'
: null,
render: (_, record: IProjectMemberViewModel) => (
<Typography.Text style={{ textTransform: 'capitalize' }}>{record.access}</Typography.Text>
),
@@ -263,7 +286,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

@@ -1,4 +1,10 @@
import {
Button,
Dropdown,
Flex,
Tag,
Tooltip,
Typography,
ArrowLeftOutlined,
BellFilled,
BellOutlined,
@@ -10,11 +16,12 @@ import {
SettingOutlined,
SyncOutlined,
UsergroupAddOutlined,
} from '@ant-design/icons';
} from '@/shared/antd-imports';
import { PageHeader } from '@ant-design/pro-components';
import { Button, Dropdown, Flex, Tag, Tooltip, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useState, useCallback, useMemo, memo, useRef, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { colors } from '@/styles/colors';
import { useAppDispatch } from '@/hooks/useAppDispatch';
@@ -22,8 +29,18 @@ import { useAppSelector } from '@/hooks/useAppSelector';
import { SocketEvents } from '@/shared/socket-events';
import { useAuthService } from '@/hooks/useAuth';
import { useSocket } from '@/socket/socketContext';
import { setProject, setImportTaskTemplateDrawerOpen, setRefreshTimestamp } from '@features/project/project.slice';
import { addTask, fetchTaskGroups, fetchTaskListColumns, IGroupBy } from '@features/tasks/tasks.slice';
import {
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';
import { toggleSaveAsTemplateDrawer } from '@/features/projects/projectsSlice';
@@ -34,13 +51,11 @@ import {
setProjectId,
} from '@/features/project/project-drawer.slice';
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import { useState } from 'react';
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
import { DEFAULT_TASK_NAME, UNMAPPED } from '@/shared/constants';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { getGroupIdByGroupedColumn } from '@/services/task-list/taskList.service';
import logger from '@/utils/errorLogger';
import { createPortal } from 'react-dom';
import ImportTaskTemplate from '@/components/task-templates/import-task-template';
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
@@ -48,89 +63,162 @@ import useIsProjectManager from '@/hooks/useIsProjectManager';
import useTabSearchParam from '@/hooks/useTabSearchParam';
import { addTaskCardToTheTop, fetchBoardTaskGroups } from '@/features/board/board-slice';
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
import { fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { fetchTasksV3 } from '@/features/task-management/task-management.slice';
import { ShareAltOutlined } from '@ant-design/icons';
import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
const ProjectViewHeader = () => {
const ProjectViewHeader = memo(() => {
const navigate = useNavigate();
const { t } = useTranslation('project-view/project-view-header');
const dispatch = useAppDispatch();
const currentSession = useAuthService().getCurrentSession();
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const isProjectManager = useIsProjectManager();
const { tab } = useTabSearchParam();
// Memoize auth service calls to prevent unnecessary re-evaluations
const authService = useAuthService();
const currentSession = useMemo(() => authService.getCurrentSession(), [authService]);
const isOwnerOrAdmin = useMemo(() => authService.isOwnerOrAdmin(), [authService]);
const isProjectManager = useIsProjectManager();
const { socket } = useSocket();
const {
project: selectedProject,
projectId,
} = useAppSelector(state => state.projectReducer);
const { loadingGroups, groupBy } = useAppSelector(state => state.taskReducer);
// Optimized selectors with shallow equality checks
const selectedProject = useAppSelector(state => state.projectReducer.project);
const projectId = useAppSelector(state => state.projectReducer.projectId);
const loadingGroups = useAppSelector(state => state.taskReducer.loadingGroups);
const groupBy = useAppSelector(state => state.taskReducer.groupBy);
const [creatingTask, setCreatingTask] = useState(false);
const [subscriptionLoading, setSubscriptionLoading] = useState(false);
const handleRefresh = () => {
// Use ref to track subscription timeout
const subscriptionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
// Memoized refresh handler with optimized dependencies
const handleRefresh = useCallback(() => {
if (!projectId) return;
dispatch(getProject(projectId));
switch (tab) {
case 'tasks-list':
dispatch(fetchStatuses(projectId));
dispatch(fetchTaskListColumns(projectId));
dispatch(fetchPhasesByProjectId(projectId))
dispatch(fetchTaskGroups(projectId));
dispatch(fetchPhasesByProjectId(projectId));
dispatch(fetchTasksV3(projectId));
break;
case 'board':
dispatch(fetchBoardTaskGroups(projectId));
dispatch(fetchEnhancedKanbanGroups(projectId));
break;
case 'project-insights-member-overview':
dispatch(setRefreshTimestamp());
break;
case 'all-attachments':
dispatch(setRefreshTimestamp());
break;
case 'members':
dispatch(setRefreshTimestamp());
break;
case 'updates':
dispatch(setRefreshTimestamp());
break;
default:
break;
}
};
}, [dispatch, projectId, tab]);
const handleSubscribe = () => {
if (selectedProject?.id) {
// Optimized subscription handler with proper cleanup
const handleSubscribe = useCallback(() => {
if (!selectedProject?.id || !socket || subscriptionLoading) return;
try {
setSubscriptionLoading(true);
const newSubscriptionState = !selectedProject.subscribed;
dispatch(setProject({ ...selectedProject, subscribed: newSubscriptionState }));
// Clear any existing timeout
if (subscriptionTimeoutRef.current) {
clearTimeout(subscriptionTimeoutRef.current);
}
socket?.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), {
// Emit socket event
socket.emit(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), {
project_id: selectedProject.id,
user_id: currentSession?.id,
team_member_id: currentSession?.team_member_id,
mode: newSubscriptionState ? 1 : 0,
mode: newSubscriptionState ? 0 : 1,
});
}
};
const handleSettingsClick = () => {
// Listen for response with cleanup
const handleResponse = (response: any) => {
try {
dispatch(
setProject({
...selectedProject,
subscribed: newSubscriptionState,
})
);
} catch (error) {
logger.error('Error handling project subscription response:', error);
dispatch(
setProject({
...selectedProject,
subscribed: selectedProject.subscribed,
})
);
} finally {
setSubscriptionLoading(false);
if (subscriptionTimeoutRef.current) {
clearTimeout(subscriptionTimeoutRef.current);
subscriptionTimeoutRef.current = null;
}
}
};
socket.once(SocketEvents.PROJECT_SUBSCRIBERS_CHANGE.toString(), handleResponse);
// Set timeout with ref tracking
subscriptionTimeoutRef.current = setTimeout(() => {
setSubscriptionLoading(false);
logger.error('Project subscription timeout - no response from server');
subscriptionTimeoutRef.current = null;
}, 5000);
} catch (error) {
logger.error('Error updating project subscription:', error);
setSubscriptionLoading(false);
}
}, [selectedProject, socket, subscriptionLoading, currentSession, dispatch]);
// Memoized settings handler
const handleSettingsClick = useCallback(() => {
if (selectedProject?.id) {
console.log('Opening project drawer from project view for project:', selectedProject.id);
// Set project ID first
dispatch(setProjectId(selectedProject.id));
dispatch(fetchProjectData(selectedProject.id));
dispatch(toggleProjectDrawer());
// Then fetch project data
dispatch(fetchProjectData(selectedProject.id))
.unwrap()
.then((projectData) => {
console.log('Project data fetched successfully from project view:', projectData);
// Open drawer after data is fetched
dispatch(toggleProjectDrawer());
})
.catch((error) => {
console.error('Failed to fetch project data from project view:', error);
// Still open drawer even if fetch fails, so user can see error state
dispatch(toggleProjectDrawer());
});
}
};
}, [dispatch, selectedProject?.id]);
// Optimized task creation handler
const handleCreateTask = useCallback(() => {
if (!selectedProject?.id || !currentSession?.id || !socket) return;
const handleCreateTask = () => {
try {
setCreatingTask(true);
const body: ITaskCreateRequest = {
name: DEFAULT_TASK_NAME,
project_id: selectedProject?.id,
reporter_id: currentSession?.id,
team_id: currentSession?.team_id,
const body: Partial<ITaskCreateRequest> = {
name: t('defaultTaskName'),
project_id: selectedProject.id,
reporter_id: currentSession.id,
team_id: currentSession.team_id,
};
socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => {
const handleTaskCreated = (task: IProjectTask) => {
if (task.id) {
dispatch(setSelectedTaskId(task.id));
dispatch(setShowTaskDrawer(true));
@@ -142,168 +230,283 @@ const ProjectViewHeader = () => {
} else {
dispatch(addTask({ task, groupId }));
}
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
}
}
});
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
setCreatingTask(false);
};
socket.once(SocketEvents.QUICK_TASK.toString(), handleTaskCreated);
socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
} catch (error) {
logger.error('Error creating task', error);
} finally {
setCreatingTask(false);
}
};
}, [selectedProject?.id, currentSession, socket, dispatch, groupBy, tab, t]);
const handleImportTaskTemplate = () => {
// Memoized import task template handler
const handleImportTaskTemplate = useCallback(() => {
dispatch(setImportTaskTemplateDrawerOpen(true));
};
}, [dispatch]);
const dropdownItems = [
{
key: 'import',
label: (
<div style={{ width: '100%', margin: 0, padding: 0 }} onClick={handleImportTaskTemplate}>
<ImportOutlined /> Import task
</div>
),
},
];
// Memoized navigation handler
const handleNavigateToProjects = useCallback(() => {
navigate('/worklenz/projects');
}, [navigate]);
const renderProjectAttributes = () => (
<Flex gap={8} align="center">
{selectedProject?.category_id && (
<Tag color={colors.vibrantOrange} style={{ borderRadius: 24, paddingInline: 8, margin: 0 }}>
{selectedProject.category_name}
</Tag>
)}
// Memoized save as template handler
const handleSaveAsTemplate = useCallback(() => {
dispatch(toggleSaveAsTemplateDrawer());
}, [dispatch]);
{selectedProject?.status && (
<Tooltip title={selectedProject.status}>
// Memoized invite handler
const handleInvite = useCallback(() => {
dispatch(toggleProjectMemberDrawer());
}, [dispatch]);
// Memoized dropdown items
const dropdownItems = useMemo(
() => [
{
key: 'import',
label: (
<div style={{ width: '100%', margin: 0, padding: 0 }} onClick={handleImportTaskTemplate} title={t('importTaskTooltip')}>
<ImportOutlined /> {t('importTask')}
</div>
),
},
],
[handleImportTaskTemplate, t]
);
// Memoized project attributes with optimized date formatting
const projectAttributes = useMemo(() => {
if (!selectedProject) return null;
const elements = [];
if (selectedProject.category_id) {
elements.push(
<Tooltip key="category-tooltip" title={`${t('projectCategoryTooltip')}: ${selectedProject.category_name}`}>
<Tag
key="category"
color={colors.vibrantOrange}
style={{ borderRadius: 24, paddingInline: 8, margin: 0 }}
>
{selectedProject.category_name}
</Tag>
</Tooltip>
);
}
if (selectedProject.status) {
elements.push(
<Tooltip key="status" title={`${t('projectStatusTooltip')}: ${selectedProject.status}`}>
<ProjectStatusIcon
iconName={selectedProject.status_icon || ''}
color={selectedProject.status_color || ''}
/>
</Tooltip>
)}
);
}
{(selectedProject?.start_date || selectedProject?.end_date) && (
<Tooltip
title={
<Typography.Text style={{ color: colors.white }}>
{selectedProject?.start_date &&
`${t('startDate')}: ${formatDate(new Date(selectedProject.start_date))}`}
{selectedProject?.end_date && (
<>
<br />
{`${t('endDate')}: ${formatDate(new Date(selectedProject.end_date))}`}
</>
)}
</Typography.Text>
}
>
if (selectedProject.start_date || selectedProject.end_date) {
const tooltipContent = (
<Typography.Text style={{ color: colors.white }}>
{t('projectDatesInfo')}
<br />
{selectedProject.start_date &&
`${t('startDate')}: ${formatDate(new Date(selectedProject.start_date))}`}
{selectedProject.end_date && (
<>
<br />
{`${t('endDate')}: ${formatDate(new Date(selectedProject.end_date))}`}
</>
)}
</Typography.Text>
);
elements.push(
<Tooltip key="dates" title={tooltipContent}>
<CalendarOutlined style={{ fontSize: 16 }} />
</Tooltip>
)}
);
}
{selectedProject?.notes && (
<Typography.Text type="secondary">{selectedProject.notes}</Typography.Text>
)}
</Flex>
);
if (selectedProject.notes) {
elements.push(
<Typography.Text key="notes" type="secondary">
{selectedProject.notes}
</Typography.Text>
);
}
const renderHeaderActions = () => (
<Flex gap={8} align="center">
<Tooltip title="Refresh project">
return (
<Flex gap={4} align="center">
{elements}
</Flex>
);
}, [selectedProject, t]);
// Memoized header actions with conditional rendering optimization
const headerActions = useMemo(() => {
const actions = [];
// Refresh button
actions.push(
<Tooltip key="refresh" title={t('refreshTooltip')}>
<Button
shape="circle"
icon={<SyncOutlined spin={loadingGroups} />}
onClick={handleRefresh}
/>
</Tooltip>
);
{(isOwnerOrAdmin) && (
<Tooltip title="Save as template">
<Button
shape="circle"
icon={<SaveOutlined />}
onClick={() => dispatch(toggleSaveAsTemplateDrawer())}
/>
// Save as template (owner/admin only)
if (isOwnerOrAdmin) {
actions.push(
<Tooltip key="template" title={t('saveAsTemplateTooltip')}>
<Button shape="circle" icon={<SaveOutlined />} onClick={handleSaveAsTemplate} />
</Tooltip>
)}
);
}
<Tooltip title="Project settings">
// Settings button
actions.push(
<Tooltip key="settings" title={t('settingsTooltip')}>
<Button shape="circle" icon={<SettingOutlined />} onClick={handleSettingsClick} />
</Tooltip>
);
<Tooltip title={t('subscribe')}>
// Subscribe button
actions.push(
<Tooltip key="subscribe" title={selectedProject?.subscribed ? t('unsubscribeTooltip') : t('subscribeTooltip')}>
<Button
shape="round"
loading={subscriptionLoading}
icon={selectedProject?.subscribed ? <BellFilled /> : <BellOutlined />}
onClick={handleSubscribe}
>
{selectedProject?.subscribed ? t('unsubscribe') : t('subscribe')}
</Button>
</Tooltip>
);
{(isOwnerOrAdmin || isProjectManager) && (
<Button
type="primary"
icon={<UsergroupAddOutlined />}
onClick={() => dispatch(toggleProjectMemberDrawer())}
>
Invite
</Button>
)}
// Invite button (owner/admin/project manager only)
if (isOwnerOrAdmin || isProjectManager) {
actions.push(
<Tooltip key="invite-tooltip" title={t('inviteTooltip')}>
<Button key="invite" type="primary" icon={<ShareAltOutlined />} onClick={handleInvite}>
{t('share')}
</Button>
</Tooltip>
);
}
{isOwnerOrAdmin ? (
<Dropdown.Button
loading={creatingTask}
type="primary"
icon={<DownOutlined />}
menu={{ items: dropdownItems }}
trigger={['click']}
onClick={handleCreateTask}
>
<EditOutlined /> {t('createTask')}
</Dropdown.Button>
) : (
<Button
loading={creatingTask}
type="primary"
icon={<EditOutlined />}
onClick={handleCreateTask}
>
{t('createTask')}
</Button>
)}
</Flex>
// Create task button
if (isOwnerOrAdmin) {
actions.push(
<Tooltip key="create-task-tooltip" title={t('createTaskTooltip')}>
<Dropdown.Button
key="create-task-dropdown"
loading={creatingTask}
type="primary"
icon={<DownOutlined />}
menu={{ items: dropdownItems }}
trigger={['click']}
onClick={handleCreateTask}
>
<EditOutlined /> {t('createTask')}
</Dropdown.Button>
</Tooltip>
);
} else {
actions.push(
<Tooltip key="create-task-tooltip" title={t('createTaskTooltip')}>
<Button
key="create-task"
loading={creatingTask}
type="primary"
icon={<EditOutlined />}
onClick={handleCreateTask}
>
{t('createTask')}
</Button>
</Tooltip>
);
}
return (
<Flex gap={4} align="center">
{actions}
</Flex>
);
}, [
loadingGroups,
handleRefresh,
isOwnerOrAdmin,
handleSaveAsTemplate,
handleSettingsClick,
t,
subscriptionLoading,
selectedProject?.subscribed,
handleSubscribe,
isProjectManager,
handleInvite,
creatingTask,
dropdownItems,
handleCreateTask,
]);
// Memoized page header title
const pageHeaderTitle = useMemo(
() => (
<Flex gap={4} align="center">
<Tooltip title={t('navigateBackTooltip')}>
<ArrowLeftOutlined style={{ fontSize: 16, cursor: 'pointer' }} onClick={handleNavigateToProjects} />
</Tooltip>
<Typography.Title level={4} style={{ marginBlockEnd: 0, marginInlineStart: 8 }}>
{selectedProject?.name}
</Typography.Title>
{projectAttributes}
</Flex>
),
[handleNavigateToProjects, selectedProject?.name, projectAttributes, t]
);
// Memoized page header styles
const pageHeaderStyle = useMemo(
() => ({
paddingInline: 0,
}),
[]
);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (subscriptionTimeoutRef.current) {
clearTimeout(subscriptionTimeoutRef.current);
}
};
}, []);
return (
<>
<PageHeader
className="site-page-header"
title={
<Flex gap={8} align="center">
<ArrowLeftOutlined
style={{ fontSize: 16 }}
onClick={() => navigate('/worklenz/projects')}
/>
<Typography.Title level={4} style={{ marginBlockEnd: 0, marginInlineStart: 12 }}>
{selectedProject?.name}
</Typography.Title>
{renderProjectAttributes()}
</Flex>
}
style={{ paddingInline: 0, marginBlockEnd: 12 }}
extra={renderHeaderActions()}
title={pageHeaderTitle}
style={pageHeaderStyle}
extra={headerActions}
/>
{createPortal(<ProjectDrawer onClose={() => { }} />, document.body, 'project-drawer')}
{createPortal(<ProjectDrawer onClose={() => {}} />, document.body, 'project-drawer')}
{createPortal(<ImportTaskTemplate />, document.body, 'import-task-template')}
{createPortal(<SaveProjectAsTemplate />, document.body, 'save-project-as-template')}
</>
);
};
});
ProjectViewHeader.displayName = 'ProjectViewHeader';
export default ProjectViewHeader;

View File

@@ -4,3 +4,27 @@
height: 8px;
width: 8px;
}
/* Light mode - selected tab header bold */
[data-theme="light"] .project-view-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
font-weight: 700;
color: #000000 !important;
}
/* Dark mode - selected tab header bold and white */
[data-theme="dark"] .project-view-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
font-weight: 900;
color: #ffffff;
}
/* Light mode - selected tab underline black */
[data-theme="light"] .project-view-tabs .ant-tabs-ink-bar {
background-color: #000000 !important;
}
/* Dark mode - selected tab underline white */
[data-theme="dark"] .project-view-tabs .ant-tabs-ink-bar {
background-color: #ffffff;
}

View File

@@ -1,202 +1,391 @@
import React, { useEffect, useState } from 'react';
import { PushpinFilled, PushpinOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import { Badge, Button, ConfigProvider, Flex, Tabs, TabsProps, Tooltip } from 'antd';
import React, { useEffect, useState, useMemo, useCallback, Suspense } from 'react';
import { useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { createPortal } from 'react-dom';
// Centralized Ant Design imports
import {
Button,
ConfigProvider,
Flex,
Tabs,
PushpinFilled,
PushpinOutlined,
type TabsProps,
} from '@/shared/antd-imports';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { getProject, setProjectId, setProjectView } from '@/features/project/project.slice';
import { fetchStatuses, resetStatuses } from '@/features/taskAttributes/taskStatusSlice';
import { projectsApiService } from '@/api/projects/projects.api.service';
import { colors } from '@/styles/colors';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import ProjectViewHeader from './project-view-header';
import './project-view.css';
import { resetTaskListData } from '@/features/tasks/tasks.slice';
import { resetBoardData } from '@/features/board/board-slice';
import { resetTaskManagement } from '@/features/task-management/task-management.slice';
import { resetGrouping } from '@/features/task-management/grouping.slice';
import { resetSelection } from '@/features/task-management/selection.slice';
import { resetFields } from '@/features/task-management/taskListFields.slice';
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
import { tabItems } from '@/lib/project/project-view-constants';
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import { tabItems, updateTabLabels } from '@/lib/project/project-view-constants';
import {
setSelectedTaskId,
setShowTaskDrawer,
resetTaskDrawer,
} from '@/features/task-drawer/task-drawer.slice';
import { resetState as resetEnhancedKanbanState } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { setProjectId as setInsightsProjectId } from '@/features/projects/insights/project-insights.slice';
import { SuspenseFallback } from '@/components/suspense-fallback/suspense-fallback';
import { useTranslation } from 'react-i18next';
const DeleteStatusDrawer = React.lazy(() => import('@/components/project-task-filters/delete-status-drawer/delete-status-drawer'));
const PhaseDrawer = React.lazy(() => import('@features/projects/singleProject/phase/PhaseDrawer'));
// Import critical components synchronously to avoid suspense interruptions
import TaskDrawer from '@components/task-drawer/task-drawer';
// Lazy load non-critical components with better error handling
const DeleteStatusDrawer = React.lazy(
() => import('@/components/project-task-filters/delete-status-drawer/delete-status-drawer')
);
const PhaseDrawer = React.lazy(() => import('@/features/projects/singleProject/phase/PhaseDrawer'));
const StatusDrawer = React.lazy(
() => import('@/components/project-task-filters/create-status-drawer/create-status-drawer')
);
const ProjectMemberDrawer = React.lazy(
() => import('@/components/projects/project-member-invite-drawer/project-member-invite-drawer')
);
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
const ProjectView = () => {
const ProjectView = React.memo(() => {
const location = useLocation();
const navigate = useNavigate();
const dispatch = useAppDispatch();
const [searchParams] = useSearchParams();
const { projectId } = useParams();
const { t, i18n } = useTranslation('project-view');
// Memoized selectors to prevent unnecessary re-renders
const selectedProject = useAppSelector(state => state.projectReducer.project);
useDocumentTitle(selectedProject?.name || 'Project View');
const [activeTab, setActiveTab] = useState<string>(searchParams.get('tab') || tabItems[0].key);
const [pinnedTab, setPinnedTab] = useState<string>(searchParams.get('pinned_tab') || '');
const [taskid, setTaskId] = useState<string>(searchParams.get('task') || '');
const projectLoading = useAppSelector(state => state.projectReducer.projectLoading);
// State to track translation loading
const [translationsReady, setTranslationsReady] = useState(false);
// Optimize document title updates
useDocumentTitle(selectedProject?.name || t('projectView'));
// Memoize URL params to prevent unnecessary state updates
const urlParams = useMemo(
() => ({
tab: searchParams.get('tab') || tabItems[0].key,
pinnedTab: searchParams.get('pinned_tab') || '',
taskId: searchParams.get('task') || '',
}),
[searchParams]
);
const [activeTab, setActiveTab] = useState<string>(urlParams.tab);
const [pinnedTab, setPinnedTab] = useState<string>(urlParams.pinnedTab);
const [taskid, setTaskId] = useState<string>(urlParams.taskId);
const [isInitialized, setIsInitialized] = useState(false);
// Update local state when URL params change
useEffect(() => {
if (projectId) {
dispatch(setProjectId(projectId));
dispatch(getProject(projectId)).then((res: any) => {
if (!res.payload) {
navigate('/worklenz/projects');
return;
}
dispatch(fetchStatuses(projectId));
dispatch(fetchLabels());
});
setActiveTab(urlParams.tab);
setPinnedTab(urlParams.pinnedTab);
setTaskId(urlParams.taskId);
}, [urlParams]);
// Remove translation preloading since we're using simple load-as-you-go approach
useEffect(() => {
updateTabLabels();
setTranslationsReady(true);
}, [i18n.language]);
// Update tab labels when language changes
useEffect(() => {
if (translationsReady) {
updateTabLabels();
}
if (taskid) {
dispatch(setSelectedTaskId(taskid || ''));
dispatch(setShowTaskDrawer(true));
}
}, [dispatch, navigate, projectId, taskid]);
}, [t, translationsReady]);
const pinToDefaultTab = async (itemKey: string) => {
if (!itemKey || !projectId) return;
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
const res = await projectsApiService.updateDefaultTab({
project_id: projectId,
default_view: defaultView,
});
if (res.done) {
setPinnedTab(itemKey);
tabItems.forEach(item => {
if (item.key === itemKey) {
item.isPinned = true;
} else {
item.isPinned = false;
}
});
navigate({
pathname: `/worklenz/projects/${projectId}`,
search: new URLSearchParams({
tab: activeTab,
pinned_tab: itemKey
}).toString(),
});
}
};
const handleTabChange = (key: string) => {
setActiveTab(key);
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
navigate({
pathname: location.pathname,
search: new URLSearchParams({
tab: key,
pinned_tab: pinnedTab,
}).toString(),
});
};
const tabMenuItems = tabItems.map(item => ({
key: item.key,
label: (
<Flex align="center" style={{ color: colors.skyBlue }}>
{item.label}
{item.key === 'tasks-list' || item.key === 'board' ? (
<ConfigProvider wave={{ disabled: true }}>
<Button
className="borderless-icon-btn"
style={{
backgroundColor: colors.transparent,
boxShadow: 'none',
}}
icon={
item.key === pinnedTab ? (
<PushpinFilled
size={20}
style={{
color: colors.skyBlue,
rotate: '-45deg',
transition: 'transform ease-in 300ms',
}}
/>
) : (
<PushpinOutlined
size={20}
style={{
color: colors.skyBlue,
}}
/>
)
}
onClick={e => {
e.stopPropagation();
pinToDefaultTab(item.key);
}}
/>
</ConfigProvider>
) : null}
</Flex>
),
children: item.element,
}));
const resetProjectData = () => {
// Comprehensive cleanup function for when leaving project view entirely
const resetAllProjectData = useCallback(() => {
dispatch(setProjectId(null));
dispatch(resetStatuses());
dispatch(deselectAll());
dispatch(resetTaskListData());
dispatch(resetBoardData());
};
dispatch(resetTaskManagement());
dispatch(resetGrouping());
dispatch(resetSelection());
dispatch(resetFields());
dispatch(resetEnhancedKanbanState());
// Reset project insights
dispatch(setInsightsProjectId(''));
// Reset task drawer completely
dispatch(resetTaskDrawer());
}, [dispatch]);
// Effect for handling component unmount (leaving project view entirely)
useEffect(() => {
// This cleanup only runs when the component unmounts
return () => {
resetAllProjectData();
};
}, [resetAllProjectData]);
// Effect for handling route changes (when navigating away from project view)
useEffect(() => {
const currentPath = location.pathname;
// If we're not on a project view path, clean up
if (!currentPath.includes('/worklenz/projects/') || currentPath === '/worklenz/projects') {
resetAllProjectData();
}
}, [location.pathname, resetAllProjectData]);
// Optimized project data loading with better error handling and performance tracking
useEffect(() => {
if (projectId && !isInitialized) {
const loadProjectData = async () => {
try {
// Clean up previous project data before loading new project
dispatch(resetTaskListData());
dispatch(resetBoardData());
dispatch(resetTaskManagement());
dispatch(resetEnhancedKanbanState());
dispatch(deselectAll());
// Load new project data
dispatch(setProjectId(projectId));
// Load project and essential data in parallel
const [projectResult] = await Promise.allSettled([
dispatch(getProject(projectId)),
dispatch(fetchStatuses(projectId)),
dispatch(fetchLabels()),
]);
if (projectResult.status === 'fulfilled' && !projectResult.value.payload) {
navigate('/worklenz/projects');
return;
}
setIsInitialized(true);
} catch (error) {
console.error('Error loading project data:', error);
navigate('/worklenz/projects');
}
};
loadProjectData();
}
}, [dispatch, navigate, projectId]);
// Reset initialization when project changes
useEffect(() => {
setIsInitialized(false);
}, [projectId]);
// Effect for handling task drawer opening from URL params
useEffect(() => {
if (taskid && isInitialized) {
dispatch(setSelectedTaskId(taskid));
dispatch(setShowTaskDrawer(true));
}
}, [dispatch, taskid, isInitialized]);
// Optimized pin tab function with better error handling
const pinToDefaultTab = useCallback(
async (itemKey: string) => {
if (!itemKey || !projectId) return;
try {
const defaultView = itemKey === 'tasks-list' ? 'TASK_LIST' : 'BOARD';
const res = await projectsApiService.updateDefaultTab({
project_id: projectId,
default_view: defaultView,
});
if (res.done) {
setPinnedTab(itemKey);
// Optimize tab items update
tabItems.forEach(item => {
item.isPinned = item.key === itemKey;
});
navigate(
{
pathname: `/worklenz/projects/${projectId}`,
search: new URLSearchParams({
tab: activeTab,
pinned_tab: itemKey,
}).toString(),
},
{ replace: true }
); // Use replace to avoid history pollution
}
} catch (error) {
console.error('Error updating default tab:', error);
}
},
[projectId, activeTab, navigate]
);
// Optimized tab change handler
const handleTabChange = useCallback(
(key: string) => {
setActiveTab(key);
dispatch(setProjectView(key === 'board' ? 'kanban' : 'list'));
// Use replace for better performance and history management
navigate(
{
pathname: location.pathname,
search: new URLSearchParams({
tab: key,
pinned_tab: pinnedTab,
}).toString(),
},
{ replace: true }
);
},
[dispatch, location.pathname, navigate, pinnedTab]
);
// Memoized tab menu items with enhanced styling
const tabMenuItems = useMemo(() => {
// Only render tabs when translations are ready
if (!translationsReady) {
return [];
}
const menuItems = tabItems.map(item => ({
key: item.key,
label: (
<Flex align="center" gap={6} style={{ color: 'inherit' }}>
<span style={{ fontWeight: 500, fontSize: '13px' }}>{item.label}</span>
{(item.key === 'tasks-list' || item.key === 'board') && (
<ConfigProvider wave={{ disabled: true }}>
<Button
className="borderless-icon-btn"
size="small"
type="text"
style={{
backgroundColor: 'transparent',
border: 'none',
boxShadow: 'none',
padding: '2px',
minWidth: 'auto',
height: 'auto',
lineHeight: 1,
}}
icon={
item.key === pinnedTab ? (
<PushpinFilled
style={{
fontSize: '12px',
color: 'currentColor',
transform: 'rotate(-45deg)',
transition: 'all 0.3s ease',
}}
/>
) : (
<PushpinOutlined
style={{
fontSize: '12px',
color: 'currentColor',
transition: 'all 0.3s ease',
}}
/>
)
}
onClick={e => {
e.stopPropagation();
pinToDefaultTab(item.key);
}}
title={item.key === pinnedTab ? t('unpinTab') : t('pinTab')}
/>
</ConfigProvider>
)}
</Flex>
),
children: item.element,
}));
return menuItems;
}, [pinnedTab, pinToDefaultTab, t, translationsReady]);
// Optimized secondary components loading with better UX
const [shouldLoadSecondaryComponents, setShouldLoadSecondaryComponents] = useState(false);
useEffect(() => {
return () => {
resetProjectData();
};
}, []);
if (isInitialized) {
// Reduce delay and load secondary components after core data is ready
const timer = setTimeout(() => {
setShouldLoadSecondaryComponents(true);
}, 500); // Reduced from 1000ms to 500ms
return () => clearTimeout(timer);
}
}, [isInitialized]);
// Optimized portal elements with better error boundaries
const portalElements = useMemo(
() => (
<>
{/* Critical component - load immediately without suspense */}
{createPortal(<TaskDrawer />, document.body, 'task-drawer')}
{/* Non-critical components - load after delay with suspense fallback */}
{shouldLoadSecondaryComponents && (
<Suspense fallback={<SuspenseFallback />}>
{createPortal(<ProjectMemberDrawer />, document.body, 'project-member-drawer')}
{createPortal(<PhaseDrawer />, document.body, 'phase-drawer')}
{createPortal(<StatusDrawer />, document.body, 'status-drawer')}
{createPortal(<DeleteStatusDrawer />, document.body, 'delete-status-drawer')}
</Suspense>
)}
</>
),
[shouldLoadSecondaryComponents]
);
// Show loading state while project is being fetched or translations are loading
if (projectLoading || !isInitialized || !translationsReady) {
return (
<div style={{ marginBlockStart: 70, marginBlockEnd: 12, minHeight: '80vh' }}>
<SuspenseFallback />
</div>
);
}
return (
<div style={{ marginBlockStart: 80, marginBlockEnd: 24, minHeight: '80vh' }}>
<div style={{ marginBlockStart: 70, marginBlockEnd: 12, minHeight: '80vh' }}>
<ProjectViewHeader />
<Tabs
className="project-view-tabs"
activeKey={activeTab}
onChange={handleTabChange}
items={tabMenuItems}
tabBarStyle={{ paddingInline: 0 }}
destroyInactiveTabPane={true}
// tabBarExtraContent={
// <div>
// <span style={{ position: 'relative', top: '-10px' }}>
// <Tooltip title="Members who are active on this project will be displayed here.">
// <QuestionCircleOutlined />
// </Tooltip>
// </span>
// <span
// style={{
// position: 'relative',
// right: '20px',
// top: '10px',
// }}
// >
// <Badge status="success" dot className="profile-badge" />
// </span>
// </div>
// }
destroyOnHidden={true}
animated={{
inkBar: true,
tabPane: false,
}}
size="small"
/>
{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>
);
};
});
ProjectView.displayName = 'ProjectView';
export default ProjectView;

View File

@@ -0,0 +1,238 @@
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

@@ -31,7 +31,7 @@ import { SocketEvents } from '@/shared/socket-events';
import logger from '@/utils/errorLogger';
import TaskListTable from '../task-list-table/task-list-table';
import Collapsible from '@/components/collapsible/collapsible';
import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar';
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
import { createPortal } from 'react-dom';
@@ -90,9 +90,7 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
selected: true,
}));
const groupId = groups.find(group =>
group.tasks.some(task => task.id === data.id)
)?.id;
const groupId = groups.find(group => group.tasks.some(task => task.id === data.id))?.id;
if (groupId) {
dispatch(
@@ -129,13 +127,13 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
const overGroupId = over.data.current?.groupId;
const activeTaskId = active.id;
const overTaskId = over.id;
setGroups(prevGroups => {
// ... existing drag end logic ...
});
};
const getDropdownItems = (groupId: string): MenuProps['items'] => ([
const getDropdownItems = (groupId: string): MenuProps['items'] => [
{
key: '1',
icon: <EditOutlined />,
@@ -157,7 +155,7 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
type: 'group',
})),
},
]);
];
return (
<DndContext
@@ -184,20 +182,25 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
<Button
className="custom-collapse-button"
style={{
backgroundColor: themeMode === 'dark' ? group.color_code_dark : group.color_code,
backgroundColor:
themeMode === 'dark' ? group.color_code_dark : group.color_code,
border: 'none',
borderBottomLeftRadius: expandedGroups[group.id] ? 0 : 4,
borderBottomRightRadius: expandedGroups[group.id] ? 0 : 4,
color: colors.darkGray,
}}
icon={<RightOutlined rotate={expandedGroups[group.id] ? 90 : 0} />}
onClick={() => setExpandedGroups(prev => ({ ...prev, [group.id]: !prev[group.id] }))}
onClick={() =>
setExpandedGroups(prev => ({ ...prev, [group.id]: !prev[group.id] }))
}
>
{renamingGroup === group.id ? (
<Input
size="small"
value={groupNames[group.id]}
onChange={e => setGroupNames(prev => ({ ...prev, [group.id]: e.target.value }))}
onChange={e =>
setGroupNames(prev => ({ ...prev, [group.id]: e.target.value }))
}
onBlur={() => setRenamingGroup(null)}
onPressEnter={() => setRenamingGroup(null)}
onClick={e => e.stopPropagation()}
@@ -220,11 +223,7 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
className="border-l-[3px] relative after:content after:absolute after:h-full after:w-1 after:z-10 after:top-0 after:left-0 mt-1"
color={themeMode === 'dark' ? group.color_code_dark : group.color_code}
>
<TaskListTable
taskList={group.tasks}
tableId={group.id}
activeId={activeId}
/>
<TaskListTable taskList={group.tasks} tableId={group.id} activeId={activeId} />
</Collapsible>
</Flex>
</div>
@@ -232,7 +231,6 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
</Flex>
</ConfigProvider>
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
{createPortal(
<TaskTemplateDrawer showDrawer={false} selectedTemplateId={''} onClose={() => {}} />,
document.body,
@@ -242,4 +240,4 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
);
};
export default TaskGroupList;
export default TaskGroupList;

View File

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

View File

@@ -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,
@@ -15,13 +16,14 @@ import {
import { getTeamMembers } from '@/features/team-members/team-members.slice';
import useTabSearchParam from '@/hooks/useTabSearchParam';
const SearchDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/search-dropdown'));
const SortFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/sort-filter-dropdown'));
const LabelsFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/labels-filter-dropdown'));
const MembersFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/members-filter-dropdown'));
const GroupByFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/group-by-filter-dropdown'));
const ShowFieldsFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/show-fields-filter-dropdown'));
const PriorityFilterDropdown = React.lazy(() => import('@components/project-task-filters/filter-dropdowns/priority-filter-dropdown'));
// Import filter components synchronously for better performance
import SearchDropdown from '@components/project-task-filters/filter-dropdowns/search-dropdown';
import SortFilterDropdown from '@components/project-task-filters/filter-dropdowns/sort-filter-dropdown';
import LabelsFilterDropdown from '@components/project-task-filters/filter-dropdowns/labels-filter-dropdown';
import MembersFilterDropdown from '@components/project-task-filters/filter-dropdowns/members-filter-dropdown';
import GroupByFilterDropdown from '@components/project-task-filters/filter-dropdowns/group-by-filter-dropdown';
import ShowFieldsFilterDropdown from '@components/project-task-filters/filter-dropdowns/show-fields-filter-dropdown';
import PriorityFilterDropdown from '@components/project-task-filters/filter-dropdowns/priority-filter-dropdown';
interface TaskListFiltersProps {
position: 'board' | 'list';
@@ -33,23 +35,53 @@ 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());
// Optimized filter data loading - staggered and non-blocking
useEffect(() => {
const fetchInitialData = async () => {
if (!priorities.length) await dispatch(fetchPriorities());
if (projectId) {
await dispatch(fetchLabelsByProject(projectId));
await dispatch(fetchTaskAssignees(projectId));
const loadFilterData = () => {
try {
// Load priorities first (usually cached/fast) - immediate
if (!priorities.length) {
dispatch(fetchPriorities());
}
if (projectId) {
// Stagger the loading to prevent overwhelming the server
// Load project-specific data with delays
setTimeout(() => {
dispatch(fetchLabelsByProject(projectId));
}, 100);
setTimeout(() => {
dispatch(fetchTaskAssignees(projectId));
}, 200);
// Load team members last (heaviest query)
setTimeout(() => {
dispatch(
getTeamMembers({
index: 0,
size: 50, // Reduce initial load size
field: null,
order: null,
search: null,
all: false, // Don't load all at once
})
);
}, 300);
}
} 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();
// Load immediately without setTimeout to avoid additional delay
loadFilterData();
}, [dispatch, priorities.length, projectId]);
return (

View File

@@ -71,10 +71,9 @@ const TaskContextMenu = ({ visible, position, selectedTask, onClose, t }: TaskCo
if (res.done) {
const { id: taskId, assignees } = res.body;
trackMixpanelEvent(evt_project_task_list_context_menu_assign_me);
const groupId = taskGroups.find(group =>
group.tasks.some(task =>
task.id === taskId ||
task.sub_tasks?.some(subtask => subtask.id === taskId)
const groupId = taskGroups.find(group =>
group.tasks.some(
task => task.id === taskId || task.sub_tasks?.some(subtask => subtask.id === taskId)
)
)?.id;
@@ -115,7 +114,8 @@ const TaskContextMenu = ({ visible, position, selectedTask, onClose, t }: TaskCo
trackMixpanelEvent(evt_project_task_list_context_menu_archive);
dispatch(deleteTask({ taskId: selectedTask.id }));
dispatch(deselectAll());
if (selectedTask.parent_task_id) socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask.parent_task_id);
if (selectedTask.parent_task_id)
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask.parent_task_id);
}
} catch (error) {
console.error(error);
@@ -135,7 +135,8 @@ const TaskContextMenu = ({ visible, position, selectedTask, onClose, t }: TaskCo
trackMixpanelEvent(evt_project_task_list_context_menu_delete);
dispatch(deleteTask({ taskId: selectedTask.id }));
dispatch(deselectAll());
if (selectedTask.parent_task_id) socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask.parent_task_id);
if (selectedTask.parent_task_id)
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask.parent_task_id);
}
} catch (error) {
console.error(error);
@@ -264,14 +265,16 @@ const TaskContextMenu = ({ visible, position, selectedTask, onClose, t }: TaskCo
label: t('contextMenu.moveTo'),
children: getMoveToOptions(),
},
...(!selectedTask?.parent_task_id ? [
{
key: '3',
icon: <InboxOutlined />,
label: archived ? t('contextMenu.unarchive' ) : t('contextMenu.archive'),
onClick: handleArchive,
},
] : []),
...(!selectedTask?.parent_task_id
? [
{
key: '3',
icon: <InboxOutlined />,
label: archived ? t('contextMenu.unarchive') : t('contextMenu.archive'),
onClick: handleArchive,
},
]
: []),
...(selectedTask?.sub_tasks_count === 0 && !selectedTask?.parent_task_id
? [
{

View File

@@ -7,14 +7,14 @@ import { useTranslation } from 'react-i18next';
import { colors } from '../../../../../../../../styles/colors';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
const CustomColumnLabelCell = ({
labelsList,
const CustomColumnLabelCell = ({
labelsList,
selectedLabels = [],
onChange
}: {
labelsList: ITaskLabel[],
selectedLabels?: string[],
onChange?: (labels: string[]) => void
onChange,
}: {
labelsList: ITaskLabel[];
selectedLabels?: string[];
onChange?: (labels: string[]) => void;
}) => {
const [currentLabelOption, setCurrentLabelOption] = useState<ITaskLabel | null>(null);

View File

@@ -8,14 +8,14 @@ import { colors } from '../../../../../../../../styles/colors';
import { SelectionType } from '../../custom-column-modal/selection-type-column/selection-type-column';
import { ALPHA_CHANNEL } from '@/shared/constants';
const CustomColumnSelectionCell = ({
const CustomColumnSelectionCell = ({
selectionsList,
value,
onChange
}: {
selectionsList: SelectionType[],
value?: string,
onChange?: (value: string) => void
onChange,
}: {
selectionsList: SelectionType[];
value?: string;
onChange?: (value: string) => void;
}) => {
const [currentSelectionOption, setCurrentSelectionOption] = useState<SelectionType | null>(null);
@@ -23,10 +23,10 @@ const CustomColumnSelectionCell = ({
const { t } = useTranslation('task-list-table');
// Debug the selectionsList and value
console.log('CustomColumnSelectionCell props:', {
selectionsList,
console.log('CustomColumnSelectionCell props:', {
selectionsList,
value,
selectionsCount: selectionsList?.length || 0
selectionsCount: selectionsList?.length || 0,
});
// Set initial selection based on value prop
@@ -61,7 +61,7 @@ const CustomColumnSelectionCell = ({
// handle selection selection
const handleSelectionOptionSelect: MenuProps['onClick'] = e => {
if (e.key === 'noSelections') return;
const selectedOption = selectionsList.find(option => option.selection_id === e.key);
if (selectedOption) {
setCurrentSelectionOption(selectedOption);
@@ -105,7 +105,8 @@ const CustomColumnSelectionCell = ({
paddingInline: 8,
height: 22,
fontSize: 13,
backgroundColor: currentSelectionOption?.selection_color + ALPHA_CHANNEL || colors.transparent,
backgroundColor:
currentSelectionOption?.selection_color + ALPHA_CHANNEL || colors.transparent,
color: colors.darkGray,
cursor: 'pointer',
}}

View File

@@ -1,4 +1,16 @@
import { Button, Divider, Flex, Form, Input, message, Modal, Select, Typography, Popconfirm } from 'antd';
import {
Button,
Divider,
Flex,
Form,
Input,
message,
Modal,
Select,
Typography,
Popconfirm,
} from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import SelectionTypeColumn from './selection-type-column/selection-type-column';
import NumberTypeColumn from './number-type-column/number-type-column';
import LabelTypeColumn from './label-type-column/label-type-column';
@@ -20,6 +32,7 @@ import {
setSecondNumericColumn,
setSelectionsList,
setLabelsList,
resetCustomFieldValues,
} from '@features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
import CustomColumnHeader from '../custom-column-header/custom-column-header';
import { nanoid } from '@reduxjs/toolkit';
@@ -30,7 +43,12 @@ import {
import { themeWiseColor } from '@/utils/themeWiseColor';
import KeyTypeColumn from './key-type-column/key-type-column';
import logger from '@/utils/errorLogger';
import { addCustomColumn, deleteCustomColumn as deleteCustomColumnFromTasks } from '@/features/tasks/tasks.slice';
import {
fetchTasksV3,
fetchTaskListColumns,
addCustomColumn,
deleteCustomColumn as deleteCustomColumnFromTaskManagement,
} from '@/features/task-management/task-management.slice';
import { useParams } from 'react-router-dom';
import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service';
import { ExclamationCircleFilled } from '@ant-design/icons';
@@ -38,6 +56,7 @@ import { ExclamationCircleFilled } from '@ant-design/icons';
const CustomColumnModal = () => {
const [mainForm] = Form.useForm();
const { projectId } = useParams();
const { t } = useTranslation('task-list-table');
// get theme details from theme reducer
const themeMode = useAppSelector(state => state.themeReducer.mode);
@@ -48,6 +67,7 @@ const CustomColumnModal = () => {
customColumnId,
customColumnModalType,
isCustomColumnModalOpen,
currentColumnData,
decimals,
label,
labelPosition,
@@ -68,35 +88,56 @@ const CustomColumnModal = () => {
state => state.taskListCustomColumnsReducer.customFieldNumberType
);
// if it is already created column get the column data
const openedColumn = useAppSelector(state => state.taskReducer.customColumns).find(
col => col.id === customColumnId
);
// Use the column data passed from TaskListV2
const openedColumn = currentColumnData;
// Function to reset all form and Redux state
const resetModalData = () => {
mainForm.resetFields();
dispatch(resetCustomFieldValues());
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
};
// Function to handle deleting a custom column
const handleDeleteColumn = async () => {
if (!customColumnId) return;
// The customColumnId should now be the UUID passed from TaskListV2
// But also check the column data as a fallback, prioritizing uuid over id
const columnUUID = customColumnId ||
openedColumn?.uuid ||
openedColumn?.id ||
openedColumn?.custom_column_obj?.uuid ||
openedColumn?.custom_column_obj?.id;
if (!customColumnId || !columnUUID) {
message.error('Cannot delete column: Missing UUID');
return;
}
try {
// Make API request to delete the custom column using the service
await tasksCustomColumnsService.deleteCustomColumn(openedColumn?.id || customColumnId);
await tasksCustomColumnsService.deleteCustomColumn(columnUUID);
// Dispatch actions to update the Redux store
dispatch(deleteCustomColumnFromTasks(customColumnId));
dispatch(deleteCustomColumnFromTaskManagement(customColumnId));
dispatch(deleteCustomColumnFromColumns(customColumnId));
// Close the modal
// Close the modal and reset data
dispatch(toggleCustomColumnModalOpen(false));
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
resetModalData();
// Show success message
message.success('Custom column deleted successfully');
// Reload the page to reflect the changes
window.location.reload();
message.success(t('customColumns.modal.deleteSuccessMessage'));
// Refresh tasks and columns to reflect the deleted custom column
if (projectId) {
dispatch(fetchTaskListColumns(projectId));
dispatch(fetchTasksV3(projectId));
}
} catch (error) {
logger.error('Error deleting custom column:', error);
message.error('Failed to delete custom column');
message.error(t('customColumns.modal.deleteErrorMessage'));
}
};
@@ -104,49 +145,49 @@ const CustomColumnModal = () => {
{
key: 'people',
value: 'people',
label: 'People',
label: t('customColumns.fieldTypes.people'),
disabled: false,
},
{
key: 'number',
value: 'number',
label: 'Number',
label: t('customColumns.fieldTypes.number'),
disabled: false,
},
{
key: 'date',
value: 'date',
label: 'Date',
label: t('customColumns.fieldTypes.date'),
disabled: false,
},
{
key: 'selection',
value: 'selection',
label: 'Selection',
label: t('customColumns.fieldTypes.selection'),
disabled: false,
},
{
key: 'checkbox',
value: 'checkbox',
label: 'Checkbox',
label: t('customColumns.fieldTypes.checkbox'),
disabled: true,
},
{
key: 'labels',
value: 'labels',
label: 'Labels',
label: t('customColumns.fieldTypes.labels'),
disabled: true,
},
{
key: 'key',
value: 'key',
label: 'Key',
label: t('customColumns.fieldTypes.key'),
disabled: true,
},
{
key: 'formula',
value: 'formula',
label: 'Formula',
label: t('customColumns.fieldTypes.formula'),
disabled: true,
},
];
@@ -211,18 +252,27 @@ const CustomColumnModal = () => {
field_type: value.fieldType,
width: 120,
is_visible: true,
configuration
configuration,
});
if (res.done) {
if (res.body.id) newColumn.id = res.body.id;
dispatch(addCustomColumn(newColumn));
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
dispatch(toggleCustomColumnModalOpen(false));
dispatch(addCustomColumn(newColumn));
dispatch(toggleCustomColumnModalOpen(false));
resetModalData();
// Show success message
message.success(t('customColumns.modal.createSuccessMessage'));
// Refresh tasks and columns to include the new custom column values
if (projectId) {
dispatch(fetchTaskListColumns(projectId));
dispatch(fetchTasksV3(projectId));
}
}
} catch (error) {
logger.error('Error creating custom column:', error);
message.error('Failed to create custom column');
message.error(t('customColumns.modal.createErrorMessage'));
}
} else if (customColumnModalType === 'edit' && customColumnId) {
const updatedColumn = openedColumn
@@ -250,7 +300,14 @@ const CustomColumnModal = () => {
}
: null;
if (updatedColumn) {
// Get the correct UUID for the update operation, prioritizing uuid over id
const updateColumnUUID = customColumnId ||
openedColumn?.uuid ||
openedColumn?.id ||
openedColumn?.custom_column_obj?.uuid ||
openedColumn?.custom_column_obj?.id;
if (updatedColumn && updateColumnUUID) {
try {
// Prepare the configuration object
const configuration = {
@@ -285,23 +342,29 @@ const CustomColumnModal = () => {
};
// Make API request to update custom column using the service
await tasksCustomColumnsService.updateCustomColumn(openedColumn?.id || customColumnId, {
await tasksCustomColumnsService.updateCustomColumn(updateColumnUUID, {
name: value.fieldTitle,
field_type: value.fieldType,
width: 150,
is_visible: true,
configuration
configuration,
});
// Close modal
// Close modal and reset data
dispatch(toggleCustomColumnModalOpen(false));
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
resetModalData();
// Reload the page instead of updating the slice
window.location.reload();
// Show success message
message.success(t('customColumns.modal.updateSuccessMessage'));
// Refresh tasks and columns to reflect the updated custom column
if (projectId) {
dispatch(fetchTaskListColumns(projectId));
dispatch(fetchTasksV3(projectId));
}
} catch (error) {
logger.error('Error updating custom column:', error);
message.error('Failed to update custom column');
message.error(t('customColumns.modal.updateErrorMessage'));
}
}
}
@@ -314,49 +377,50 @@ const CustomColumnModal = () => {
return (
<Modal
title={customColumnModalType === 'create' ? 'Add field' : 'Edit field'}
title={customColumnModalType === 'create' ? t('customColumns.modal.addFieldTitle') : t('customColumns.modal.editFieldTitle')}
centered
open={isCustomColumnModalOpen}
onCancel={() => {
dispatch(toggleCustomColumnModalOpen(false));
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
resetModalData();
}}
styles={{
header: { position: 'relative' },
footer: { display: 'none' },
}}
onClose={() => {
mainForm.resetFields();
}}
afterOpenChange={open => {
if (open && customColumnModalType === 'edit' && openedColumn) {
if (open && customColumnModalType === 'edit' && openedColumn) {
// Set the field type first so the correct form fields are displayed
dispatch(setCustomFieldType(openedColumn.custom_column_obj?.fieldType || 'people'));
// Set other field values based on the custom column type
if (openedColumn.custom_column_obj?.fieldType === 'number') {
dispatch(setCustomFieldNumberType(openedColumn.custom_column_obj?.numberType || 'formatted'));
dispatch(
setCustomFieldNumberType(openedColumn.custom_column_obj?.numberType || 'formatted')
);
dispatch(setDecimals(openedColumn.custom_column_obj?.decimals || 0));
dispatch(setLabel(openedColumn.custom_column_obj?.label || ''));
dispatch(setLabelPosition(openedColumn.custom_column_obj?.labelPosition || 'left'));
} else if (openedColumn.custom_column_obj?.fieldType === 'formula') {
dispatch(setExpression(openedColumn.custom_column_obj?.expression || 'add'));
dispatch(setFirstNumericColumn(openedColumn.custom_column_obj?.firstNumericColumn || null));
dispatch(setSecondNumericColumn(openedColumn.custom_column_obj?.secondNumericColumn || null));
dispatch(
setFirstNumericColumn(openedColumn.custom_column_obj?.firstNumericColumn || null)
);
dispatch(
setSecondNumericColumn(openedColumn.custom_column_obj?.secondNumericColumn || null)
);
} else if (openedColumn.custom_column_obj?.fieldType === 'selection') {
// Directly set the selections list in the Redux store
if (Array.isArray(openedColumn.custom_column_obj?.selectionsList)) {
console.log('Setting selections list:', openedColumn.custom_column_obj.selectionsList);
dispatch(setSelectionsList(openedColumn.custom_column_obj.selectionsList));
}
} else if (openedColumn.custom_column_obj?.fieldType === 'labels') {
// Directly set the labels list in the Redux store
if (Array.isArray(openedColumn.custom_column_obj?.labelsList)) {
console.log('Setting labels list:', openedColumn.custom_column_obj.labelsList);
dispatch(setLabelsList(openedColumn.custom_column_obj.labelsList));
}
}
// Set form values
mainForm.setFieldsValue({
fieldTitle: openedColumn.name || openedColumn.custom_column_obj?.fieldTitle,
@@ -371,9 +435,11 @@ const CustomColumnModal = () => {
secondNumericColumn: openedColumn.custom_column_obj?.secondNumericColumn,
});
} else if (open && customColumnModalType === 'create') {
// Reset form for create mode
mainForm.resetFields();
dispatch(setCustomFieldType('people'));
// Reset all data for create mode
resetModalData();
} else if (!open) {
// Reset data when modal closes
resetModalData();
}
}}
>
@@ -414,22 +480,22 @@ const CustomColumnModal = () => {
<Flex gap={16} align="center" justify="space-between">
<Form.Item
name={'fieldTitle'}
label={<Typography.Text>Field title</Typography.Text>}
label={<Typography.Text>{t('customColumns.modal.fieldTitle')}</Typography.Text>}
layout="vertical"
rules={[
{
required: true,
message: 'Field title is required',
message: t('customColumns.modal.fieldTitleRequired'),
},
]}
required={false}
>
<Input placeholder="title" style={{ minWidth: '100%', width: 300 }} />
<Input placeholder={t('customColumns.modal.columnTitlePlaceholder')} style={{ minWidth: '100%', width: 300 }} />
</Form.Item>
<Form.Item
name={'fieldType'}
label={<Typography.Text>Type</Typography.Text>}
label={<Typography.Text>{t('customColumns.modal.type')}</Typography.Text>}
layout="vertical"
>
<Select
@@ -462,27 +528,30 @@ const CustomColumnModal = () => {
>
{customColumnModalType === 'edit' && customColumnId && (
<Popconfirm
title="Are you sure you want to delete this custom column?"
description="This action cannot be undone. All data associated with this column will be permanently deleted."
title={t('customColumns.modal.deleteConfirmTitle')}
description={t('customColumns.modal.deleteConfirmDescription')}
icon={<ExclamationCircleFilled style={{ color: 'red' }} />}
onConfirm={handleDeleteColumn}
okText="Delete"
cancelText="Cancel"
okText={t('customColumns.modal.deleteButton')}
cancelText={t('customColumns.modal.cancelButton')}
okButtonProps={{ danger: true }}
>
<Button danger>Delete</Button>
<Button danger>{t('customColumns.modal.deleteButton')}</Button>
</Popconfirm>
)}
<Flex gap={8}>
<Button onClick={() => dispatch(toggleCustomColumnModalOpen(false))}>Cancel</Button>
<Button onClick={() => {
dispatch(toggleCustomColumnModalOpen(false));
resetModalData();
}}>{t('customColumns.modal.cancelButton')}</Button>
{customColumnModalType === 'create' ? (
<Button type="primary" htmlType="submit">
Create
{t('customColumns.modal.createButton')}
</Button>
) : (
<Button type="primary" htmlType="submit">
Update
{t('customColumns.modal.updateButton')}
</Button>
)}
</Flex>

View File

@@ -27,9 +27,9 @@ const LabelTypeColumn = () => {
const { customColumnModalType, customColumnId } = useAppSelector(
state => state.taskListCustomColumnsReducer
);
// Get the opened column data if in edit mode
const openedColumn = useAppSelector(state =>
const openedColumn = useAppSelector(state =>
state.taskReducer.customColumns.find(col => col.key === customColumnId)
);
@@ -127,11 +127,7 @@ const LabelTypeColumn = () => {
))}
</Flex>
<Button
type="link"
onClick={handleAddLabel}
style={{ width: 'fit-content', padding: 0 }}
>
<Button type="link" onClick={handleAddLabel} style={{ width: 'fit-content', padding: 0 }}>
+ Add a label
</Button>
</Flex>

View File

@@ -25,29 +25,23 @@ const SelectionTypeColumn = () => {
]);
// Get the custom column modal type and column ID from the store
const { customColumnModalType, customColumnId, selectionsList: storeSelectionsList } = useAppSelector(
state => state.taskListCustomColumnsReducer
);
// Get the opened column data if in edit mode
const openedColumn = useAppSelector(state =>
state.taskReducer.customColumns.find(col => col.key === customColumnId)
);
console.log('SelectionTypeColumn render:', {
const {
customColumnModalType,
customColumnId,
openedColumn,
storeSelectionsList,
'openedColumn?.custom_column_obj?.selectionsList': openedColumn?.custom_column_obj?.selectionsList
});
currentColumnData,
selectionsList: storeSelectionsList,
} = useAppSelector(state => state.taskListCustomColumnsReducer);
// Use the current column data passed from TaskListV2
const openedColumn = currentColumnData;
// Load existing selections when in edit mode
useEffect(() => {
if (customColumnModalType === 'edit' && openedColumn?.custom_column_obj?.selectionsList) {
const existingSelections = openedColumn.custom_column_obj.selectionsList;
console.log('Loading existing selections:', existingSelections);
if (Array.isArray(existingSelections) && existingSelections.length > 0) {
setSelections(existingSelections);
dispatch(setSelectionsList(existingSelections));
@@ -86,7 +80,9 @@ const SelectionTypeColumn = () => {
// update selection name
const handleUpdateSelectionName = (selectionId: string, selectionName: string) => {
const updatedSelections = selections.map(selection =>
selection.selection_id === selectionId ? { ...selection, selection_name: selectionName } : selection
selection.selection_id === selectionId
? { ...selection, selection_name: selectionName }
: selection
);
setSelections(updatedSelections);
dispatch(setSelectionsList(updatedSelections)); // update the slice with the new selection name
@@ -95,7 +91,9 @@ const SelectionTypeColumn = () => {
// update selection color
const handleUpdateSelectionColor = (selectionId: string, selectionColor: string) => {
const updatedSelections = selections.map(selection =>
selection.selection_id === selectionId ? { ...selection, selection_color: selectionColor } : selection
selection.selection_id === selectionId
? { ...selection, selection_color: selectionColor }
: selection
);
setSelections(updatedSelections);
dispatch(setSelectionsList(updatedSelections)); // update the slice with the new selection color
@@ -103,7 +101,9 @@ const SelectionTypeColumn = () => {
// remove a selection
const handleRemoveSelection = (selectionId: string) => {
const updatedSelections = selections.filter(selection => selection.selection_id !== selectionId);
const updatedSelections = selections.filter(
selection => selection.selection_id !== selectionId
);
setSelections(updatedSelections);
dispatch(setSelectionsList(updatedSelections)); // update the slice after selection removal
};

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';
@@ -61,7 +61,7 @@ import {
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
import TaskListTableWrapper from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper';
import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar';
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
@@ -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,19 @@ 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 => ({
...assignee,
selected: true,
}));
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 +140,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]);
// Memoize socket event handlers
const handleLabelsChange = useCallback(
async (labels: ILabelsChangeResponse) => {
if (!labels) return;
// Socket handler for label updates
useEffect(() => {
if (!socket) return;
const handleLabelsChange = async (labels: ILabelsChangeResponse) => {
await Promise.all([
dispatch(updateTaskLabel(labels)),
dispatch(setTaskLabels(labels)),
dispatch(fetchLabels()),
projectId && dispatch(fetchLabelsByProject(projectId)),
]);
};
},
[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 +184,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 +199,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 +209,213 @@ 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);
// Memoize socket event handlers
const handleEndDateChange = useCallback(
(task: { id: string; parent_task: string | null; end_date: string }) => {
if (!task) return;
return () => {
socket.off(SocketEvents.TASK_PRIORITY_CHANGE.toString(), handlePriorityChange);
};
}, [socket, dispatch]);
const taskWithProgress = {
...task,
manual_progress: false,
} as IProjectTask;
// Socket handler for due date updates
useEffect(() => {
if (!socket) return;
dispatch(updateTaskEndDate({ task: taskWithProgress }));
dispatch(setTaskEndDate(taskWithProgress));
},
[dispatch]
);
const handleEndDateChange = (task: {
id: string;
parent_task: string | null;
end_date: string;
}) => {
dispatch(updateTaskEndDate({ task }));
dispatch(setTaskEndDate(task));
};
// Memoize socket event handlers
const handleTaskNameChange = useCallback(
(data: { id: string; parent_task: string; name: string }) => {
if (!data) return;
socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
return () => {
socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
};
}, [socket, dispatch]);
// Socket handler for task name updates
useEffect(() => {
if (!socket) return;
const handleTaskNameChange = (data: { id: string; parent_task: string; name: string }) => {
dispatch(updateTaskName(data));
};
},
[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);
// Memoize socket event handlers
const handleStartDateChange = useCallback(
(task: { id: string; parent_task: string | null; start_date: string }) => {
if (!task) return;
return () => {
socket.off(SocketEvents.TASK_PHASE_CHANGE.toString(), handlePhaseChange);
};
}, [socket, dispatch]);
const taskWithProgress = {
...task,
manual_progress: false,
} as IProjectTask;
// Socket handler for start date updates
useEffect(() => {
if (!socket) return;
dispatch(updateTaskStartDate({ task: taskWithProgress }));
dispatch(setStartDate(taskWithProgress));
},
[dispatch]
);
const handleStartDateChange = (task: {
id: string;
parent_task: string | null;
start_date: string;
}) => {
dispatch(updateTaskStartDate({ task }));
dispatch(setStartDate(task));
};
// Memoize socket event handlers
const handleTaskSubscribersChange = useCallback(
(data: InlineMember[]) => {
if (!data) return;
socket.on(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
return () => {
socket.off(SocketEvents.TASK_START_DATE_CHANGE.toString(), handleStartDateChange);
};
}, [socket, dispatch]);
// Socket handler for task subscribers updates
useEffect(() => {
if (!socket) return;
const handleTaskSubscribersChange = (data: InlineMember[]) => {
dispatch(setTaskSubscribers(data));
};
},
[dispatch]
);
socket.on(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
// Memoize socket event handlers
const handleEstimationChange = useCallback(
(task: { id: string; parent_task: string | null; estimation: number }) => {
if (!task) return;
return () => {
socket.off(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), handleTaskSubscribersChange);
};
}, [socket, dispatch]);
const taskWithProgress = {
...task,
manual_progress: false,
} as IProjectTask;
// Socket handler for task estimation updates
useEffect(() => {
if (!socket) return;
dispatch(updateTaskEstimation({ task: taskWithProgress }));
},
[dispatch]
);
const handleEstimationChange = (task: {
id: string;
parent_task: string | null;
estimation: number;
}) => {
dispatch(updateTaskEstimation({ task }));
};
// Memoize socket event handlers
const handleTaskDescriptionChange = useCallback(
(data: { id: string; parent_task: string; description: string }) => {
if (!data) return;
socket.on(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
return () => {
socket.off(SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(), handleEstimationChange);
};
}, [socket, dispatch]);
// Socket handler for task description updates
useEffect(() => {
if (!socket) return;
const handleTaskDescriptionChange = (data: {
id: string;
parent_task: string;
description: string;
}) => {
dispatch(updateTaskDescription(data));
};
},
[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 +434,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 +447,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
return;
}
// Update task properties based on target group
switch (groupBy) {
case IGroupBy.STATUS:
task.status = overGroupId;
@@ -433,35 +459,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 +499,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 +524,69 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
});
}
// Emit socket event
// NEW SIMPLIFIED APPROACH: Calculate all affected task updates and send them
const taskUpdates: Array<{
task_id: string;
sort_order: number;
status_id?: string;
priority_id?: string;
phase_id?: string;
}> = [];
// Add updates for all tasks in affected groups
if (activeGroupId === overGroupId) {
// Same group - just reorder
const updatedTasks = [...sourceGroup.tasks];
updatedTasks.splice(fromIndex, 1);
updatedTasks.splice(toIndex, 0, task);
updatedTasks.forEach((task, index) => {
taskUpdates.push({
task_id: task.id,
sort_order: index + 1, // 1-based indexing
});
});
} else {
// Different groups - update both source and target
const updatedSourceTasks = sourceGroup.tasks.filter((_, i) => i !== fromIndex);
const updatedTargetTasks = [...targetGroup.tasks];
if (isTargetGroupEmpty) {
updatedTargetTasks.push(task);
} else if (toIndex >= 0 && toIndex <= updatedTargetTasks.length) {
updatedTargetTasks.splice(toIndex, 0, task);
} else {
updatedTargetTasks.push(task);
}
// Add updates for source group
updatedSourceTasks.forEach((task, index) => {
taskUpdates.push({
task_id: task.id,
sort_order: index + 1,
});
});
// Add updates for target group (including the moved task)
updatedTargetTasks.forEach((task, index) => {
const update: any = {
task_id: task.id,
sort_order: index + 1,
};
// Add group-specific updates
if (groupBy === IGroupBy.STATUS) {
update.status_id = targetGroup.id;
} else if (groupBy === IGroupBy.PRIORITY) {
update.priority_id = targetGroup.id;
} else if (groupBy === IGroupBy.PHASE) {
update.phase_id = targetGroup.id;
}
taskUpdates.push(update);
});
}
socket?.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
project_id: projectId,
from_index: sourceGroup.tasks[fromIndex].sort_order,
@@ -514,13 +595,12 @@ 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,
task_updates: taskUpdates, // NEW: Send calculated updates
});
// Reset styles
setTimeout(resetTaskRowStyles, 0);
trackMixpanelEvent(evt_project_task_list_drag_and_move);
},
[
@@ -535,6 +615,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
]
);
// Memoize drag handlers
const handleDragOver = useCallback(
({ active, over }: DragEndEvent) => {
if (!over) return;
@@ -554,12 +635,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 +655,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 +704,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);
}
@@ -656,8 +731,6 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
/>
))}
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
{createPortal(
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
document.body,

View File

@@ -6,7 +6,7 @@ const TaskListDescriptionCell = ({ description }: { description: string }) => {
return (
<Typography.Paragraph
ellipsis={{
ellipsis={{
expandable: false,
rows: 1,
tooltip: description,

View File

@@ -3,7 +3,7 @@ import { Typography } from 'antd';
import React from 'react';
interface ITaskListEstimationCellProps {
task: IProjectTask
task: IProjectTask;
}
const TaskListEstimationCell = ({ task }: ITaskListEstimationCellProps) => {
return <Typography.Text>{task?.total_time_string}</Typography.Text>;

View File

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

View File

@@ -1,20 +1,57 @@
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

@@ -20,7 +20,7 @@ import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/tas
import { useState, useRef, useEffect } from 'react';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { fetchSubTasks } from '@/features/tasks/tasks.slice';
import { fetchSubTasks } from '@/features/task-management/task-management.slice';
type TaskListTaskCellProps = {
task: IProjectTask;
@@ -74,7 +74,7 @@ const TaskListTaskCell = ({
return (
<button
onClick={() => handleToggleExpansion(taskId)}
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
className="hover flex h-4 w-4 items-center justify-center rounded-sm text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
>
{task.show_sub_tasks ? <DownOutlined /> : <RightOutlined />}
</button>
@@ -86,11 +86,11 @@ const TaskListTaskCell = ({
isSubTask: boolean,
subTasksCount: number
) => {
if (subTasksCount > 0) {
if (subTasksCount > 0 && !isSubTask) {
return (
<button
onClick={() => handleToggleExpansion(taskId)}
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
className="hover flex h-4 w-4 items-center justify-center rounded-sm text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
>
{task.show_sub_tasks ? <DownOutlined /> : <RightOutlined />}
</button>
@@ -100,7 +100,7 @@ const TaskListTaskCell = ({
return !isSubTask ? (
<button
onClick={() => handleToggleExpansion(taskId)}
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54] open-task-button"
className="hover flex h-4 w-4 items-center justify-center rounded-sm text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54] open-task-button"
>
{task.show_sub_tasks ? <DownOutlined /> : <RightOutlined />}
</button>
@@ -110,25 +110,23 @@ const TaskListTaskCell = ({
};
const renderSubtasksCountLabel = (taskId: string, isSubTask: boolean, subTasksCount: number) => {
if (!taskId) return null;
if (!taskId || subTasksCount <= 1) 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

@@ -1,36 +1,80 @@
import { Input } from 'antd';
import React, { useState } from 'react';
import { Input, Button } from 'antd';
import React, { useRef, useEffect, useState } from 'react';
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
import { colors } from '../../../../../../styles/colors';
import { useTranslation } from 'react-i18next';
const AddSubTaskListRow = () => {
const [isEdit, setIsEdit] = useState<boolean>(false);
interface AddSubTaskListRowProps {
visibleColumns: { key: string; label: string; width: number }[];
taskColumnKey: string;
onAdd: (name: string) => void;
onCancel: () => void;
parentTaskId: string;
}
// localization
const AddSubTaskListRow: React.FC<AddSubTaskListRowProps> = ({
visibleColumns,
taskColumnKey,
onAdd,
onCancel,
}) => {
const [subtaskName, setSubtaskName] = useState('');
const inputRef = useRef<any>(null);
const { t } = useTranslation('task-list-table');
// get data theme data from redux
const themeMode = useAppSelector(state => state.themeReducer.mode);
const customBorderColor = themeMode === 'dark' && ' border-[#303030]';
const customBorderColor = themeMode === 'dark' ? ' border-[#303030]' : '';
useEffect(() => {
inputRef.current?.focus();
}, []);
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && subtaskName.trim()) {
onAdd(subtaskName.trim());
setSubtaskName('');
} else if (e.key === 'Escape') {
onCancel();
}
};
return (
<div className={`border-t ${customBorderColor}`}>
{isEdit ? (
<Input
className="h-12 w-full rounded-none"
style={{ borderColor: colors.skyBlue }}
placeholder={t('addTaskInputPlaceholder')}
onBlur={() => setIsEdit(false)}
/>
) : (
<Input
onFocus={() => setIsEdit(true)}
className="w-[300px] border-none"
value={t('addSubTaskText')}
/>
)}
</div>
<tr className={`add-subtask-row${customBorderColor}`}>
{visibleColumns.map(col => (
<td key={col.key} style={{ padding: 0, background: 'inherit' }}>
{col.key === taskColumnKey ? (
<div style={{ display: 'flex', alignItems: 'center', padding: '4px 0' }}>
<Input
ref={inputRef}
value={subtaskName}
onChange={e => setSubtaskName(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={onCancel}
placeholder={t('enterSubtaskName')}
style={{ width: '100%' }}
autoFocus
/>
<Button
size="small"
type="primary"
style={{ marginLeft: 8 }}
disabled={!subtaskName.trim()}
onClick={() => {
if (subtaskName.trim()) {
onAdd(subtaskName.trim());
setSubtaskName('');
}
}}
>
{t('add')}
</Button>
<Button size="small" style={{ marginLeft: 4 }} onClick={onCancel}>
{t('cancel')}
</Button>
</div>
) : null}
</td>
))}
</tr>
);
};

View File

@@ -1,18 +1,13 @@
import Input, { InputRef } from 'antd/es/input';
import { useMemo, useRef, useState } from 'react';
import { useMemo, useRef, useState, useEffect } from 'react';
import { Spin } from 'antd';
import { LoadingOutlined } from '@ant-design/icons';
import { useAppSelector } from '@/hooks/useAppSelector';
import { colors } from '@/styles/colors';
import { useTranslation } from 'react-i18next';
import { SocketEvents } from '@/shared/socket-events';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { DRAWER_ANIMATION_INTERVAL } from '@/shared/constants';
import {
getCurrentGroup,
GROUP_BY_STATUS_VALUE,
GROUP_BY_PRIORITY_VALUE,
GROUP_BY_PHASE_VALUE,
addTask,
} from '@/features/tasks/tasks.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useSocket } from '@/socket/socketContext';
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
@@ -31,7 +26,10 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
const [isEdit, setIsEdit] = useState<boolean>(false);
const [taskName, setTaskName] = useState<string>('');
const [creatingTask, setCreatingTask] = useState<boolean>(false);
const [error, setError] = useState<string>('');
const [taskCreationTimeout, setTaskCreationTimeout] = useState<NodeJS.Timeout | null>(null);
const taskInputRef = useRef<InputRef>(null);
const containerRef = useRef<HTMLDivElement>(null);
const dispatch = useAppDispatch();
const currentSession = useAuthService().getCurrentSession();
@@ -42,22 +40,71 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
const themeMode = useAppSelector(state => state.themeReducer.mode);
const customBorderColor = useMemo(() => themeMode === 'dark' && ' border-[#303030]', [themeMode]);
const projectId = useAppSelector(state => state.projectReducer.projectId);
const currentGrouping = useAppSelector(state => state.grouping.currentGrouping);
// Cleanup timeout on unmount
useEffect(() => {
return () => {
if (taskCreationTimeout) {
clearTimeout(taskCreationTimeout);
}
};
}, [taskCreationTimeout]);
// Handle click outside to cancel edit mode
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
isEdit &&
!creatingTask &&
containerRef.current &&
!containerRef.current.contains(event.target as Node)
) {
cancelEdit();
}
};
if (isEdit) {
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}
}, [isEdit, creatingTask]);
const createRequestBody = (): ITaskCreateRequest | null => {
if (!projectId || !currentSession) return null;
const body: ITaskCreateRequest = {
project_id: projectId,
id: '',
name: taskName,
reporter_id: currentSession.id,
description: '',
status_id: '',
priority: '',
start_date: '',
end_date: '',
total_hours: 0,
total_minutes: 0,
billable: false,
phase_id: '',
parent_task_id: undefined,
project_id: projectId,
team_id: currentSession.team_id,
task_key: '',
labels: [],
assignees: [],
names: [],
sub_tasks_count: 0,
manual_progress: false,
progress_value: null,
weight: null,
reporter_id: currentSession.id,
};
const groupBy = getCurrentGroup();
if (groupBy.value === GROUP_BY_STATUS_VALUE) {
if (currentGrouping === 'status') {
body.status_id = groupId || undefined;
} else if (groupBy.value === GROUP_BY_PRIORITY_VALUE) {
} else if (currentGrouping === 'priority') {
body.priority_id = groupId || undefined;
} else if (groupBy.value === GROUP_BY_PHASE_VALUE) {
} else if (currentGrouping === 'phase') {
body.phase_id = groupId || undefined;
}
@@ -69,10 +116,14 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
const reset = (scroll = true) => {
setIsEdit(false);
setCreatingTask(false);
setTaskName('');
setError('');
if (taskCreationTimeout) {
clearTimeout(taskCreationTimeout);
setTaskCreationTimeout(null);
}
setIsEdit(true);
setTimeout(() => {
@@ -81,74 +132,217 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr
}, DRAWER_ANIMATION_INTERVAL);
};
const onNewTaskReceived = (task: IAddNewTask) => {
if (!groupId) return;
// Ensure we're adding the task with the correct group
const taskWithGroup = {
...task,
groupId: groupId,
};
// Add the task to the state
dispatch(
addTask({
task: taskWithGroup,
groupId,
insert: true,
})
);
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id || task.id);
// Reset the input state
reset(false);
const cancelEdit = () => {
setIsEdit(false);
setTaskName('');
setError('');
if (taskCreationTimeout) {
clearTimeout(taskCreationTimeout);
setTaskCreationTimeout(null);
}
};
const addInstantTask = async () => {
if (creatingTask || !projectId || !currentSession || taskName.trim() === '') return;
// Validation
if (creatingTask || !projectId || !currentSession) return;
const trimmedTaskName = taskName.trim();
if (trimmedTaskName === '') {
setError('Task name cannot be empty');
taskInputRef.current?.focus();
return;
}
try {
setCreatingTask(true);
setError('');
const body = createRequestBody();
if (!body) return;
if (!body) {
setError('Failed to create task. Please try again.');
setCreatingTask(false);
return;
}
// Set timeout for task creation (10 seconds)
const timeout = setTimeout(() => {
setCreatingTask(false);
setError('Task creation timed out. Please try again.');
}, 10000);
setTaskCreationTimeout(timeout);
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
// Handle success response - the global socket handler will handle task addition
socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => {
clearTimeout(timeout);
setTaskCreationTimeout(null);
setCreatingTask(false);
onNewTaskReceived(task as IAddNewTask);
if (task && task.id) {
// Just reset the form - the global handler will add the task to Redux
reset(false);
// Emit progress update for parent task if this is a subtask
if (task.parent_task_id) {
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
} else {
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
}
} else {
setError('Failed to create task. Please try again.');
}
});
// Handle error response
socket?.once('error', (errorData: any) => {
clearTimeout(timeout);
setTaskCreationTimeout(null);
setCreatingTask(false);
const errorMessage = errorData?.message || 'Failed to create task';
setError(errorMessage);
});
} catch (error) {
console.error('Error adding task:', error);
setCreatingTask(false);
setError('An unexpected error occurred. Please try again.');
}
};
const handleAddTask = () => {
setIsEdit(false);
if (creatingTask) return; // Prevent multiple submissions
addInstantTask();
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Escape') {
e.preventDefault();
cancelEdit();
} else if (e.key === 'Enter' && !creatingTask) {
e.preventDefault();
handleAddTask();
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setTaskName(e.target.value);
if (error) setError(''); // Clear error when user starts typing
};
return (
<div>
<div className="add-task-row-container" ref={containerRef}>
{isEdit ? (
<Input
className="h-12 w-full rounded-none"
style={{ borderColor: colors.skyBlue }}
placeholder={t('addTaskInputPlaceholder')}
onChange={e => setTaskName(e.target.value)}
onBlur={handleAddTask}
onPressEnter={handleAddTask}
ref={taskInputRef}
/>
<div className="add-task-input-container">
<Input
className="add-task-input"
style={{
borderColor: error ? '#ff4d4f' : colors.skyBlue,
paddingRight: creatingTask ? '32px' : '12px',
}}
placeholder={t('addTaskInputPlaceholder')}
value={taskName}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
ref={taskInputRef}
autoFocus
disabled={creatingTask}
/>
{creatingTask && (
<div className="add-task-loading">
<Spin size="small" indicator={<LoadingOutlined style={{ fontSize: 14 }} spin />} />
</div>
)}
{error && <div className="add-task-error">{error}</div>}
</div>
) : (
<Input
onFocus={() => setIsEdit(true)}
className="w-[300px] border-none"
value={parentTask ? t('addSubTaskText') : t('addTaskText')}
ref={taskInputRef}
/>
<div className="add-task-label" onClick={() => setIsEdit(true)}>
<span className="add-task-text">
{parentTask ? t('addSubTaskText') : t('addTaskText')}
</span>
</div>
)}
<style>{`
.add-task-row-container {
width: 100%;
transition: height 0.3s ease;
}
.add-task-input-container {
position: relative;
width: 100%;
}
.add-task-input {
width: 100%;
height: 40px;
border-radius: 6px;
border: 1px solid ${colors.skyBlue};
font-size: 14px;
padding: 0 12px;
margin: 2px 0;
transition: border-color 0.2s ease, background-color 0.2s ease;
}
.add-task-input:disabled {
background-color: var(--task-bg-secondary, #f5f5f5);
cursor: not-allowed;
opacity: 0.7;
}
.add-task-loading {
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
z-index: 1;
}
.add-task-error {
font-size: 12px;
color: #ff4d4f;
margin-top: 4px;
margin-left: 2px;
line-height: 1.4;
}
.add-task-label {
width: 100%;
height: 40px;
display: flex;
align-items: center;
padding: 0;
cursor: pointer;
border-radius: 6px;
border: 1px solid transparent;
transition: all 0.2s ease;
color: var(--task-text-tertiary, #8c8c8c);
}
.add-task-label:hover {
background: var(--task-hover-bg, #fafafa);
border-color: var(--task-border-tertiary, #d9d9d9);
color: var(--task-text-secondary, #595959);
}
.add-task-text {
font-size: 14px;
user-select: none;
}
/* Dark mode support */
.dark .add-task-label,
[data-theme="dark"] .add-task-label {
color: var(--task-text-tertiary, #8c8c8c);
}
.dark .add-task-label:hover,
[data-theme="dark"] .add-task-label:hover {
background: var(--task-hover-bg, #2a2a2a);
border-color: var(--task-border-tertiary, #505050);
color: var(--task-text-secondary, #d9d9d9);
}
`}</style>
</div>
);
};

View File

@@ -16,7 +16,12 @@ import TaskListTable from '../task-list-table';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import Collapsible from '@/components/collapsible/collapsible';
import { fetchTaskGroups, fetchTaskListColumns, IGroupBy, updateTaskGroupColor } from '@/features/tasks/tasks.slice';
import {
fetchTaskGroups,
fetchTaskListColumns,
IGroupBy,
updateTaskGroupColor,
} from '@/features/tasks/tasks.slice';
import { useAuthService } from '@/hooks/useAuth';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types';
@@ -131,7 +136,11 @@ const TaskListTableWrapper = ({
await updateStatus();
} else if (groupBy === IGroupBy.PHASE) {
const body = { id: tableId, name: tableName.trim() };
const res = await phasesApiService.updateNameOfPhase(tableId, body as ITaskPhase, projectId);
const res = await phasesApiService.updateNameOfPhase(
tableId,
body as ITaskPhase,
projectId
);
if (res.done) {
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' });
dispatch(fetchPhasesByProjectId(projectId));
@@ -199,6 +208,18 @@ const TaskListTableWrapper = ({
>
<Flex vertical>
<Flex style={{ transform: 'translateY(6px)' }}>
{groupBy !== IGroupBy.PRIORITY &&
!showRenameInput &&
isEditable &&
name !== 'Unmapped' && (
<Dropdown menu={{ items }}>
<Button
icon={<EllipsisOutlined />}
className="borderless-icon-btn"
title={isEditable ? undefined : t('noPermission')}
/>
</Dropdown>
)}
<Button
className="custom-collapse-button"
style={{
@@ -211,7 +232,7 @@ const TaskListTableWrapper = ({
icon={<RightOutlined rotate={isExpanded ? 90 : 0} />}
onClick={handlToggleExpand}
>
{(showRenameInput && name !== 'Unmapped') ? (
{showRenameInput && name !== 'Unmapped' ? (
<Input
size="small"
value={tableName}
@@ -234,22 +255,19 @@ const TaskListTableWrapper = ({
</Typography.Text>
)}
</Button>
{groupBy !== IGroupBy.PRIORITY && !showRenameInput && isEditable && name !== 'Unmapped' && (
<Dropdown menu={{ items }}>
<Button
icon={<EllipsisOutlined />}
className="borderless-icon-btn"
title={isEditable ? undefined : t('noPermission')}
/>
</Dropdown>
)}
</Flex>
<Collapsible
isOpen={isExpanded}
className={`border-l-[3px] relative after:content after:absolute after:h-full after:w-1 after:z-10 after:top-0 after:left-0`}
color={color}
>
<TaskListTable taskList={taskList} tableId={tableId} activeId={activeId} />
<TaskListTable
taskList={taskList}
tableId={tableId}
activeId={activeId}
groupBy={groupBy}
isOver={isOver}
/>
</Collapsible>
</Flex>
</ConfigProvider>
@@ -257,4 +275,4 @@ const TaskListTableWrapper = ({
);
};
export default TaskListTableWrapper;
export default TaskListTableWrapper;

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

@@ -22,7 +22,6 @@ const TasksProgressCell = ({ tasksStat }: TasksProgressCellProps) => {
{ percent: donePercent, color: '#98d4b1', label: 'done' },
{ percent: doingPercent, color: '#bce3cc', label: 'doing' },
{ percent: todoPercent, color: '#e3f4ea', label: 'todo' },
];
return (

View File

@@ -25,15 +25,17 @@ 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);
const handleExport = () => {
if (!currentSession?.team_name) return;
reportingExportApiService.exportMembers(currentSession.team_name, duration, dateRange, archived);
reportingExportApiService.exportMembers(
currentSession.team_name,
duration,
dateRange,
archived
);
};
useEffect(() => {
@@ -44,7 +46,7 @@ const MembersReports = () => {
return (
<Flex vertical>
<CustomPageHeader
title={`Members`}
title={`Members (${total})`}
children={
<Space>
<Button>

View File

@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useCallback, useMemo } from 'react';
import { Button, Card, Checkbox, Flex, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
@@ -25,29 +25,40 @@ const OverviewReports = () => {
trackMixpanelEvent(evt_reporting_overview);
}, [trackMixpanelEvent]);
const handleArchiveToggle = () => {
const handleArchiveToggle = useCallback(() => {
dispatch(toggleIncludeArchived());
};
}, [dispatch]);
// Memoize the header children to prevent unnecessary re-renders
const headerChildren = useMemo(
() => (
<Button type="text" onClick={handleArchiveToggle}>
<Checkbox checked={includeArchivedProjects} />
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
</Button>
),
[handleArchiveToggle, includeArchivedProjects, t]
);
// Memoize the teams text to prevent unnecessary re-renders
const teamsText = useMemo(
() => (
<Typography.Text strong style={{ fontSize: 16 }}>
{t('teamsText')}
</Typography.Text>
),
[t]
);
return (
<Flex vertical gap={24}>
<CustomPageHeader
title={t('overviewTitle')}
children={
<Button type="text" onClick={handleArchiveToggle}>
<Checkbox checked={includeArchivedProjects} />
<Typography.Text>{t('includeArchivedButton')}</Typography.Text>
</Button>
}
/>
<CustomPageHeader title={t('overviewTitle')} children={headerChildren} />
<OverviewStats />
<Card>
<Flex vertical gap={12}>
<Typography.Text strong style={{ fontSize: 16 }}>
{t('teamsText')}
</Typography.Text>
{teamsText}
<OverviewReportsTable />
</Flex>
</Card>

View File

@@ -1,32 +1,155 @@
import { ReactNode } from 'react';
import { Card, Flex, Typography } from 'antd';
import { Card, Flex, Typography, theme } from 'antd';
import React, { useMemo } from 'react';
type InsightCardProps = {
icon: ReactNode;
interface InsightCardProps {
icon: React.ReactNode;
title: string;
children: ReactNode;
children: React.ReactNode;
loading?: boolean;
};
}
const OverviewStatCard = ({ icon, title, children, loading = false }: InsightCardProps) => {
return (
<Card
className="custom-insights-card"
style={{ width: '100%' }}
styles={{ body: { paddingInline: 16 } }}
loading={loading}
>
<Flex gap={16} align="flex-start">
{icon}
const OverviewStatCard = React.memo(
({ icon, title, children, loading = false }: InsightCardProps) => {
const { token } = theme.useToken();
// Better dark mode detection using multiple token properties
const isDarkMode =
token.colorBgContainer === '#1f1f1f' ||
token.colorBgBase === '#141414' ||
token.colorBgElevated === '#1f1f1f' ||
document.documentElement.getAttribute('data-theme') === 'dark' ||
document.body.classList.contains('dark');
<Flex vertical gap={12}>
<Typography.Text style={{ fontSize: 16 }}>{title}</Typography.Text>
// Memoize enhanced card styles with dark mode support
const cardStyles = useMemo(
() => ({
body: {
padding: '24px',
background: isDarkMode ? '#1f1f1f !important' : '#ffffff !important',
},
}),
[isDarkMode]
);
<>{children}</>
</Flex>
</Flex>
</Card>
);
};
// Memoize card container styles with dark mode support
const cardContainerStyle = useMemo(
() => ({
width: '100%',
borderRadius: '0px',
border: isDarkMode ? '1px solid #303030' : '1px solid #f0f0f0',
boxShadow: isDarkMode ? '0 2px 8px rgba(0, 0, 0, 0.3)' : '0 2px 8px rgba(0, 0, 0, 0.06)',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
overflow: 'hidden',
position: 'relative' as const,
cursor: 'default',
backgroundColor: isDarkMode ? '#1f1f1f !important' : '#ffffff !important',
}),
[isDarkMode]
);
// Memoize icon container styles with dark mode support
const iconContainerStyle = useMemo(
() => ({
padding: '12px',
borderRadius: '0px',
background: isDarkMode ? '#2a2a2a' : '#f8f9ff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minWidth: '64px',
minHeight: '64px',
boxShadow: isDarkMode
? '0 2px 4px rgba(24, 144, 255, 0.2)'
: '0 2px 4px rgba(24, 144, 255, 0.1)',
border: isDarkMode ? '1px solid #404040' : '1px solid rgba(24, 144, 255, 0.1)',
}),
[isDarkMode]
);
// Memoize title styles with dark mode support
const titleStyle = useMemo(
() => ({
fontSize: '18px',
fontWeight: 600,
color: isDarkMode ? '#ffffff !important' : '#262626 !important',
marginBottom: '8px',
lineHeight: '1.4',
}),
[isDarkMode]
);
// Memoize decorative element styles with dark mode support
const decorativeStyle = useMemo(
() => ({
position: 'absolute' as const,
top: 0,
right: 0,
width: '60px',
height: '60px',
background: isDarkMode
? 'linear-gradient(135deg, rgba(24, 144, 255, 0.15) 0%, rgba(24, 144, 255, 0.08) 100%)'
: 'linear-gradient(135deg, rgba(24, 144, 255, 0.05) 0%, rgba(24, 144, 255, 0.02) 100%)',
opacity: isDarkMode ? 0.8 : 0.6,
clipPath: 'polygon(100% 0%, 0% 100%, 100% 100%)',
}),
[isDarkMode]
);
return (
<div
className={`overview-stat-card ${isDarkMode ? 'dark-mode' : 'light-mode'}`}
style={{
backgroundColor: isDarkMode ? '#1f1f1f' : '#ffffff',
border: isDarkMode ? '1px solid #303030' : '1px solid #f0f0f0',
borderRadius: '0px',
boxShadow: isDarkMode ? '0 2px 8px rgba(0, 0, 0, 0.3)' : '0 2px 8px rgba(0, 0, 0, 0.06)',
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
overflow: 'hidden',
position: 'relative',
cursor: 'default',
width: '100%',
}}
>
<Card
style={{
backgroundColor: 'transparent',
border: 'none',
borderRadius: '0px',
}}
styles={{
body: {
padding: '24px',
backgroundColor: 'transparent',
},
}}
loading={loading}
>
<Flex gap={20} align="flex-start">
<div style={iconContainerStyle}>{icon}</div>
<Flex vertical gap={8} style={{ flex: 1, minWidth: 0 }}>
<Typography.Text style={titleStyle}>{title}</Typography.Text>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '6px',
marginTop: '4px',
}}
>
{children}
</div>
</Flex>
</Flex>
{/* Decorative element */}
<div style={decorativeStyle} />
</Card>
</div>
);
}
);
OverviewStatCard.displayName = 'OverviewStatCard';
export default OverviewStatCard;

View File

@@ -1,5 +1,5 @@
import { Flex, Typography } from 'antd';
import React, { useEffect, useState } from 'react';
import { Flex, Typography, theme } from 'antd';
import React, { useEffect, useState, useCallback, useMemo } from 'react';
import OverviewStatCard from './overview-stat-card';
import { BankOutlined, FileOutlined, UsergroupAddOutlined } from '@ant-design/icons';
import { colors } from '@/styles/colors';
@@ -12,11 +12,12 @@ const OverviewStats = () => {
const [stats, setStats] = useState<IRPTOverviewStatistics>({});
const [loading, setLoading] = useState(false);
const { t } = useTranslation('reporting-overview');
const { token } = theme.useToken();
const includeArchivedProjects = useAppSelector(
state => state.reportingReducer.includeArchivedProjects
);
const getOverviewStats = async () => {
const getOverviewStats = useCallback(async () => {
setLoading(true);
try {
const { done, body } =
@@ -29,104 +30,166 @@ const OverviewStats = () => {
} finally {
setLoading(false);
}
};
}, [includeArchivedProjects]);
useEffect(() => {
getOverviewStats();
}, [includeArchivedProjects]);
}, [getOverviewStats]);
const renderStatText = (count: number = 0, singularKey: string, pluralKey: string) => {
return `${count} ${count === 1 ? t(singularKey) : t(pluralKey)}`;
};
const renderStatText = useCallback(
(count: number = 0, singularKey: string, pluralKey: string) => {
return `${count} ${count === 1 ? t(singularKey) : t(pluralKey)}`;
},
[t]
);
const renderStatCard = (
icon: React.ReactNode,
mainCount: number = 0,
mainKey: string,
stats: { text: string; type?: 'secondary' | 'danger' }[]
) => (
<OverviewStatCard
icon={icon}
title={renderStatText(mainCount, mainKey, `${mainKey}Plural`)}
loading={loading}
>
<Flex vertical>
{stats.map((stat, index) => (
<Typography.Text key={index} type={stat.type}>
{stat.text}
</Typography.Text>
))}
</Flex>
</OverviewStatCard>
const renderStatCard = useCallback(
(
icon: React.ReactNode,
mainCount: number = 0,
mainKey: string,
stats: { text: string; type?: 'secondary' | 'danger' }[]
) => (
<OverviewStatCard
icon={icon}
title={renderStatText(mainCount, mainKey, `${mainKey}Plural`)}
loading={loading}
>
<Flex vertical>
{stats.map((stat, index) => (
<Typography.Text
key={index}
type={stat.type}
style={{
fontSize: '14px',
lineHeight: '1.5',
color:
stat.type === 'danger'
? '#ff4d4f'
: stat.type === 'secondary'
? token.colorTextSecondary
: token.colorText,
}}
>
{stat.text}
</Typography.Text>
))}
</Flex>
</OverviewStatCard>
),
[renderStatText, loading, token]
);
// Memoize team stats to prevent unnecessary recalculations
const teamStats = useMemo(
() => [
{
text: renderStatText(stats?.teams?.projects, 'projectCount', 'projectCountPlural'),
type: 'secondary' as const,
},
{
text: renderStatText(stats?.teams?.members, 'memberCount', 'memberCountPlural'),
type: 'secondary' as const,
},
],
[stats?.teams?.projects, stats?.teams?.members, renderStatText]
);
// Memoize project stats to prevent unnecessary recalculations
const projectStats = useMemo(
() => [
{
text: renderStatText(
stats?.projects?.active,
'activeProjectCount',
'activeProjectCountPlural'
),
type: 'secondary' as const,
},
{
text: renderStatText(
stats?.projects?.overdue,
'overdueProjectCount',
'overdueProjectCountPlural'
),
type: 'danger' as const,
},
],
[stats?.projects?.active, stats?.projects?.overdue, renderStatText]
);
// Memoize member stats to prevent unnecessary recalculations
const memberStats = useMemo(
() => [
{
text: renderStatText(
stats?.members?.unassigned,
'unassignedMemberCount',
'unassignedMemberCountPlural'
),
type: 'secondary' as const,
},
{
text: renderStatText(
stats?.members?.overdue,
'memberWithOverdueTaskCount',
'memberWithOverdueTaskCountPlural'
),
type: 'danger' as const,
},
],
[stats?.members?.unassigned, stats?.members?.overdue, renderStatText]
);
// Memoize icons with enhanced styling for better visibility
const teamIcon = useMemo(
() => (
<BankOutlined
style={{
color: colors.skyBlue,
fontSize: 42,
filter: 'drop-shadow(0 2px 4px rgba(24, 144, 255, 0.3))',
}}
/>
),
[]
);
const projectIcon = useMemo(
() => (
<FileOutlined
style={{
color: colors.limeGreen,
fontSize: 42,
filter: 'drop-shadow(0 2px 4px rgba(82, 196, 26, 0.3))',
}}
/>
),
[]
);
const memberIcon = useMemo(
() => (
<UsergroupAddOutlined
style={{
color: colors.lightGray,
fontSize: 42,
filter: 'drop-shadow(0 2px 4px rgba(112, 112, 112, 0.3))',
}}
/>
),
[]
);
return (
<Flex gap={24}>
{renderStatCard(
<BankOutlined style={{ color: colors.skyBlue, fontSize: 42 }} />,
stats?.teams?.count,
'teamCount',
[
{
text: renderStatText(stats?.teams?.projects, 'projectCount', 'projectCountPlural'),
type: 'secondary',
},
{
text: renderStatText(stats?.teams?.members, 'memberCount', 'memberCountPlural'),
type: 'secondary',
},
]
)}
{renderStatCard(teamIcon, stats?.teams?.count, 'teamCount', teamStats)}
{renderStatCard(
<FileOutlined style={{ color: colors.limeGreen, fontSize: 42 }} />,
stats?.projects?.count,
'projectCount',
[
{
text: renderStatText(
stats?.projects?.active,
'activeProjectCount',
'activeProjectCountPlural'
),
type: 'secondary',
},
{
text: renderStatText(
stats?.projects?.overdue,
'overdueProjectCount',
'overdueProjectCountPlural'
),
type: 'danger',
},
]
)}
{renderStatCard(projectIcon, stats?.projects?.count, 'projectCount', projectStats)}
{renderStatCard(
<UsergroupAddOutlined style={{ color: colors.lightGray, fontSize: 42 }} />,
stats?.members?.count,
'memberCount',
[
{
text: renderStatText(
stats?.members?.unassigned,
'unassignedMemberCount',
'unassignedMemberCountPlural'
),
type: 'secondary',
},
{
text: renderStatText(
stats?.members?.overdue,
'memberWithOverdueTaskCount',
'memberWithOverdueTaskCountPlural'
),
type: 'danger',
},
]
)}
{renderStatCard(memberIcon, stats?.members?.count, 'memberCount', memberStats)}
</Flex>
);
};
export default OverviewStats;
export default React.memo(OverviewStats);

View File

@@ -1,4 +1,4 @@
import { memo, useEffect, useState } from 'react';
import { memo, useEffect, useState, useCallback, useMemo } from 'react';
import { ConfigProvider, Table, TableColumnsType } from 'antd';
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
import CustomTableTitle from '../../../../components/CustomTableTitle';
@@ -11,7 +11,7 @@ import Avatars from '@/components/avatars/avatars';
import OverviewTeamInfoDrawer from '@/components/reporting/drawers/overview-team-info/overview-team-info-drawer';
import { toggleOverViewTeamDrawer } from '@/features/reporting/reporting.slice';
const OverviewReportsTable = () => {
const OverviewReportsTable = memo(() => {
const { t } = useTranslation('reporting-overview');
const dispatch = useAppDispatch();
@@ -22,7 +22,7 @@ const OverviewReportsTable = () => {
const [teams, setTeams] = useState<IRPTTeam[]>([]);
const [loading, setLoading] = useState(false);
const getTeams = async () => {
const getTeams = useCallback(async () => {
setLoading(true);
try {
const { done, body } = await reportingApiService.getOverviewTeams(includeArchivedProjects);
@@ -34,66 +34,85 @@ const OverviewReportsTable = () => {
} finally {
setLoading(false);
}
};
}, [includeArchivedProjects]);
useEffect(() => {
getTeams();
}, [includeArchivedProjects]);
}, [getTeams]);
const handleDrawerOpen = (team: IRPTTeam) => {
setSelectedTeam(team);
dispatch(toggleOverViewTeamDrawer());
};
const handleDrawerOpen = useCallback(
(team: IRPTTeam) => {
setSelectedTeam(team);
dispatch(toggleOverViewTeamDrawer());
},
[dispatch]
);
const columns: TableColumnsType = [
{
key: 'name',
title: <CustomTableTitle title={t('nameColumn')} />,
className: 'group-hover:text-[#1890ff]',
dataIndex: 'name',
},
{
key: 'projects',
title: <CustomTableTitle title={t('projectsColumn')} />,
className: 'group-hover:text-[#1890ff]',
dataIndex: 'projects_count',
},
{
key: 'members',
title: <CustomTableTitle title={t('membersColumn')} />,
render: record => <Avatars members={record.members} maxCount={3} />,
},
];
// Memoize table columns to prevent recreation on every render
const columns: TableColumnsType<IRPTTeam> = useMemo(
() => [
{
key: 'name',
title: <CustomTableTitle title={t('nameColumn')} />,
className: 'group-hover:text-[#1890ff]',
dataIndex: 'name',
},
{
key: 'projects',
title: <CustomTableTitle title={t('projectsColumn')} />,
className: 'group-hover:text-[#1890ff]',
dataIndex: 'projects_count',
},
{
key: 'members',
title: <CustomTableTitle title={t('membersColumn')} />,
render: (record: IRPTTeam) => <Avatars members={record.members} maxCount={3} />,
},
],
[t]
);
return (
<ConfigProvider
theme={{
// Memoize table configuration
const tableConfig = useMemo(
() => ({
theme: {
components: {
Table: {
cellPaddingBlock: 8,
cellPaddingInline: 10,
},
},
}}
>
},
}),
[]
);
// Memoize row props generator
const getRowProps = useCallback(
(record: IRPTTeam) => ({
onClick: () => handleDrawerOpen(record),
style: { height: 48, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
}),
[handleDrawerOpen]
);
return (
<ConfigProvider {...tableConfig}>
<Table
columns={columns}
dataSource={teams}
scroll={{ x: 'max-content' }}
rowKey={record => record.id}
loading={loading}
onRow={record => {
return {
onClick: () => handleDrawerOpen(record as IRPTTeam),
style: { height: 48, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
};
}}
onRow={getRowProps}
/>
<OverviewTeamInfoDrawer team={selectedTeam} />
</ConfigProvider>
);
};
});
export default memo(OverviewReportsTable);
OverviewReportsTable.displayName = 'OverviewReportsTable';
export default OverviewReportsTable;

View File

@@ -48,7 +48,6 @@ const ProjectCategoriesFilterDropdown = () => {
// Add filtered categories memo
const filteredCategories = useMemo(() => {
if (!searchQuery.trim()) return orgCategories;
return orgCategories.filter(category =>
@@ -92,22 +91,22 @@ const ProjectCategoriesFilterDropdown = () => {
{filteredCategories.length ? (
filteredCategories.map(category => (
<List.Item
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
key={category.id}
style={{
display: 'flex',
justifyContent: 'flex-start',
gap: 8,
padding: '4px 8px',
border: 'none',
}}
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
key={category.id}
style={{
display: 'flex',
justifyContent: 'flex-start',
gap: 8,
padding: '4px 8px',
border: 'none',
}}
>
<Checkbox id={category.id} onChange={() => handleCategoryChange(category)}>
<Flex gap={8}>
<Badge color={category.color_code} />
{category.name}
</Flex>
</Checkbox>
<Checkbox id={category.id} onChange={() => handleCategoryChange(category)}>
<Flex gap={8}>
<Badge color={category.color_code} />
{category.name}
</Flex>
</Checkbox>
</List.Item>
))
) : (
@@ -129,10 +128,11 @@ const ProjectCategoriesFilterDropdown = () => {
icon={<CaretDownFilled />}
iconPosition="end"
loading={projectCategoriesLoading}
className={`transition-colors duration-300 ${isDropdownOpen
className={`transition-colors duration-300 ${
isDropdownOpen
? 'border-[#1890ff] text-[#1890ff]'
: 'hover:text-[#1890ff hover:border-[#1890ff]'
}`}
}`}
>
{t('categoryText')}
</Button>

View File

@@ -1,5 +1,8 @@
import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/projectHealthSlice';
import { fetchProjectData, setSelectedProjectHealths } from '@/features/reporting/projectReports/project-reports-slice';
import {
fetchProjectData,
setSelectedProjectHealths,
} from '@/features/reporting/projectReports/project-reports-slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IProjectHealth } from '@/types/project/projectHealth.types';
@@ -23,22 +26,19 @@ const ProjectHealthFilterDropdown = () => {
state => state.projectReportsReducer
);
useEffect(() => {
setSelectedHealths(selectedProjectHealths);
}, [selectedProjectHealths]);
useEffect(() => {
if (!projectHealthsLoading) dispatch(fetchProjectHealth());
}, [dispatch]);
const debouncedUpdate = useCallback(
debounce((healths: IProjectHealth[]) => {
dispatch(setSelectedProjectHealths(healths));
dispatch(fetchProjectData());
}, 300),
dispatch(fetchProjectData());
}, 300),
[dispatch]
);
@@ -52,8 +52,8 @@ const ProjectHealthFilterDropdown = () => {
updatedHealths = [...selectedHealths, health];
}
setSelectedHealths(updatedHealths);
debouncedUpdate(updatedHealths);
setSelectedHealths(updatedHealths);
debouncedUpdate(updatedHealths);
};
const projectHealthDropdownContent = (
@@ -75,7 +75,7 @@ const ProjectHealthFilterDropdown = () => {
id={item.id}
checked={selectedHealths.some(h => h.id === item.id)}
onChange={() => handleHealthChange(item)}
disabled={projectLoading}
disabled={projectLoading}
>
{item.name}
</Checkbox>
@@ -96,7 +96,7 @@ const ProjectHealthFilterDropdown = () => {
<Button
icon={<CaretDownFilled />}
iconPosition="end"
loading={projectHealthsLoading}
loading={projectHealthsLoading}
className={`transition-colors duration-300 ${
isDropdownOpen
? 'border-[#1890ff] text-[#1890ff]'
@@ -109,4 +109,4 @@ const ProjectHealthFilterDropdown = () => {
);
};
export default ProjectHealthFilterDropdown;
export default ProjectHealthFilterDropdown;

View File

@@ -43,8 +43,8 @@ const ProjectManagersFilterDropdown = () => {
useEffect(() => {
if (!projectManagersLoading) dispatch(fetchProjectManagers());
}, [dispatch]);
}, [dispatch]);
const projectManagerDropdownContent = (
<Card className="custom-card" styles={{ body: { padding: 8, width: 260 } }}>
<Flex vertical gap={8}>

View File

@@ -1,4 +1,5 @@
import { Flex } from 'antd';
import { useMemo, useCallback, memo } from 'react';
import { useTranslation } from 'react-i18next';
import ProjectStatusFilterDropdown from './project-status-filter-dropdown';
import ProjectHealthFilterDropdown from './project-health-filter-dropdown';
@@ -15,26 +16,48 @@ const ProjectsReportsFilters = () => {
const { t } = useTranslation('reporting-projects-filters');
const { searchQuery } = useAppSelector(state => state.projectReportsReducer);
return (
<Flex gap={8} align="center" justify="space-between">
// Memoize the search query handler to prevent recreation on every render
const handleSearchQueryChange = useCallback(
(text: string) => {
dispatch(setSearchQuery(text));
},
[dispatch]
);
// Memoize the filter dropdowns container to prevent recreation on every render
const filterDropdowns = useMemo(
() => (
<Flex gap={8} wrap={'wrap'}>
<ProjectStatusFilterDropdown />
<ProjectHealthFilterDropdown />
<ProjectCategoriesFilterDropdown />
<ProjectManagersFilterDropdown />
</Flex>
),
[]
);
// Memoize the right side controls to prevent recreation on every render
const rightControls = useMemo(
() => (
<Flex gap={12}>
<ProjectTableShowFieldsDropdown />
<CustomSearchbar
placeholderText={t('searchByNamePlaceholder')}
searchQuery={searchQuery}
setSearchQuery={text => dispatch(setSearchQuery(text))}
setSearchQuery={handleSearchQueryChange}
/>
</Flex>
),
[t, searchQuery, handleSearchQueryChange]
);
return (
<Flex gap={8} align="center" justify="space-between">
{filterDropdowns}
{rightControls}
</Flex>
);
};
export default ProjectsReportsFilters;
export default memo(ProjectsReportsFilters);

View File

@@ -1,5 +1,8 @@
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
import { fetchProjectData, setSelectedProjectStatuses } from '@/features/reporting/projectReports/project-reports-slice';
import {
fetchProjectData,
setSelectedProjectStatuses,
} from '@/features/reporting/projectReports/project-reports-slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IProjectStatus } from '@/types/project/projectStatus.types';
@@ -30,7 +33,7 @@ const ProjectStatusFilterDropdown = () => {
const debouncedUpdate = useCallback(
debounce((statuses: IProjectStatus[]) => {
dispatch(setSelectedProjectStatuses(statuses));
dispatch(fetchProjectData());
dispatch(fetchProjectData());
}, 300),
[dispatch]
);
@@ -45,7 +48,7 @@ const ProjectStatusFilterDropdown = () => {
updatedStatuses = [...selectedStatuses, status];
}
setSelectedStatuses(updatedStatuses);
setSelectedStatuses(updatedStatuses);
debouncedUpdate(updatedStatuses);
};
@@ -65,11 +68,7 @@ const ProjectStatusFilterDropdown = () => {
}}
>
<Space>
<Checkbox
id={item.id}
key={item.id}
onChange={e => handleProjectStatusClick(item)}
>
<Checkbox id={item.id} key={item.id} onChange={e => handleProjectStatusClick(item)}>
{item.name}
</Checkbox>
</Space>

View File

@@ -35,11 +35,7 @@ const ProjectTableShowFieldsDropdown = () => {
};
return (
<Dropdown
menu={menuItems}
trigger={['click']}
onOpenChange={open => setIsDropdownOpen(open)}
>
<Dropdown menu={menuItems} trigger={['click']} onOpenChange={open => setIsDropdownOpen(open)}>
<Button
icon={<MoreOutlined />}
className={`transition-colors duration-300 ${

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useMemo } from 'react';
import { useEffect, useState, useMemo, useCallback, memo } from 'react';
import { Button, ConfigProvider, Flex, PaginationProps, Table, TableColumnsType } from 'antd';
import { useTranslation } from 'react-i18next';
import { createPortal } from 'react-dom';
@@ -63,10 +63,14 @@ const ProjectsReportsTable = () => {
const columnsVisibility = useAppSelector(state => state.projectReportsTableColumnsReducer);
const handleDrawerOpen = (record: IRPTProject) => {
setSelectedProject(record);
dispatch(toggleProjectReportsDrawer());
};
// Memoize the drawer open handler to prevent recreation on every render
const handleDrawerOpen = useCallback(
(record: IRPTProject) => {
setSelectedProject(record);
dispatch(toggleProjectReportsDrawer());
},
[dispatch]
);
const columns: TableColumnsType<IRPTProject> = useMemo(
() => [
@@ -231,7 +235,7 @@ const ProjectsReportsTable = () => {
width: 200,
},
],
[t, order]
[t, order, handleDrawerOpen]
);
// filter columns based on the `hidden` state from Redux
@@ -240,12 +244,16 @@ const ProjectsReportsTable = () => {
[columns, columnsVisibility]
);
const handleTableChange = (pagination: PaginationProps, filters: any, sorter: any) => {
if (sorter.order) dispatch(setOrder(sorter.order));
if (sorter.field) dispatch(setField(sorter.field));
dispatch(setIndex(pagination.current));
dispatch(setPageSize(pagination.pageSize));
};
// Memoize the table change handler to prevent recreation on every render
const handleTableChange = useCallback(
(pagination: PaginationProps, filters: any, sorter: any) => {
if (sorter.order) dispatch(setOrder(sorter.order));
if (sorter.field) dispatch(setField(sorter.field));
dispatch(setIndex(pagination.current));
dispatch(setPageSize(pagination.pageSize));
},
[dispatch]
);
useEffect(() => {
if (!isLoading) dispatch(fetchProjectData());
@@ -268,7 +276,7 @@ const ProjectsReportsTable = () => {
return () => {
dispatch(resetProjectReports());
};
}, []);
}, [dispatch]);
const tableRowProps = useMemo(
() => ({
@@ -292,27 +300,42 @@ const ProjectsReportsTable = () => {
[]
);
// Memoize pagination configuration to prevent recreation on every render
const paginationConfig = useMemo(
() => ({
showSizeChanger: true,
defaultPageSize: 10,
total: total,
current: index,
pageSizeOptions: PAGE_SIZE_OPTIONS,
}),
[total, index]
);
// Memoize scroll configuration to prevent recreation on every render
const scrollConfig = useMemo(() => ({ x: 'max-content' }), []);
// Memoize row key function to prevent recreation on every render
const getRowKey = useCallback((record: IRPTProject) => record.id, []);
// Memoize onRow function to prevent recreation on every render
const getRowProps = useCallback(() => tableRowProps, [tableRowProps]);
return (
<ConfigProvider {...tableConfig}>
<Table
columns={visibleColumns}
dataSource={projectList}
pagination={{
showSizeChanger: true,
defaultPageSize: 10,
total: total,
current: index,
pageSizeOptions: PAGE_SIZE_OPTIONS,
}}
scroll={{ x: 'max-content' }}
pagination={paginationConfig}
scroll={scrollConfig}
loading={isLoading}
onChange={handleTableChange}
rowKey={record => record.id}
onRow={() => tableRowProps}
rowKey={getRowKey}
onRow={getRowProps}
/>
{createPortal(<ProjectReportsDrawer selectedProject={selectedProject} />, document.body)}
</ConfigProvider>
);
};
export default ProjectsReportsTable;
export default memo(ProjectsReportsTable);

View File

@@ -58,20 +58,20 @@ const ProjectCategoryCell = ({ id, name, color_code, projectId }: ProjectCategor
</Typography.Text>
),
}));
// handle category select
const onClick: MenuProps['onClick'] = e => {
const newCategory = filteredCategoriesData.find(category => category.id === e.key);
if (newCategory && connected && socket) {
// Update local state immediately
setSelectedCategory(newCategory);
// Emit socket event
socket.emit(
SocketEvents.PROJECT_CATEGORY_CHANGE.toString(),
JSON.stringify({
project_id: projectId,
category_id: newCategory.id
category_id: newCategory.id,
})
);
}
@@ -133,12 +133,14 @@ const ProjectCategoryCell = ({ id, name, color_code, projectId }: ProjectCategor
if (parsedData && parsedData.project_id === projectId) {
// Update local state
setSelectedCategory(parsedData.category);
// Update redux store
dispatch(updateProjectCategory({
projectId: parsedData.project_id,
category: parsedData.category
}));
dispatch(
updateProjectCategory({
projectId: parsedData.project_id,
category: parsedData.category,
})
);
}
} catch (error) {
console.error('Error handling category change response:', error);
@@ -200,7 +202,7 @@ const ProjectCategoryCell = ({ id, name, color_code, projectId }: ProjectCategor
// Action creator for updating project category
const updateProjectCategory = (payload: { projectId: string; category: IProjectCategory }) => ({
type: 'projects/updateCategory',
payload
payload,
});
export default ProjectCategoryCell;

View File

@@ -5,7 +5,10 @@ import dayjs, { Dayjs } from 'dayjs';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setProjectEndDate, setProjectStartDate } from '@/features/reporting/projectReports/project-reports-slice';
import {
setProjectEndDate,
setProjectStartDate,
} from '@/features/reporting/projectReports/project-reports-slice';
import logger from '@/utils/errorLogger';
type ProjectDatesCellProps = {
@@ -41,10 +44,13 @@ const ProjectDatesCell = ({ projectId, startDate, endDate }: ProjectDatesCellPro
if (!socket) {
throw new Error('Socket connection not available');
}
socket.emit(SocketEvents.PROJECT_START_DATE_CHANGE.toString(), JSON.stringify({
project_id: projectId,
start_date: date?.format('YYYY-MM-DD'),
}));
socket.emit(
SocketEvents.PROJECT_START_DATE_CHANGE.toString(),
JSON.stringify({
project_id: projectId,
start_date: date?.format('YYYY-MM-DD'),
})
);
} catch (error) {
logger.error('Error sending start date change:', error);
}
@@ -55,10 +61,13 @@ const ProjectDatesCell = ({ projectId, startDate, endDate }: ProjectDatesCellPro
if (!socket) {
throw new Error('Socket connection not available');
}
socket.emit(SocketEvents.PROJECT_END_DATE_CHANGE.toString(), JSON.stringify({
project_id: projectId,
end_date: date?.format('YYYY-MM-DD'),
}));
socket.emit(
SocketEvents.PROJECT_END_DATE_CHANGE.toString(),
JSON.stringify({
project_id: projectId,
end_date: date?.format('YYYY-MM-DD'),
})
);
} catch (error) {
logger.error('Error sending end date change:', error);
}
@@ -70,8 +79,14 @@ const ProjectDatesCell = ({ projectId, startDate, endDate }: ProjectDatesCellPro
socket.on(SocketEvents.PROJECT_END_DATE_CHANGE.toString(), handleEndDateChangeResponse);
return () => {
socket.removeListener(SocketEvents.PROJECT_START_DATE_CHANGE.toString(), handleStartDateChangeResponse);
socket.removeListener(SocketEvents.PROJECT_END_DATE_CHANGE.toString(), handleEndDateChangeResponse);
socket.removeListener(
SocketEvents.PROJECT_START_DATE_CHANGE.toString(),
handleStartDateChangeResponse
);
socket.removeListener(
SocketEvents.PROJECT_END_DATE_CHANGE.toString(),
handleEndDateChangeResponse
);
};
}
}, [connected, socket]);

View File

@@ -47,10 +47,13 @@ const ProjectHealthCell = ({ value, label, color, projectId }: HealthStatusDataT
const onClick: MenuProps['onClick'] = e => {
if (!e.key || !projectId) return;
socket?.emit(SocketEvents.PROJECT_HEALTH_CHANGE.toString(), JSON.stringify({
project_id: projectId,
health_id: e.key,
}));
socket?.emit(
SocketEvents.PROJECT_HEALTH_CHANGE.toString(),
JSON.stringify({
project_id: projectId,
health_id: e.key,
})
);
};
// dropdown items

View File

@@ -35,7 +35,7 @@ const ProjectStatusCell = ({ currentStatus, projectId }: ProjectStatusCellProps)
{getStatusIcon(status.icon || '', status.color_code || '')}
{t(`${status.name}`)}
</Typography.Text>
)
),
}));
const handleStatusChange = (value: string) => {

View File

@@ -12,7 +12,7 @@ const ProjectUpdateCell = ({ updates }: ProjectUpdateCellProps) => {
ellipsis={{ expanded: false }}
className="group-hover:text-[#1890ff]"
>
<div dangerouslySetInnerHTML={{__html: updates}} />
<div dangerouslySetInnerHTML={{ __html: updates }} />
</Typography.Text>
);
};

Some files were not shown because too many files have changed in this diff Show More