Merge branch 'release-v2.1.4' of https://github.com/Worklenz/worklenz into feature/team-utilization

This commit is contained in:
chamikaJ
2025-07-30 12:56:56 +05:30
173 changed files with 12856 additions and 1582 deletions

View File

@@ -0,0 +1,8 @@
import React from 'react';
import { AdvancedGanttDemo } from '../components/advanced-gantt';
const GanttDemoPage: React.FC = () => {
return <AdvancedGanttDemo />;
};
export default GanttDemoPage;

View File

@@ -1,5 +1,9 @@
/* Account Setup Page Styles */
/* Steps styling - using Ant Design theme tokens */
.ant-steps-item-finish .ant-steps-item-icon {
border-color: #1890ff !important;
border-color: var(--ant-color-primary) !important;
background-color: var(--ant-color-primary) !important;
}
.ant-steps-item-icon {
@@ -9,37 +13,53 @@
font-size: 16px !important;
line-height: 32px !important;
text-align: center !important;
border: 1px solid rgba(0, 0, 0, 0.25) !important;
border: 1px solid var(--ant-color-border) !important;
border-radius: 32px !important;
transition:
background-color 0.3s,
border-color 0.3s !important;
transition: all 0.3s !important;
background-color: var(--ant-color-bg-container) !important;
color: var(--ant-color-text) !important;
}
.ant-steps-item-wait .ant-steps-item-icon {
cursor: not-allowed;
opacity: 0.6;
}
.dark-mode .ant-steps-item-wait .ant-steps-item-icon {
cursor: not-allowed;
.ant-steps-item-process .ant-steps-item-icon {
border-color: var(--ant-color-primary) !important;
background-color: var(--ant-color-primary) !important;
color: var(--ant-color-white) !important;
}
.progress-steps .ant-steps-item.ant-steps-item-process .ant-steps-item-title::after {
background-color: #1677ff !important;
background-color: var(--ant-color-primary) !important;
width: 60px !important;
}
.ant-steps-item-title {
color: var(--ant-color-text) !important;
}
.ant-steps-item-description {
color: var(--ant-color-text-secondary) !important;
}
/* Responsive layout */
@media (max-width: 1000px) {
.progress-steps {
width: 400px !important;
}
.step {
width: 400px !important;
}
.progress-steps,
.step,
.step-content {
width: 400px !important;
width: 90% !important;
max-width: 500px !important;
}
}
@media (max-width: 768px) {
.progress-steps,
.step,
.step-content {
width: 95% !important;
max-width: 400px !important;
}
}
@@ -53,98 +73,93 @@
}
.step-content {
width: 200px !important;
width: 100% !important;
max-width: 300px !important;
padding: 0 1rem !important;
}
}
@media (max-width: 1000px) {
.organization-name-form {
width: 400px !important;
/* Tasks step specific styles */
.tasks-step .task-item-card {
transition: all 0.3s ease;
}
.tasks-step .task-item-card:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.tasks-step .task-input {
font-size: 16px;
}
.tasks-step .task-input:focus {
box-shadow: none !important;
}
/* Project step specific styles */
.project-step .ant-input-affix-wrapper {
transition: all 0.3s ease;
}
.project-step .ant-input-affix-wrapper:focus-within {
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
}
.project-suggestion-button {
transition: all 0.2s ease;
cursor: pointer;
}
.project-suggestion-button:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.project-suggestion-button:active {
transform: translateY(0);
}
/* Organization step specific styles */
.organization-step .ant-input-affix-wrapper {
transition: all 0.3s ease;
}
.organization-step .ant-input-affix-wrapper:focus-within {
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.1);
border-color: #1890ff;
}
.organization-suggestion-button {
transition: all 0.2s ease;
cursor: pointer;
}
.organization-suggestion-button:hover {
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.organization-suggestion-button:active {
transform: translateY(0);
}
/* Survey step animations */
.survey-page-transition {
animation: fadeInUp 0.4s ease-out;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 500px) {
.organization-name-form {
width: 200px !important;
}
}
.vert-text {
max-width: 40px;
background-color: #fff;
position: relative;
z-index: 99;
margin-left: auto;
margin-right: auto;
text-align: center;
justify-content: center;
margin-top: 2rem;
}
.vert-text-dark {
max-width: 40px;
background-color: #141414;
position: relative;
z-index: 99;
margin-left: auto;
margin-right: auto;
text-align: center;
justify-content: center;
margin-top: 2rem;
}
.vert-line {
position: absolute;
left: 0;
right: 0;
width: 100%;
content: "";
height: 2px;
background-color: #00000047;
bottom: 0;
top: 0;
margin-bottom: auto;
margin-top: auto;
}
.vert-line-dark {
position: absolute;
left: 0;
right: 0;
width: 100%;
content: "";
height: 2px;
background-color: white;
bottom: 0;
top: 0;
margin-bottom: auto;
margin-top: auto;
}
@media (max-width: 1000px) {
.first-project-form {
width: 400px !important;
}
}
@media (max-width: 500px) {
.first-project-form {
width: 200px !important;
}
}
.custom-close-button:hover {
background-color: transparent !important;
}
@media (max-width: 1000px) {
.create-first-task-form {
width: 400px !important;
}
}
@media (max-width: 500px) {
.create-first-task-form {
width: 200px !important;
}
}
/* Theme transitions */
* {
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
}

View File

@@ -2,13 +2,15 @@ import React, { useEffect } from 'react';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Space, Steps, Button, Typography } from 'antd/es';
import { Space, Steps, Button, Typography, theme, Dropdown, MenuProps } from '@/shared/antd-imports';
import { GlobalOutlined, MoonOutlined, SunOutlined } from '@/shared/antd-imports';
import logger from '@/utils/errorLogger';
import { setCurrentStep } from '@/features/account-setup/account-setup.slice';
import { setCurrentStep, setSurveySubStep } from '@/features/account-setup/account-setup.slice';
import { OrganizationStep } from '@/components/account-setup/organization-step';
import { ProjectStep } from '@/components/account-setup/project-step';
import { TasksStep } from '@/components/account-setup/tasks-step';
import { SurveyStep } from '@/components/account-setup/survey-step';
import MembersStep from '@/components/account-setup/members-step';
import {
evt_account_setup_complete,
@@ -31,23 +33,110 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
import './account-setup.css';
import { IAccountSetupRequest } from '@/types/project-templates/project-templates.types';
import { profileSettingsApiService } from '@/api/settings/profile/profile-settings.api.service';
import { projectTemplatesApiService } from '@/api/project-templates/project-templates.api.service';
import { surveyApiService } from '@/api/survey/survey.api.service';
import { ISurveySubmissionRequest, ISurveyAnswer } from '@/types/account-setup/survey.types';
import { setLanguage } from '@/features/i18n/localesSlice';
import { ILanguageType, Language } from '@/features/i18n/localesSlice';
import { toggleTheme } from '@/features/theme/themeSlice';
const { Title } = Typography;
// Simplified styles for form components using Ant Design theme tokens
const getAccountSetupStyles = (token: any) => ({
form: {
width: '100%',
maxWidth: '600px',
},
label: {
color: token.colorText,
fontWeight: 500,
},
buttonContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginTop: '1rem',
},
drawerFooter: {
display: 'flex',
justifyContent: 'right',
padding: '10px 16px',
},
});
const AccountSetup: React.FC = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('account-setup');
const { t, i18n } = useTranslation('account-setup');
useDocumentTitle(t('setupYourAccount', 'Account Setup'));
const navigate = useNavigate();
const { trackMixpanelEvent } = useMixpanelTracking();
const { token } = theme.useToken();
const { currentStep, organizationName, projectName, templateId, tasks, teamMembers } =
const { currentStep, organizationName, projectName, templateId, tasks, teamMembers, surveyData, surveySubStep } =
useSelector((state: RootState) => state.accountSetupReducer);
const lng = useSelector((state: RootState) => state.localesReducer.lng);
const userDetails = getUserSession();
const themeMode = useSelector((state: RootState) => state.themeReducer.mode);
const [surveyId, setSurveyId] = React.useState<string | null>(null);
const [isSkipping, setIsSkipping] = React.useState(false);
const isDarkMode = themeMode === 'dark';
const organizationNamePlaceholder = userDetails?.name ? `e.g. ${userDetails?.name}'s Team` : '';
// Helper to extract organization name from email or fallback to user name
function getOrganizationNamePlaceholder(userDetails: { email?: string; name?: string } | null): string {
if (!userDetails) return '';
const email = userDetails.email || '';
const name = userDetails.name || '';
if (email) {
const match = email.match(/^([^@]+)@([^@]+)$/);
if (match) {
const domain = match[2].toLowerCase();
// List of common public email providers
const publicProviders = [
'gmail.com', 'yahoo.com', 'outlook.com', 'hotmail.com', 'icloud.com', 'aol.com', 'protonmail.com', 'zoho.com', 'gmx.com', 'mail.com', 'yandex.com', 'msn.com', 'live.com', 'me.com', 'comcast.net', 'rediffmail.com', 'ymail.com', 'rocketmail.com', 'inbox.com', 'mail.ru', 'qq.com', 'naver.com', '163.com', '126.com', 'sina.com', 'yeah.net', 'googlemail.com', 'fastmail.com', 'hushmail.com', 'tutanota.com', 'pm.me', 'mailbox.org', 'proton.me'
];
if (!publicProviders.includes(domain)) {
// Use the first part of the domain (before the first dot)
const org = domain.split('.')[0];
if (org && org.length > 1) {
return `e.g. ${org.charAt(0).toUpperCase() + org.slice(1)} Team`;
}
}
}
}
// Fallback to user name
return name ? `e.g. ${name}'s Team` : '';
}
const organizationNamePlaceholder = getOrganizationNamePlaceholder(userDetails);
// Helper to extract organization name from email or fallback to user name
function getOrganizationNameInitialValue(userDetails: { email?: string; name?: string } | null): string {
if (!userDetails) return '';
const email = userDetails.email || '';
const name = userDetails.name || '';
if (email) {
const match = email.match(/^([^@]+)@([^@]+)$/);
if (match) {
const domain = match[2].toLowerCase();
const publicProviders = [
'gmail.com', 'yahoo.com', 'outlook.com', 'hotmail.com', 'icloud.com', 'aol.com', 'protonmail.com', 'zoho.com', 'gmx.com', 'mail.com', 'yandex.com', 'msn.com', 'live.com', 'me.com', 'comcast.net', 'rediffmail.com', 'ymail.com', 'rocketmail.com', 'inbox.com', 'mail.ru', 'qq.com', 'naver.com', '163.com', '126.com', 'sina.com', 'yeah.net', 'googlemail.com', 'fastmail.com', 'hushmail.com', 'tutanota.com', 'pm.me', 'mailbox.org', 'proton.me'
];
if (!publicProviders.includes(domain)) {
const org = domain.split('.')[0];
if (org && org.length > 1) {
return org.charAt(0).toUpperCase() + org.slice(1);
}
}
}
}
return name || '';
}
const organizationNameInitialValue = getOrganizationNameInitialValue(userDetails);
const styles = getAccountSetupStyles(token);
useEffect(() => {
trackMixpanelEvent(evt_account_setup_visit);
@@ -65,88 +154,25 @@ const AccountSetup: React.FC = () => {
logger.error('Failed to verify authentication status', error);
}
};
const loadSurvey = async () => {
try {
const response = await surveyApiService.getAccountSetupSurvey();
if (response.done && response.body) {
setSurveyId(response.body.id);
} else {
logger.error('Survey not found or inactive (warn replaced with error)');
}
} catch (error) {
logger.error('Failed to load survey', error);
// Continue without survey - don't block account setup
}
};
void verifyAuthStatus();
void loadSurvey();
}, [dispatch, navigate, trackMixpanelEvent]);
const calculateHeight = () => {
if (currentStep === 2) {
return tasks.length * 105;
}
if (currentStep === 3) {
return teamMembers.length * 105;
}
return 'min-content';
};
const styles = {
form: {
width: '600px',
paddingBottom: '1rem',
marginTop: '3rem',
height: '100%',
overflow: 'hidden',
},
label: {
color: isDarkMode ? '' : '#00000073',
fontWeight: 500,
},
buttonContainer: {
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginTop: '1rem',
},
drawerFooter: {
display: 'flex',
justifyContent: 'right',
padding: '10px 16px',
},
container: {
height: '100vh',
width: '100vw',
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
padding: '3rem 0',
backgroundColor: isDarkMode ? 'black' : '#FAFAFA',
},
contentContainer: {
backgroundColor: isDarkMode ? '#141414' : 'white',
marginTop: '1.5rem',
paddingTop: '3rem',
margin: '1.5rem auto 0',
width: '100%',
maxWidth: '66.66667%',
minHeight: 'fit-content',
display: 'flex',
flexDirection: 'column' as const,
},
space: {
display: 'flex',
flexDirection: 'column' as const,
alignItems: 'center',
gap: '0',
flexGrow: 1,
width: '100%',
minHeight: 'fit-content',
},
steps: {
margin: '1rem 0',
width: '600px',
},
stepContent: {
flexGrow: 1,
width: '600px',
minHeight: calculateHeight(),
overflow: 'visible',
},
actionButtons: {
flexGrow: 1,
width: '600px',
marginBottom: '1rem',
},
};
const completeAccountSetup = async (skip = false) => {
try {
@@ -159,6 +185,13 @@ const AccountSetup: React.FC = () => {
: teamMembers
.map(teamMember => sanitizeInput(teamMember.value.trim()))
.filter(email => validateEmail(email)),
survey_data: {
organization_type: surveyData.organization_type,
user_role: surveyData.user_role,
main_use_cases: surveyData.main_use_cases,
previous_tools: surveyData.previous_tools,
how_heard_about: surveyData.how_heard_about,
},
};
const res = await profileSettingsApiService.setupAccount(model);
if (res.done && res.body.id) {
@@ -184,6 +217,59 @@ const AccountSetup: React.FC = () => {
}
};
const handleSkipMembers = async () => {
try {
setIsSkipping(true);
// Bypass all validation and complete setup without team members
await completeAccountSetup(true);
} catch (error) {
logger.error('Failed to skip members and complete setup', error);
} finally {
setIsSkipping(false);
}
};
const completeAccountSetupWithTemplate = async () => {
try {
await saveSurveyData(); // Save survey data first
const model: IAccountSetupRequest = {
team_name: sanitizeInput(organizationName),
project_name: null, // No project name when using template
template_id: templateId,
tasks: [],
team_members: [],
survey_data: {
organization_type: surveyData.organization_type,
user_role: surveyData.user_role,
main_use_cases: surveyData.main_use_cases,
previous_tools: surveyData.previous_tools,
how_heard_about: surveyData.how_heard_about,
},
};
const res = await projectTemplatesApiService.setupAccount(model);
if (res.done && res.body.id) {
trackMixpanelEvent(evt_account_setup_complete);
// Refresh user session to update setup_completed status
try {
const authResponse = await dispatch(verifyAuthentication()).unwrap() as IAuthorizeResponse;
if (authResponse?.authenticated && authResponse?.user) {
setSession(authResponse.user);
dispatch(setUser(authResponse.user));
}
} catch (error) {
logger.error('Failed to refresh user session after template setup completion', error);
}
navigate(`/worklenz/projects/${res.body.id}?tab=tasks-list&pinned_tab=tasks-list`);
}
} catch (error) {
logger.error('completeAccountSetupWithTemplate', error);
}
};
const steps = [
{
title: '',
@@ -192,6 +278,20 @@ const AccountSetup: React.FC = () => {
onEnter={() => dispatch(setCurrentStep(currentStep + 1))}
styles={styles}
organizationNamePlaceholder={organizationNamePlaceholder}
organizationNameInitialValue={organizationNameInitialValue}
isDarkMode={isDarkMode}
token={token}
/>
),
},
{
title: '',
content: (
<SurveyStep
onEnter={() => dispatch(setCurrentStep(currentStep + 1))}
styles={styles}
isDarkMode={isDarkMode}
token={token}
/>
),
},
@@ -202,6 +302,7 @@ const AccountSetup: React.FC = () => {
onEnter={() => dispatch(setCurrentStep(currentStep + 1))}
styles={styles}
isDarkMode={isDarkMode}
token={token}
/>
),
},
@@ -212,12 +313,13 @@ const AccountSetup: React.FC = () => {
onEnter={() => dispatch(setCurrentStep(currentStep + 1))}
styles={styles}
isDarkMode={isDarkMode}
token={token}
/>
),
},
{
title: '',
content: <MembersStep isDarkMode={isDarkMode} styles={styles} />,
content: <MembersStep isDarkMode={isDarkMode} styles={styles} token={token} />,
},
];
@@ -226,10 +328,21 @@ const AccountSetup: React.FC = () => {
case 0:
return !organizationName?.trim();
case 1:
return !projectName?.trim() && !templateId;
// Survey step - check current sub-step requirements
if (surveySubStep === 0) {
return !(surveyData.organization_type && surveyData.user_role);
} else if (surveySubStep === 1) {
return !(surveyData.main_use_cases && surveyData.main_use_cases.length > 0);
} else if (surveySubStep === 2) {
return !surveyData.how_heard_about;
}
return false;
case 2:
return tasks.length === 0 || tasks.every(task => !task.value?.trim());
// Project step - either project name OR template must be provided
return !projectName?.trim() && !templateId;
case 3:
return tasks.length === 0 || tasks.every(task => !task.value?.trim());
case 4:
return (
teamMembers.length > 0 && !teamMembers.some(member => validateEmail(member.value?.trim()))
);
@@ -238,58 +351,267 @@ const AccountSetup: React.FC = () => {
}
};
const nextStep = () => {
if (currentStep === 3) {
const saveSurveyData = async () => {
if (!surveyId || !surveyData) {
logger.error('Skipping survey save - no survey ID or data (info replaced with error)');
return;
}
try {
const answers: ISurveyAnswer[] = [];
// Get the survey questions to map data properly
const surveyResponse = await surveyApiService.getAccountSetupSurvey();
if (!surveyResponse.done || !surveyResponse.body?.questions) {
logger.error('Could not retrieve survey questions for data mapping (warn replaced with error)');
return;
}
const questions = surveyResponse.body.questions;
// Map survey data to answers based on question keys
questions.forEach(question => {
switch (question.question_key) {
case 'organization_type':
if (surveyData.organization_type) {
answers.push({
question_id: question.id,
answer_text: surveyData.organization_type
});
}
break;
case 'user_role':
if (surveyData.user_role) {
answers.push({
question_id: question.id,
answer_text: surveyData.user_role
});
}
break;
case 'main_use_cases':
if (surveyData.main_use_cases && surveyData.main_use_cases.length > 0) {
answers.push({
question_id: question.id,
answer_json: surveyData.main_use_cases
});
}
break;
case 'previous_tools':
if (surveyData.previous_tools) {
answers.push({
question_id: question.id,
answer_text: surveyData.previous_tools
});
}
break;
case 'how_heard_about':
if (surveyData.how_heard_about) {
answers.push({
question_id: question.id,
answer_text: surveyData.how_heard_about
});
}
break;
}
});
if (answers.length > 0) {
const submissionData: ISurveySubmissionRequest = {
survey_id: surveyId,
answers
};
const result = await surveyApiService.submitSurveyResponse(submissionData);
if (result.done) {
logger.error('Survey data saved successfully (info replaced with error)');
} else {
logger.error('Survey submission returned unsuccessful response (warn replaced with error)');
}
} else {
logger.error('No survey answers to save (info replaced with error)');
}
} catch (error) {
logger.error('Failed to save survey data', error);
// Don't block account setup flow if survey fails
}
};
const nextStep = async () => {
if (currentStep === 1) {
// Handle survey sub-step navigation
if (surveySubStep < 2) {
// Move to next survey sub-step
dispatch(setSurveySubStep(surveySubStep + 1));
} else {
// Survey completed, save data and move to next main step
await saveSurveyData();
dispatch(setCurrentStep(currentStep + 1));
dispatch(setSurveySubStep(0)); // Reset for next time
}
} else if (currentStep === 2) {
// Project step - check if template is selected
if (templateId) {
// Template selected, complete account setup with template
await completeAccountSetupWithTemplate();
} else {
// No template, proceed to tasks step
dispatch(setCurrentStep(currentStep + 1));
}
} else if (currentStep === 4) {
// Complete setup after members step
completeAccountSetup();
} else {
dispatch(setCurrentStep(currentStep + 1));
}
};
// Language switcher functionality
const languages = [
{ key: Language.EN, label: 'English', flag: '🇺🇸' },
{ key: Language.ES, label: 'Español', flag: '🇪🇸' },
{ key: Language.PT, label: 'Português', flag: '🇵🇹' },
{ key: Language.DE, label: 'Deutsch', flag: '🇩🇪' },
{ key: Language.ALB, label: 'Shqip', flag: '🇦🇱' },
{ key: Language.ZH_CN, label: '简体中文', flag: '🇨🇳' }
];
const handleLanguageChange = (languageKey: ILanguageType) => {
dispatch(setLanguage(languageKey));
i18n.changeLanguage(languageKey);
};
const handleThemeToggle = () => {
dispatch(toggleTheme());
};
const languageMenuItems: MenuProps['items'] = languages.map(lang => ({
key: lang.key,
label: (
<div className="flex items-center space-x-2">
<span>{lang.flag}</span>
<span>{lang.label}</span>
</div>
),
onClick: () => handleLanguageChange(lang.key as ILanguageType)
}));
const currentLanguage = languages.find(lang => lang.key === lng) || languages[0];
return (
<div style={styles.container}>
<div>
<div
className="account-setup-container min-h-screen w-full flex flex-col items-center py-8 px-4 relative"
style={{ backgroundColor: token.colorBgLayout }}
>
{/* Controls - Top Right */}
<div className="absolute top-6 right-6 flex items-center space-x-3">
{/* Theme Switcher */}
<Button
type="text"
size="small"
icon={isDarkMode ? <SunOutlined /> : <MoonOutlined />}
onClick={handleThemeToggle}
className="flex items-center"
style={{ color: token?.colorTextTertiary }}
title={isDarkMode ? 'Switch to light mode' : 'Switch to dark mode'}
/>
{/* Language Switcher */}
<Dropdown
menu={{ items: languageMenuItems }}
placement="bottomRight"
trigger={['click']}
>
<Button
type="text"
size="small"
icon={<GlobalOutlined />}
className="flex items-center space-x-2"
style={{ color: token?.colorTextTertiary }}
>
<span>{currentLanguage.flag}</span>
<span>{currentLanguage.label}</span>
</Button>
</Dropdown>
</div>
{/* Logo */}
<div className="mb-4">
<img src={isDarkMode ? logoDark : logo} alt="Logo" width={235} height={50} />
</div>
<Title level={5} style={{ textAlign: 'center', margin: '4px 0 24px' }}>
{/* Title */}
<Title
level={3}
className="text-center mb-6 font-semibold"
style={{ color: token.colorText }}
>
{t('setupYourAccount')}
</Title>
<div style={styles.contentContainer}>
<Space className={isDarkMode ? 'dark-mode' : ''} style={styles.space} direction="vertical">
<Steps
className={isContinueDisabled() ? 'step' : 'progress-steps'}
current={currentStep}
items={steps}
style={styles.steps}
/>
<div className="step-content" style={styles.stepContent}>
{steps[currentStep].content}
{/* Content Container */}
<div
className="w-full max-w-4xl rounded-lg shadow-lg mt-6 p-8"
style={{
backgroundColor: token.colorBgContainer,
borderColor: token.colorBorder,
border: `1px solid ${token.colorBorder}`,
boxShadow: token.boxShadowTertiary
}}
>
<div className="flex flex-col items-center space-y-6 w-full">
{/* Steps */}
<div className="w-full max-w-2xl">
<Steps
className={`${isContinueDisabled() ? 'step' : 'progress-steps'} ${isDarkMode ? 'dark-mode' : 'light-mode'}`}
current={currentStep}
items={steps}
/>
</div>
<div style={styles.actionButtons} className="setup-action-buttons">
<div
style={{
display: 'flex',
justifyContent: currentStep !== 0 ? 'space-between' : 'flex-end',
}}
>
{/* Step Content */}
<div className="w-full max-w-2xl flex flex-col items-center min-h-fit">
<div className="step-content w-full">
{steps[currentStep].content}
</div>
</div>
{/* Action Buttons */}
<div className="w-full max-w-2xl mt-8">
<div className={`flex ${
currentStep !== 0 ? 'justify-between' : 'justify-end'
} items-center`}>
{currentStep !== 0 && (
<div>
<div className="flex flex-col space-y-2">
<Button
style={{ padding: 0 }}
type="link"
className="my-7"
onClick={() => dispatch(setCurrentStep(currentStep - 1))}
className="p-0 font-medium"
style={{ color: token.colorTextSecondary }}
onClick={() => {
if (currentStep === 1 && surveySubStep > 0) {
// Go back within survey sub-steps
dispatch(setSurveySubStep(surveySubStep - 1));
} else {
// Go back to previous main step
dispatch(setCurrentStep(currentStep - 1));
if (currentStep === 2) {
// When going back to survey from next step, go to last sub-step
dispatch(setSurveySubStep(2));
}
}
}}
>
{t('goBack')}
</Button>
{currentStep === 3 && (
{currentStep === 4 && (
<Button
style={{ color: isDarkMode ? '' : '#00000073', fontWeight: 500 }}
type="link"
className="my-7"
onClick={() => completeAccountSetup(true)}
className="p-0 font-medium"
style={{ color: token.colorTextTertiary }}
onClick={handleSkipMembers}
loading={isSkipping}
disabled={isSkipping}
>
{t('skipForNow')}
{isSkipping ? t('skipping') : t('skipForNow')}
</Button>
)}
</div>
@@ -298,14 +620,13 @@ const AccountSetup: React.FC = () => {
type="primary"
htmlType="submit"
disabled={isContinueDisabled()}
className="mt-7 mb-7"
onClick={nextStep}
>
{t('continue')}
</Button>
</div>
</div>
</Space>
</div>
</div>
</div>
);

View File

@@ -1,14 +1,16 @@
import { useEffect, memo, useMemo, useCallback } 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';
import Row from 'antd/es/row';
import Card from 'antd/es/card';
import GreetingWithTime from './greeting-with-time';
import TasksList from '@/pages/home/task-list/tasks-list';
import TodoList from '@/pages/home/todo-list/todo-list';
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
import RecentAndFavouriteProjectList from '@/pages/home/recent-and-favourite-project-list/recent-and-favourite-project-list';
import TodoList from './todo-list/todo-list';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import { useAppDispatch } from '@/hooks/useAppDispatch';
@@ -19,7 +21,7 @@ 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, { Suspense } from 'react';
import UserActivityFeed from './user-activity-feed/user-activity-feed';
const DESKTOP_MIN_WIDTH = 1024;
const TASK_LIST_MIN_WIDTH = 500;
@@ -27,6 +29,9 @@ const SIDEBAR_MAX_WIDTH = 400;
// Lazy load heavy components
const TaskDrawer = React.lazy(() => import('@/components/task-drawer/task-drawer'));
const SurveyPromptModal = React.lazy(() =>
import('@/components/survey/SurveyPromptModal').then(m => ({ default: m.SurveyPromptModal }))
);
const HomePage = memo(() => {
const dispatch = useAppDispatch();
@@ -97,26 +102,6 @@ const HomePage = memo(() => {
);
}, [isDesktop, isOwnerOrAdmin]);
const MainContent = useMemo(() => {
return isDesktop ? (
<Flex gap={24} align="flex-start" className="w-full mt-12">
<Flex style={desktopFlexStyle}>
<TasksList />
</Flex>
<Flex vertical gap={24} style={sidebarFlexStyle}>
<TodoList />
<RecentAndFavouriteProjectList />
</Flex>
</Flex>
) : (
<Flex vertical gap={24} className="mt-6">
<TasksList />
<TodoList />
<RecentAndFavouriteProjectList />
</Flex>
);
}, [isDesktop, desktopFlexStyle, sidebarFlexStyle]);
return (
<div className="my-24 min-h-[90vh]">
<Col className="flex flex-col gap-6">
@@ -124,24 +109,26 @@ const HomePage = memo(() => {
{CreateProjectButtonComponent}
</Col>
{MainContent}
<Row gutter={[24, 24]} className="mt-12">
<Col xs={24} lg={16}>
<Flex vertical gap={24}>
<TasksList />
{/* Use Suspense for lazy-loaded components with error boundary */}
<Suspense fallback={<div>Loading...</div>}>
{createPortal(
<React.Suspense fallback={null}>
<TaskDrawer />
</React.Suspense>,
document.body,
'home-task-drawer'
)}
</Suspense>
<TodoList />
</Flex>
</Col>
{createPortal(
<ProjectDrawer onClose={handleProjectDrawerClose} />,
document.body,
'project-drawer'
)}
<Col xs={24} lg={8}>
<Flex vertical gap={24}>
<UserActivityFeed />
<RecentAndFavouriteProjectList />
</Flex>
</Col>
</Row>
{createPortal(<TaskDrawer />, document.body, 'home-task-drawer')}
{createPortal(<ProjectDrawer onClose={() => {}} />, document.body, 'project-drawer')}
</div>
);
});

View File

@@ -144,7 +144,7 @@ const TodoList = () => {
</Form.Item>
</Form>
<div style={{ maxHeight: 420, overflow: 'auto' }}>
<div style={{ maxHeight: 300, overflow: 'auto' }}>
{data?.body.length === 0 ? (
<EmptyListPlaceholder
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"

View File

@@ -0,0 +1,95 @@
import React, { useCallback } from 'react';
import { Table, Typography, Tooltip, theme } from 'antd';
import { FileTextOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { fromNow, formatDate } from '@/utils/dateUtils';
import {
setSelectedTaskId,
setShowTaskDrawer,
fetchTask,
} from '@/features/task-drawer/task-drawer.slice';
import { IUserRecentTask } from '@/types/home/user-activity.types';
const { Text } = Typography;
interface TaskActivityListProps {
tasks: IUserRecentTask[];
}
const TaskActivityList: React.FC<TaskActivityListProps> = React.memo(({ tasks }) => {
const { t } = useTranslation('home');
const dispatch = useAppDispatch();
const { token } = theme.useToken();
const handleTaskClick = useCallback(
(taskId: string, projectId: string) => {
dispatch(setSelectedTaskId(taskId));
dispatch(setShowTaskDrawer(true));
dispatch(fetchTask({ taskId, projectId }));
},
[dispatch]
);
const columns = [
{
key: 'task',
render: (record: IUserRecentTask) => (
<div
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 12,
width: '100%',
cursor: 'pointer',
padding: '8px 0'
}}
onClick={() => handleTaskClick(record.task_id, record.project_id)}
aria-label={`${t('tasks.recentTaskAriaLabel')} ${record.task_name}`}
>
<div style={{
marginTop: 2,
color: token.colorPrimary,
fontSize: 16
}}>
<FileTextOutlined />
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ marginBottom: 4 }}>
<Text strong style={{ fontSize: 14 }}>
{record.task_name}
</Text>
</div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text type="secondary" style={{ fontSize: 12 }}>
{record.project_name}
</Text>
<Tooltip
title={formatDate(record.last_activity_at, 'MMMM Do YYYY, h:mm:ss a')}
placement="topRight"
>
<Text type="secondary" style={{ fontSize: 12 }}>
{fromNow(record.last_activity_at)}
</Text>
</Tooltip>
</div>
</div>
</div>
),
},
];
return (
<Table
className="custom-two-colors-row-table"
dataSource={tasks}
columns={columns}
rowKey="task_id"
showHeader={false}
pagination={false}
size="small"
/>
);
});
export default TaskActivityList;

View File

@@ -0,0 +1,166 @@
import React, { useCallback } from 'react';
import { Table, Typography, Tag, Tooltip, Space, theme } from '@/shared/antd-imports';
import { ClockCircleOutlined } from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { fromNow, formatDate } from '@/utils/dateUtils';
import {
setSelectedTaskId,
setShowTaskDrawer,
fetchTask,
} from '@/features/task-drawer/task-drawer.slice';
import { IUserTimeLoggedTask } from '@/types/home/user-activity.types';
const { Text } = Typography;
interface TimeLoggedTaskListProps {
tasks: IUserTimeLoggedTask[];
}
const TimeLoggedTaskList: React.FC<TimeLoggedTaskListProps> = React.memo(({ tasks }) => {
const { t } = useTranslation('home');
const dispatch = useAppDispatch();
const { token } = theme.useToken();
const handleTaskClick = useCallback(
(taskId: string, projectId: string) => {
dispatch(setSelectedTaskId(taskId));
dispatch(setShowTaskDrawer(true));
dispatch(fetchTask({ taskId, projectId }));
},
[dispatch]
);
const columns = [
{
key: 'task',
render: (record: IUserTimeLoggedTask) => (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
width: '100%',
cursor: 'pointer',
padding: '8px 0'
}}
onClick={() => handleTaskClick(record.task_id, record.project_id)}
aria-label={`${t('tasks.timeLoggedTaskAriaLabel')} ${record.task_name}`}
>
{/* Clock Icon */}
<div style={{
color: token.colorSuccess,
fontSize: 14,
flexShrink: 0
}}>
<ClockCircleOutlined />
</div>
{/* Main Content */}
<div style={{ flex: 1, minWidth: 0 }}>
{/* Task Name */}
<div style={{ marginBottom: 2 }}>
<Text
strong
style={{
fontSize: 13,
lineHeight: 1.4,
color: token.colorText
}}
ellipsis={{ tooltip: record.task_name }}
>
{record.task_name}
</Text>
</div>
{/* Project Name */}
<Text
type="secondary"
style={{
fontSize: 11,
lineHeight: 1.2,
display: 'block',
marginBottom: 4
}}
ellipsis={{ tooltip: record.project_name }}
>
{record.project_name}
</Text>
</div>
{/* Right Side - Time and Status */}
<div style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-end',
gap: 3,
flexShrink: 0
}}>
{/* Time Logged */}
<div style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Tag
color="success"
style={{
margin: 0,
fontSize: 11,
padding: '0 6px',
height: 18,
lineHeight: '16px',
borderRadius: 3
}}
>
{record.total_time_logged_string}
</Tag>
{record.logged_by_timer && (
<Tag
color="processing"
style={{
margin: 0,
fontSize: 10,
padding: '0 4px',
height: 16,
lineHeight: '14px',
borderRadius: 2
}}
>
{t('tasks.timerTag')}
</Tag>
)}
</div>
{/* Time Ago */}
<Tooltip
title={formatDate(record.last_logged_at, 'MMMM Do YYYY, h:mm:ss a')}
placement="topRight"
>
<Text
type="secondary"
style={{
fontSize: 10,
lineHeight: 1,
color: token.colorTextTertiary
}}
>
{fromNow(record.last_logged_at)}
</Text>
</Tooltip>
</div>
</div>
),
},
];
return (
<Table
className="custom-two-colors-row-table"
dataSource={tasks}
columns={columns}
rowKey="task_id"
showHeader={false}
pagination={false}
size="small"
/>
);
});
export default TimeLoggedTaskList;

View File

@@ -0,0 +1,19 @@
.activity-feed-item:hover {
background-color: var(--activity-hover, #fafafa);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.activity-feed-item:active {
transform: translateY(0);
background-color: var(--activity-active, #f0f0f0);
}
/* Dark theme support */
[data-theme="dark"] .activity-feed-item:hover {
background-color: var(--activity-hover, #262626);
}
[data-theme="dark"] .activity-feed-item:active {
background-color: var(--activity-active, #1f1f1f);
}

View File

@@ -0,0 +1,203 @@
import React, { useMemo, useCallback, useEffect } from 'react';
import { Card, Segmented, Skeleton, Empty, Typography, Alert, Button, Tooltip } from '@/shared/antd-imports';
import { ClockCircleOutlined, UnorderedListOutlined, SyncOutlined } from '@/shared/antd-imports';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { ActivityFeedType } from '@/types/home/user-activity.types';
import { setActiveTab } from '@/features/home-page/user-activity.slice';
import {
useGetUserRecentTasksQuery,
useGetUserTimeLoggedTasksQuery,
} from '@/api/home-page/user-activity.api.service';
import TaskActivityList from './task-activity-list';
import TimeLoggedTaskList from './time-logged-task-list';
const { Title } = Typography;
const UserActivityFeed: React.FC = () => {
const { t } = useTranslation('home');
const dispatch = useAppDispatch();
const { activeTab } = useAppSelector(state => state.userActivityReducer);
const {
data: recentTasksData,
isLoading: loadingRecentTasks,
error: recentTasksError,
refetch: refetchRecentTasks,
} = useGetUserRecentTasksQuery(
{ limit: 10 },
{
skip: false,
refetchOnMountOrArgChange: true
}
);
const {
data: timeLoggedTasksData,
isLoading: loadingTimeLoggedTasks,
error: timeLoggedTasksError,
refetch: refetchTimeLoggedTasks,
} = useGetUserTimeLoggedTasksQuery(
{ limit: 10 },
{
skip: false,
refetchOnMountOrArgChange: true
}
);
const recentTasks = useMemo(() => {
if (!recentTasksData) return [];
// Handle both array and object responses from the API
if (Array.isArray(recentTasksData)) {
return recentTasksData;
}
// If it's an object with a data property (common API pattern)
if (recentTasksData && typeof recentTasksData === 'object' && 'data' in recentTasksData) {
const data = (recentTasksData as any).data;
return Array.isArray(data) ? data : [];
}
// If it's a different object structure, try to extract tasks
if (recentTasksData && typeof recentTasksData === 'object') {
const possibleArrays = Object.values(recentTasksData as any).filter(Array.isArray);
return possibleArrays.length > 0 ? possibleArrays[0] : [];
}
return [];
}, [recentTasksData]);
const timeLoggedTasks = useMemo(() => {
if (!timeLoggedTasksData) return [];
// Handle both array and object responses from the API
if (Array.isArray(timeLoggedTasksData)) {
return timeLoggedTasksData;
}
// If it's an object with a data property (common API pattern)
if (timeLoggedTasksData && typeof timeLoggedTasksData === 'object' && 'data' in timeLoggedTasksData) {
const data = (timeLoggedTasksData as any).data;
return Array.isArray(data) ? data : [];
}
// If it's a different object structure, try to extract tasks
if (timeLoggedTasksData && typeof timeLoggedTasksData === 'object') {
const possibleArrays = Object.values(timeLoggedTasksData as any).filter(Array.isArray);
return possibleArrays.length > 0 ? possibleArrays[0] : [];
}
return [];
}, [timeLoggedTasksData]);
const segmentOptions = useMemo(
() => [
{
value: ActivityFeedType.TIME_LOGGED_TASKS,
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<ClockCircleOutlined style={{ fontSize: 14 }} />
{t('tasks.timeLoggedSegment')}
</span>
),
},
{
value: ActivityFeedType.RECENT_TASKS,
label: (
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<UnorderedListOutlined style={{ fontSize: 14 }} />
{t('tasks.recentTasksSegment')}
</span>
),
},
],
[t]
);
const handleTabChange = useCallback(
(value: ActivityFeedType) => {
dispatch(setActiveTab(value));
},
[dispatch]
);
// Refetch data when the active tab changes
useEffect(() => {
if (activeTab === ActivityFeedType.RECENT_TASKS) {
refetchRecentTasks();
} else if (activeTab === ActivityFeedType.TIME_LOGGED_TASKS) {
refetchTimeLoggedTasks();
}
}, [activeTab, refetchRecentTasks, refetchTimeLoggedTasks]);
const handleRefresh = useCallback(() => {
if (activeTab === ActivityFeedType.TIME_LOGGED_TASKS) {
refetchTimeLoggedTasks();
} else {
refetchRecentTasks();
}
}, [activeTab, refetchRecentTasks, refetchTimeLoggedTasks]);
const isLoading = activeTab === ActivityFeedType.TIME_LOGGED_TASKS ? loadingTimeLoggedTasks : loadingRecentTasks;
const currentCount = activeTab === ActivityFeedType.TIME_LOGGED_TASKS ? timeLoggedTasks.length : recentTasks.length;
const renderContent = () => {
if (activeTab === ActivityFeedType.TIME_LOGGED_TASKS) {
if (loadingTimeLoggedTasks) {
return <Skeleton active />;
}
if (timeLoggedTasksError) {
return <Alert message={t('tasks.errorLoadingTimeLoggedTasks')} type="error" showIcon />;
}
if (timeLoggedTasks.length === 0) {
return <Empty description={t('tasks.noTimeLoggedTasks')} />;
}
return (
<div style={{ maxHeight: 450, overflow: 'auto' }}>
<TimeLoggedTaskList tasks={timeLoggedTasks} />
</div>
);
} else if (activeTab === ActivityFeedType.RECENT_TASKS) {
if (loadingRecentTasks) {
return <Skeleton active />;
}
if (recentTasksError) {
return <Alert message={t('tasks.errorLoadingRecentTasks')} type="error" showIcon />;
}
if (recentTasks.length === 0) {
return <Empty description={t('tasks.noRecentTasks')} />;
}
return (
<div style={{ maxHeight: 450, overflow: 'auto' }}>
<TaskActivityList tasks={recentTasks} />
</div>
);
}
return null;
};
return (
<Card
title={
<Typography.Title level={5} style={{ marginBlockEnd: 0 }}>
{t('tasks.recentActivity')} ({currentCount})
</Typography.Title>
}
extra={
<Tooltip title={t('tasks.refresh')}>
<Button
shape="circle"
icon={<SyncOutlined spin={isLoading} />}
onClick={handleRefresh}
/>
</Tooltip>
}
style={{ width: '100%' }}
>
<Segmented
options={segmentOptions}
value={activeTab}
onChange={handleTabChange}
style={{ marginBottom: 16, width: '100%' }}
block
/>
{renderContent()}
</Card>
);
};
export default React.memo(UserActivityFeed);

View File

@@ -0,0 +1,63 @@
import React, { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import { AdvancedGanttChart } from '../../components/advanced-gantt';
import { useAppSelector } from '../../hooks/useAppSelector';
import { GanttTask } from '../../types/advanced-gantt.types';
const ProjectGanttView: React.FC = () => {
const { projectId } = useParams<{ projectId: string }>();
// Get tasks from your Redux store (adjust based on your actual state structure)
const tasks = useAppSelector(state => state.tasksReducer?.tasks || []);
// Transform your tasks to GanttTask format
const ganttTasks = useMemo((): GanttTask[] => {
return tasks.map(task => ({
id: task.id,
name: task.name,
startDate: task.start_date ? new Date(task.start_date) : new Date(),
endDate: task.end_date ? new Date(task.end_date) : new Date(),
progress: task.progress || 0,
type: 'task',
status: task.status || 'not-started',
priority: task.priority || 'medium',
assignee: task.assignee ? {
id: task.assignee.id,
name: task.assignee.name,
avatar: task.assignee.avatar,
} : undefined,
parent: task.parent_task_id,
level: task.level || 0,
// Map other fields as needed
}));
}, [tasks]);
const handleTaskUpdate = (taskId: string, updates: Partial<GanttTask>) => {
// Implement your task update logic here
console.log('Update task:', taskId, updates);
// Dispatch Redux action to update task
};
const handleTaskMove = (taskId: string, newDates: { start: Date; end: Date }) => {
// Implement your task move logic here
console.log('Move task:', taskId, newDates);
// Dispatch Redux action to update task dates
};
return (
<div className="project-gantt-view h-full">
<AdvancedGanttChart
tasks={ganttTasks}
onTaskUpdate={handleTaskUpdate}
onTaskMove={handleTaskMove}
enableDragDrop={true}
enableResize={true}
enableProgressEdit={true}
enableInlineEdit={true}
className="h-full"
/>
</div>
);
};
export default ProjectGanttView;

View File

@@ -0,0 +1,583 @@
import { useEffect, useState, useRef, useMemo, useCallback } 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,
closestCenter,
DragOverlay,
MouseSensor,
TouchSensor,
useSensor,
useSensors,
getFirstCollision,
pointerWithin,
rectIntersection,
UniqueIdentifier,
} 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 { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
import { debounce } from 'lodash';
import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types';
import { updateTaskPriority as updateBoardTaskPriority } from '@/features/board/board-slice';
interface DroppableContainer {
id: UniqueIdentifier;
data: {
current?: {
type?: string;
};
};
}
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);
// Add local loading state to immediately show skeleton
const [isLoading, setIsLoading] = useState(true);
const { projectId } = useAppSelector(state => state.projectReducer);
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer);
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
state => state.taskStatusReducer
);
const [activeItem, setActiveItem] = useState<any>(null);
// Store the original source group ID when drag starts
const originalSourceGroupIdRef = useRef<string | null>(null);
const lastOverId = useRef<UniqueIdentifier | null>(null);
const recentlyMovedToNewContainer = useRef(false);
const [clonedItems, setClonedItems] = useState<any>(null);
const isDraggingRef = useRef(false);
// Update loading state based on all loading conditions
useEffect(() => {
setIsLoading(loadingGroups || loadingStatusCategories);
}, [loadingGroups, loadingStatusCategories]);
// Load data efficiently with async/await and Promise.all
useEffect(() => {
const loadData = async () => {
if (projectId && groupBy && projectView === 'kanban') {
const promises = [];
if (!loadingGroups) {
promises.push(dispatch(fetchBoardTaskGroups(projectId)));
}
if (!statusCategories.length) {
promises.push(dispatch(fetchStatusesCategories()));
}
// Wait for all data to load
await Promise.all(promises);
}
};
loadData();
}, [dispatch, projectId, groupBy, projectView, search, archived]);
// Create sensors with memoization to prevent unnecessary re-renders
const sensors = useSensors(
useSensor(MouseSensor, {
activationConstraint: {
distance: 10,
delay: 100,
tolerance: 5,
},
}),
useSensor(TouchSensor, {
activationConstraint: {
delay: 250,
tolerance: 5,
},
})
);
const collisionDetectionStrategy = useCallback(
(args: {
active: { id: UniqueIdentifier; data: { current?: { type?: string } } };
droppableContainers: DroppableContainer[];
}) => {
if (activeItem?.type === 'section') {
return closestCenter({
...args,
droppableContainers: args.droppableContainers.filter(
(container: DroppableContainer) => container.data.current?.type === 'section'
),
});
}
// Start by finding any intersecting droppable
const pointerIntersections = pointerWithin(args);
const intersections =
pointerIntersections.length > 0
? pointerIntersections
: rectIntersection(args);
let overId = getFirstCollision(intersections, 'id');
if (overId !== null) {
const overContainer = args.droppableContainers.find(
(container: DroppableContainer) => container.id === overId
);
if (overContainer?.data.current?.type === 'section') {
const containerItems = taskGroups.find(
(group) => group.id === overId
)?.tasks || [];
if (containerItems.length > 0) {
overId = closestCenter({
...args,
droppableContainers: args.droppableContainers.filter(
(container: DroppableContainer) =>
container.id !== overId &&
container.data.current?.type === 'task'
),
})[0]?.id;
}
}
lastOverId.current = overId;
return [{ id: overId }];
}
if (recentlyMovedToNewContainer.current) {
lastOverId.current = activeItem?.id;
}
return lastOverId.current ? [{ id: lastOverId.current }] : [];
},
[activeItem, taskGroups]
);
const handleTaskProgress = (data: {
id: string;
status: string;
complete_ratio: number;
completed_count: number;
total_tasks_count: number;
parent_task: string;
}) => {
dispatch(updateTaskProgress(data));
};
// Debounced move task function to prevent rapid updates
const debouncedMoveTask = useCallback(
debounce((taskId: string, sourceGroupId: string, targetGroupId: string, targetIndex: number) => {
dispatch(
moveTaskBetweenGroups({
taskId,
sourceGroupId,
targetGroupId,
targetIndex,
})
);
}, 100),
[dispatch]
);
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
isDraggingRef.current = true;
setActiveItem(active.data.current);
setCurrentTaskIndex(active.data.current?.sortable.index);
if (active.data.current?.type === 'task') {
originalSourceGroupIdRef.current = active.data.current.sectionId;
}
setClonedItems(taskGroups);
};
const findGroupForId = (id: string) => {
// If id is a sectionId
if (taskGroups.some(group => group.id === id)) return id;
// If id is a taskId, find the group containing it
const group = taskGroups.find(g => g.tasks.some(t => t.id === id));
return group?.id;
};
const handleDragOver = (event: DragOverEvent) => {
try {
if (!isDraggingRef.current) return;
const { active, over } = event;
if (!over) return;
// Get the ids
const activeId = active.id;
const overId = over.id;
// Find the group (section) for each
const activeGroupId = findGroupForId(activeId as string);
const overGroupId = findGroupForId(overId as string);
// Only move if both groups exist and are different, and the active is a task
if (
activeGroupId &&
overGroupId &&
active.data.current?.type === 'task'
) {
// Find the target index in the over group
const targetGroup = taskGroups.find(g => g.id === overGroupId);
let targetIndex = 0;
if (targetGroup) {
// If over is a task, insert before it; if over is a section, append to end
if (over.data.current?.type === 'task') {
targetIndex = targetGroup.tasks.findIndex(t => t.id === overId);
if (targetIndex === -1) targetIndex = targetGroup.tasks.length;
} else {
targetIndex = targetGroup.tasks.length;
}
}
// Use debounced move task to prevent rapid updates
debouncedMoveTask(
activeId as string,
activeGroupId,
overGroupId,
targetIndex
);
}
} catch (error) {
console.error('handleDragOver error:', error);
}
};
const handlePriorityChange = (taskId: string, priorityId: string) => {
if (!taskId || !priorityId || !socket) return;
const payload = {
task_id: taskId,
priority_id: priorityId,
team_id: currentSession?.team_id,
};
socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify(payload));
socket.once(SocketEvents.TASK_PRIORITY_CHANGE.toString(), (data: ITaskListPriorityChangeResponse) => {
dispatch(updateBoardTaskPriority(data));
});
};
const handleDragEnd = async (event: DragEndEvent) => {
isDraggingRef.current = false;
const { active, over } = event;
if (!over || !projectId) {
setActiveItem(null);
originalSourceGroupIdRef.current = null;
setClonedItems(null);
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
};
// 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);
}
});
// Handle priority change if groupBy is priority
if (groupBy === IGroupBy.PRIORITY) {
handlePriorityChange(task.id, targetGroupId);
}
}
// 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
};
const handleDragCancel = () => {
isDraggingRef.current = false;
if (clonedItems) {
dispatch(reorderTaskGroups(clonedItems));
}
setActiveItem(null);
setClonedItems(null);
originalSourceGroupIdRef.current = null;
};
// Reset the recently moved flag after animation frame
useEffect(() => {
requestAnimationFrame(() => {
recentlyMovedToNewContainer.current = false;
});
}, [taskGroups]);
useEffect(() => {
if (socket) {
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
}
return () => {
socket?.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
};
}, [socket]);
// Track analytics event on component mount
useEffect(() => {
trackMixpanelEvent(evt_project_board_visit);
}, []);
// Cleanup debounced function on unmount
useEffect(() => {
return () => {
debouncedMoveTask.cancel();
};
}, [debouncedMoveTask]);
return (
<Flex vertical gap={16}>
<TaskListFilters position={'board'} />
<Skeleton active loading={isLoading} className='mt-4 p-4'>
<DndContext
sensors={sensors}
collisionDetection={collisionDetectionStrategy}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
onDragCancel={handleDragCancel}
>
<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

@@ -11,6 +11,7 @@ import {
TableProps,
Tooltip,
Typography,
Input
} from '@/shared/antd-imports';
// Icons
@@ -70,26 +71,29 @@ const ProjectViewMembers = () => {
field: 'name',
order: 'ascend',
total: 0,
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
pageSizeOptions: ['10', '20', '50', '100'],
size: 'small',
});
const [searchQuery, setSearchQuery] = useState(''); // <-- Add search state
// API Functions
const getProjectMembers = async () => {
const getProjectMembers = async (search: string = searchQuery) => {
if (!projectId) return;
setIsLoading(true);
try {
const offset = (pagination.current - 1) * pagination.pageSize;
const res = await projectsApiService.getMembers(
projectId,
pagination.current,
pagination.pageSize,
pagination.current, // index
pagination.pageSize, // size // offset
pagination.field,
pagination.order,
null
search
);
if (res.done) {
setMembers(res.body);
setPagination(p => ({ ...p, total: res.body.total ?? 0 })); // update total from backend, default to 0
}
} catch (error) {
logger.error('Error fetching members:', error);
@@ -123,16 +127,14 @@ const ProjectViewMembers = () => {
return Math.floor((completed / total) * 100);
};
const handleTableChange = (pagination: any, filters: any, sorter: any) => {
setPagination({
current: pagination.current,
pageSize: pagination.pageSize,
field: sorter.field || pagination.field,
order: sorter.order || pagination.order,
total: pagination.total,
pageSizeOptions: pagination.pageSizeOptions,
size: pagination.size,
});
const handleTableChange = (tablePagination: any, filters: any, sorter: any) => {
setPagination(prev => ({
...prev,
current: tablePagination.current,
pageSize: tablePagination.pageSize,
field: sorter.field || prev.field,
order: sorter.order || prev.order,
}));
};
// Effects
@@ -145,6 +147,7 @@ const ProjectViewMembers = () => {
pagination.pageSize,
pagination.field,
pagination.order,
// searchQuery, // <-- Do NOT include here, search is triggered manually
]);
useEffect(() => {
@@ -269,18 +272,33 @@ const ProjectViewMembers = () => {
<Card
style={{ width: '100%' }}
title={
<Flex justify="space-between">
<Flex justify="space-between" align="center">
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
{members?.total} {members?.total !== 1 ? t('membersCountPlural') : t('memberCount')}
</Typography.Text>
<Tooltip title={t('refreshButtonTooltip')}>
<Button
shape="circle"
icon={<SyncOutlined />}
onClick={() => void getProjectMembers()}
<Flex gap={8} align="center">
<Input.Search
allowClear
placeholder={t('searchPlaceholder')}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
onSearch={value => {
setPagination(p => ({ ...p, current: 1 })); // Reset to first page
void getProjectMembers(value);
}}
style={{ width: 220 }}
enterButton
size="middle"
/>
</Tooltip>
<Tooltip title={t('refreshButtonTooltip')}>
<Button
shape="circle"
icon={<SyncOutlined />}
onClick={() => void getProjectMembers()}
/>
</Tooltip>
</Flex>
</Flex>
}
>
@@ -299,8 +317,12 @@ const ProjectViewMembers = () => {
columns={columns}
rowKey={record => record.id}
pagination={{
current: pagination.current,
pageSize: pagination.pageSize,
total: pagination.total,
showSizeChanger: true,
defaultPageSize: 20,
pageSizeOptions: pagination.pageSizeOptions,
size: pagination.size,
}}
onChange={handleTableChange}
onRow={record => ({

View File

@@ -28,9 +28,8 @@ const ProjectsReports = () => {
// Memoize the Excel export handler to prevent recreation on every render
const handleExcelExport = useCallback(() => {
if (currentSession?.team_name) {
reportingExportApiService.exportProjects(currentSession.team_name);
}
const teamName = currentSession?.team_name || 'Team';
reportingExportApiService.exportProjects(teamName);
}, [currentSession?.team_name]);
// Memoize the archived checkbox handler to prevent recreation on every render

View File

@@ -0,0 +1,67 @@
import { Card, Flex, Typography, Tooltip } from '@/shared/antd-imports';
import TimeReportPageHeader from '@/pages/reporting/timeReports/page-header/time-report-page-header';
import MembersTimeSheet, {
MembersTimeSheetRef,
} from '@/pages/reporting/time-reports/members-time-sheet/members-time-sheet';
import TimeReportingRightHeader from './timeReportingRightHeader/TimeReportingRightHeader';
import { useTranslation } from 'react-i18next';
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
import { useRef } from 'react';
import { useUserTimezone } from '@/hooks/useUserTimezone';
import { InfoCircleOutlined } from '@ant-design/icons';
const { Text } = Typography;
const MembersTimeReports = () => {
const { t } = useTranslation('time-report');
const chartRef = useRef<MembersTimeSheetRef>(null);
const { timezone, timezoneOffset } = useUserTimezone();
useDocumentTitle('Reporting - Allocation');
const handleExport = (type: string) => {
if (type === 'png') {
chartRef.current?.exportChart();
}
};
return (
<Flex vertical>
<TimeReportingRightHeader
title={t('Members Time Sheet')}
exportType={[{ key: 'png', label: 'PNG' }]}
export={handleExport}
/>
<Card
style={{ borderRadius: '4px' }}
title={
<div style={{ padding: '16px 0' }}>
<Flex justify="space-between" align="center">
<TimeReportPageHeader />
<Tooltip
title={`All time logs are displayed in your local timezone. Times were logged by users in their respective timezones and converted for your viewing.`}
>
<Text type="secondary" style={{ fontSize: 12 }}>
<InfoCircleOutlined style={{ marginRight: 4 }} />
Timezone: {timezone} ({timezoneOffset})
</Text>
</Tooltip>
</Flex>
</div>
}
styles={{
body: {
maxHeight: 'calc(100vh - 300px)',
overflowY: 'auto',
padding: '16px',
},
}}
>
<MembersTimeSheet ref={chartRef} />
</Card>
</Flex>
);
};
export default MembersTimeReports;