Merge branch 'release-v2.1.4' of https://github.com/Worklenz/worklenz into feature/team-utilization
This commit is contained in:
8
worklenz-frontend/src/pages/GanttDemoPage.tsx
Normal file
8
worklenz-frontend/src/pages/GanttDemoPage.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import React from 'react';
|
||||
import { AdvancedGanttDemo } from '../components/advanced-gantt';
|
||||
|
||||
const GanttDemoPage: React.FC = () => {
|
||||
return <AdvancedGanttDemo />;
|
||||
};
|
||||
|
||||
export default GanttDemoPage;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
63
worklenz-frontend/src/pages/projects/ProjectGanttView.tsx
Normal file
63
worklenz-frontend/src/pages/projects/ProjectGanttView.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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 => ({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user