init
This commit is contained in:
34
worklenz-frontend/src/pages/404-page/404-page.tsx
Normal file
34
worklenz-frontend/src/pages/404-page/404-page.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import notFoundImg from '../../assets/images/not-found-img.png';
|
||||
import { Button, Flex, Layout, Typography } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const NotFoundPage = () => {
|
||||
// Localization
|
||||
const { t } = useTranslation('404-page');
|
||||
|
||||
return (
|
||||
<Layout
|
||||
style={{
|
||||
width: '100%',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginInline: 'auto',
|
||||
minHeight: '100vh',
|
||||
paddingInline: 24,
|
||||
}}
|
||||
>
|
||||
<img src={notFoundImg} alt="not found page" style={{ width: '100%', maxWidth: 800 }} />
|
||||
<Flex vertical gap={8} align="center">
|
||||
<Typography.Title style={{ marginBlockEnd: 0 }}>404</Typography.Title>
|
||||
<Typography.Text style={{ textAlign: 'center' }}>{t('doesNotExistText')}</Typography.Text>
|
||||
<Button type="primary" href="/worklenz/home">
|
||||
{t('backHomeButton')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFoundPage;
|
||||
150
worklenz-frontend/src/pages/account-setup/account-setup.css
Normal file
150
worklenz-frontend/src/pages/account-setup/account-setup.css
Normal file
@@ -0,0 +1,150 @@
|
||||
.ant-steps-item-finish .ant-steps-item-icon {
|
||||
border-color: #1890ff !important;
|
||||
}
|
||||
|
||||
.ant-steps-item-icon {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
margin: 0 8px 0 0 !important;
|
||||
font-size: 16px !important;
|
||||
line-height: 32px !important;
|
||||
text-align: center !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.25) !important;
|
||||
border-radius: 32px !important;
|
||||
transition:
|
||||
background-color 0.3s,
|
||||
border-color 0.3s !important;
|
||||
}
|
||||
|
||||
.ant-steps-item-wait .ant-steps-item-icon {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.dark-mode .ant-steps-item-wait .ant-steps-item-icon {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.progress-steps .ant-steps-item.ant-steps-item-process .ant-steps-item-title::after {
|
||||
background-color: #1677ff !important;
|
||||
width: 60px !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.progress-steps {
|
||||
width: 400px !important;
|
||||
}
|
||||
|
||||
.step {
|
||||
width: 400px !important;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
width: 400px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
.progress-steps {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.step-content {
|
||||
width: 200px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.organization-name-form {
|
||||
width: 400px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@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;
|
||||
}
|
||||
}
|
||||
301
worklenz-frontend/src/pages/account-setup/account-setup.tsx
Normal file
301
worklenz-frontend/src/pages/account-setup/account-setup.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Space, Steps, Button, Typography } from 'antd/es';
|
||||
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { setCurrentStep } 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 MembersStep from '@/components/account-setup/members-step';
|
||||
import {
|
||||
evt_account_setup_complete,
|
||||
evt_account_setup_skip_invite,
|
||||
evt_account_setup_visit,
|
||||
} from '@/shared/worklenz-analytics-events';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { verifyAuthentication } from '@/features/auth/authSlice';
|
||||
import { setUser } from '@/features/user/userSlice';
|
||||
import { IAuthorizeResponse } from '@/types/auth/login.types';
|
||||
import { RootState } from '@/app/store';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { getUserSession, setSession } from '@/utils/session-helper';
|
||||
import { validateEmail } from '@/utils/validateEmail';
|
||||
import { sanitizeInput } from '@/utils/sanitizeInput';
|
||||
import logo from '@/assets/images/logo.png';
|
||||
import logoDark from '@/assets/images/logo-dark-mode.png';
|
||||
|
||||
import './account-setup.css';
|
||||
import { IAccountSetupRequest } from '@/types/project-templates/project-templates.types';
|
||||
import { profileSettingsApiService } from '@/api/settings/profile/profile-settings.api.service';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
const AccountSetup: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
const { t } = useTranslation('account-setup');
|
||||
useDocumentTitle(t('setupYourAccount', 'Account Setup'));
|
||||
const navigate = useNavigate();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
const { currentStep, organizationName, projectName, templateId, tasks, teamMembers } =
|
||||
useSelector((state: RootState) => state.accountSetupReducer);
|
||||
const userDetails = getUserSession();
|
||||
const themeMode = useSelector((state: RootState) => state.themeReducer.mode);
|
||||
|
||||
const isDarkMode = themeMode === 'dark';
|
||||
const organizationNamePlaceholder = userDetails?.name ? `e.g. ${userDetails?.name}'s Team` : '';
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_account_setup_visit);
|
||||
const verifyAuthStatus = async () => {
|
||||
try {
|
||||
const response = (await dispatch(verifyAuthentication()).unwrap()).payload as IAuthorizeResponse;
|
||||
if (response?.authenticated) {
|
||||
setSession(response.user);
|
||||
dispatch(setUser(response.user));
|
||||
if (response?.user?.setup_completed) {
|
||||
navigate('/worklenz/home');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify authentication status', error);
|
||||
}
|
||||
};
|
||||
void verifyAuthStatus();
|
||||
}, [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 {
|
||||
const model: IAccountSetupRequest = {
|
||||
team_name: sanitizeInput(organizationName),
|
||||
project_name: sanitizeInput(projectName),
|
||||
tasks: tasks
|
||||
.map(task => sanitizeInput(task.value.trim()))
|
||||
.filter(task => task !== ''),
|
||||
team_members: skip
|
||||
? []
|
||||
: teamMembers
|
||||
.map(teamMember => sanitizeInput(teamMember.value.trim()))
|
||||
.filter(email => validateEmail(email)),
|
||||
};
|
||||
const res = await profileSettingsApiService.setupAccount(model);
|
||||
if (res.done && res.body.id) {
|
||||
trackMixpanelEvent(skip ? evt_account_setup_skip_invite : evt_account_setup_complete);
|
||||
navigate(`/worklenz/projects/${res.body.id}?tab=tasks-list&pinned_tab=tasks-list`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('completeAccountSetup', error);
|
||||
}
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{
|
||||
title: '',
|
||||
content: (
|
||||
<OrganizationStep
|
||||
onEnter={() => dispatch(setCurrentStep(currentStep + 1))}
|
||||
styles={styles}
|
||||
organizationNamePlaceholder={organizationNamePlaceholder}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
content: (
|
||||
<ProjectStep
|
||||
onEnter={() => dispatch(setCurrentStep(currentStep + 1))}
|
||||
styles={styles}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
content: (
|
||||
<TasksStep
|
||||
onEnter={() => dispatch(setCurrentStep(currentStep + 1))}
|
||||
styles={styles}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
content: <MembersStep isDarkMode={isDarkMode} styles={styles} />,
|
||||
},
|
||||
];
|
||||
|
||||
const isContinueDisabled = () => {
|
||||
switch (currentStep) {
|
||||
case 0:
|
||||
return !organizationName?.trim();
|
||||
case 1:
|
||||
return !projectName?.trim() && !templateId;
|
||||
case 2:
|
||||
return tasks.length === 0 || tasks.every(task => !task.value?.trim());
|
||||
case 3:
|
||||
return (
|
||||
teamMembers.length > 0 && !teamMembers.some(member => validateEmail(member.value?.trim()))
|
||||
);
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep === 3) {
|
||||
completeAccountSetup();
|
||||
} else {
|
||||
dispatch(setCurrentStep(currentStep + 1));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div>
|
||||
<img src={isDarkMode ? logoDark : logo} alt="Logo" width={235} height={50} />
|
||||
</div>
|
||||
<Title level={5} style={{ textAlign: 'center', margin: '4px 0 24px' }}>
|
||||
{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}
|
||||
</div>
|
||||
<div style={styles.actionButtons} className="setup-action-buttons">
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: currentStep !== 0 ? 'space-between' : 'flex-end',
|
||||
}}
|
||||
>
|
||||
{currentStep !== 0 && (
|
||||
<div>
|
||||
<Button
|
||||
style={{ padding: 0 }}
|
||||
type="link"
|
||||
className="my-7"
|
||||
onClick={() => dispatch(setCurrentStep(currentStep - 1))}
|
||||
>
|
||||
{t('goBack')}
|
||||
</Button>
|
||||
{currentStep === 3 && (
|
||||
<Button
|
||||
style={{ color: isDarkMode ? '' : '#00000073', fontWeight: 500 }}
|
||||
type="link"
|
||||
className="my-7"
|
||||
onClick={() => completeAccountSetup(true)}
|
||||
>
|
||||
{t('skipForNow')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
disabled={isContinueDisabled()}
|
||||
className="mt-7 mb-7"
|
||||
onClick={nextStep}
|
||||
>
|
||||
{t('continue')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccountSetup;
|
||||
@@ -0,0 +1,60 @@
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
CreditCardOutlined,
|
||||
ProfileOutlined,
|
||||
TeamOutlined,
|
||||
UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import React, { ReactNode } from 'react';
|
||||
import Overview from './overview/overview';
|
||||
import Users from './users/users';
|
||||
import Teams from './teams/teams';
|
||||
import Billing from './billing/billing';
|
||||
import Projects from './projects/projects';
|
||||
|
||||
// type of a menu item in admin center sidebar
|
||||
type AdminCenterMenuItems = {
|
||||
key: string;
|
||||
name: string;
|
||||
endpoint: string;
|
||||
icon: ReactNode;
|
||||
element: ReactNode;
|
||||
};
|
||||
// settings all element items use for sidebar and routes
|
||||
export const adminCenterItems: AdminCenterMenuItems[] = [
|
||||
{
|
||||
key: 'overview',
|
||||
name: 'overview',
|
||||
endpoint: 'overview',
|
||||
icon: React.createElement(AppstoreOutlined),
|
||||
element: React.createElement(Overview),
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
name: 'users',
|
||||
endpoint: 'users',
|
||||
icon: React.createElement(UserOutlined),
|
||||
element: React.createElement(Users),
|
||||
},
|
||||
{
|
||||
key: 'teams',
|
||||
name: 'teams',
|
||||
endpoint: 'teams',
|
||||
icon: React.createElement(TeamOutlined),
|
||||
element: React.createElement(Teams),
|
||||
},
|
||||
{
|
||||
key: 'projects',
|
||||
name: 'projects',
|
||||
endpoint: 'projects',
|
||||
icon: React.createElement(ProfileOutlined),
|
||||
element: React.createElement(Projects),
|
||||
},
|
||||
{
|
||||
key: 'billing',
|
||||
name: 'billing',
|
||||
endpoint: 'billing',
|
||||
icon: React.createElement(CreditCardOutlined),
|
||||
element: React.createElement(Billing),
|
||||
},
|
||||
];
|
||||
32
worklenz-frontend/src/pages/admin-center/billing/billing.tsx
Normal file
32
worklenz-frontend/src/pages/admin-center/billing/billing.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { PageHeader } from '@ant-design/pro-components';
|
||||
import { Tabs, TabsProps } from 'antd';
|
||||
import React from 'react';
|
||||
import CurrentBill from '@/components/admin-center/billing/current-bill';
|
||||
import Configuration from '@/components/admin-center/configuration/configuration';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const Billing: React.FC = () => {
|
||||
const { t } = useTranslation('admin-center/current-bill');;
|
||||
|
||||
const items: TabsProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
label: t('currentBill'),
|
||||
children: <CurrentBill />,
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: t('configuration'),
|
||||
children: <Configuration />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<PageHeader title={<span>{t('title')}</span>} style={{ padding: '16px 0' }} />
|
||||
<Tabs defaultActiveKey="1" items={items} destroyInactiveTabPane />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Billing;
|
||||
@@ -0,0 +1,90 @@
|
||||
import { EditOutlined, MailOutlined, PhoneOutlined } from '@ant-design/icons';
|
||||
import { PageHeader } from '@ant-design/pro-components';
|
||||
import { Button, Card, Input, Space, Tooltip, Typography } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import OrganizationAdminsTable from '@/components/admin-center/overview/organization-admins-table/organization-admins-table';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { RootState } from '@/app/store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import OrganizationName from '@/components/admin-center/overview/organization-name/organization-name';
|
||||
import OrganizationOwner from '@/components/admin-center/overview/organization-owner/organization-owner';
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import { IOrganization, IOrganizationAdmin } from '@/types/admin-center/admin-center.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { tr } from 'date-fns/locale';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const Overview: React.FC = () => {
|
||||
const [organization, setOrganization] = useState<IOrganization | null>(null);
|
||||
const [organizationAdmins, setOrganizationAdmins] = useState<IOrganizationAdmin[] | null>(null);
|
||||
const [loadingAdmins, setLoadingAdmins] = useState(false);
|
||||
|
||||
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
|
||||
const { t } = useTranslation('admin-center/overview');
|
||||
|
||||
const getOrganizationDetails = async () => {
|
||||
try {
|
||||
const res = await adminCenterApiService.getOrganizationDetails();
|
||||
if (res.done) {
|
||||
setOrganization(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error getting organization details', error);
|
||||
}
|
||||
};
|
||||
|
||||
const getOrganizationAdmins = async () => {
|
||||
setLoadingAdmins(true);
|
||||
try {
|
||||
const res = await adminCenterApiService.getOrganizationAdmins();
|
||||
if (res.done) {
|
||||
setOrganizationAdmins(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error getting organization admins', error);
|
||||
} finally {
|
||||
setLoadingAdmins(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getOrganizationDetails();
|
||||
getOrganizationAdmins();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<PageHeader title={<span>{t('overview')}</span>} style={{ padding: '16px 0' }} />
|
||||
|
||||
<Space direction="vertical" style={{ width: '100%' }} size={22}>
|
||||
<OrganizationName
|
||||
themeMode={themeMode}
|
||||
name={organization?.name || ''}
|
||||
t={t}
|
||||
refetch={getOrganizationDetails}
|
||||
/>
|
||||
|
||||
<OrganizationOwner
|
||||
themeMode={themeMode}
|
||||
organization={organization}
|
||||
t={t}
|
||||
refetch={getOrganizationDetails}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{t('admins')}
|
||||
</Typography.Title>
|
||||
<OrganizationAdminsTable
|
||||
organizationAdmins={organizationAdmins}
|
||||
loading={loadingAdmins}
|
||||
themeMode={themeMode}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Overview;
|
||||
@@ -0,0 +1,16 @@
|
||||
.project-table-row .row-buttons {
|
||||
visibility: hidden;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.project-table-row:hover .project-names,
|
||||
.project-table-row:hover .project-team,
|
||||
.project-table-row:hover .project-member-count,
|
||||
.project-table-row:hover .project-created-at {
|
||||
color: #0d6efd;
|
||||
}
|
||||
|
||||
.project-table-row:hover .row-buttons {
|
||||
visibility: visible;
|
||||
}
|
||||
227
worklenz-frontend/src/pages/admin-center/projects/projects.tsx
Normal file
227
worklenz-frontend/src/pages/admin-center/projects/projects.tsx
Normal file
@@ -0,0 +1,227 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RootState } from '@/app/store';
|
||||
import { IOrganizationProject } from '@/types/admin-center/admin-center.types';
|
||||
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { deleteProject } from '@features/projects/projectsSlice';
|
||||
import './projects.css';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Table,
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { DeleteOutlined, SearchOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { PageHeader } from '@ant-design/pro-components';
|
||||
import { projectsApiService } from '@/api/projects/projects.api.service';
|
||||
|
||||
const Projects: React.FC = () => {
|
||||
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const isTablet = useMediaQuery({ query: '(min-width: 1000px)' });
|
||||
const [projects, setProjects] = useState<IOrganizationProject[]>([]);
|
||||
const [requestParams, setRequestParams] = useState({
|
||||
total: 0,
|
||||
index: 1,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
field: 'name',
|
||||
order: 'desc',
|
||||
search: '',
|
||||
});
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { t } = useTranslation('admin-center/projects');
|
||||
|
||||
const fetchProjects = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await adminCenterApiService.getOrganizationProjects(requestParams);
|
||||
if (res.done) {
|
||||
setRequestParams(prev => ({ ...prev, total: res.body.total ?? 0 }));
|
||||
setProjects(res.body.data ?? []);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching teams', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteProject = async (id: string) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await projectsApiService.deleteProject(id);
|
||||
} catch (error) {
|
||||
logger.error('Error deleting project', error);
|
||||
} finally {
|
||||
fetchProjects();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjects();
|
||||
}, [
|
||||
requestParams.search,
|
||||
requestParams.index,
|
||||
requestParams.size,
|
||||
requestParams.field,
|
||||
requestParams.order,
|
||||
]);
|
||||
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
title: 'Project name',
|
||||
key: 'projectName',
|
||||
render: (record: IOrganizationProject) => (
|
||||
<Typography.Text
|
||||
className="project-names"
|
||||
style={{ fontSize: `${isTablet ? '14px' : '10px'}` }}
|
||||
>
|
||||
{record.name}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'Team',
|
||||
key: 'team',
|
||||
render: (record: IOrganizationProject) => (
|
||||
<Typography.Text
|
||||
className="project-team"
|
||||
style={{ fontSize: `${isTablet ? '14px' : '10px'}` }}
|
||||
>
|
||||
{record.team_name}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <span style={{ display: 'flex', justifyContent: 'center' }}>{t('membersCount')}</span>,
|
||||
key: 'membersCount',
|
||||
render: (record: IOrganizationProject) => (
|
||||
<Typography.Text
|
||||
className="project-member-count"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
fontSize: `${isTablet ? '14px' : '10px'}`,
|
||||
}}
|
||||
>
|
||||
{record.member_count ?? 0}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <span style={{ display: 'flex', justifyContent: 'center' }}>Created at</span>,
|
||||
key: 'createdAt',
|
||||
render: (record: IOrganizationProject) => (
|
||||
<Typography.Text
|
||||
className="project-created-at"
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'right',
|
||||
fontSize: `${isTablet ? '14px' : '10px'}`,
|
||||
}}
|
||||
>
|
||||
{formatDateTimeWithLocale(record.created_at ?? '')}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
key: 'button',
|
||||
render: (record: IOrganizationProject) => (
|
||||
<div className="row-buttons">
|
||||
<Tooltip title={t('delete')}>
|
||||
<Popconfirm
|
||||
title={t('confirm')}
|
||||
description={t('deleteProject')}
|
||||
onConfirm={() => deleteProject(record.id ?? '')}
|
||||
>
|
||||
<Button size="small">
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<PageHeader title={<span>Projects</span>} style={{ padding: '16px 0' }} />
|
||||
<PageHeader
|
||||
style={{
|
||||
paddingLeft: 0,
|
||||
paddingTop: 0,
|
||||
paddingRight: '24px',
|
||||
paddingBottom: '16px',
|
||||
}}
|
||||
subTitle={
|
||||
<span
|
||||
style={{
|
||||
color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
|
||||
fontWeight: 500,
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
{projects.length} projects
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Flex gap={8} align="center">
|
||||
<Tooltip title={t('refreshProjects')}>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<SyncOutlined spin={isLoading} />}
|
||||
onClick={() => fetchProjects()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Input
|
||||
placeholder={t('searchPlaceholder')}
|
||||
suffix={<SearchOutlined />}
|
||||
type="text"
|
||||
value={requestParams.search}
|
||||
onChange={e => setRequestParams(prev => ({ ...prev, search: e.target.value }))}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
|
||||
<Card>
|
||||
<Table<IOrganizationProject>
|
||||
rowClassName="project-table-row"
|
||||
className="project-table"
|
||||
columns={columns}
|
||||
dataSource={projects}
|
||||
rowKey={record => record.id ?? ''}
|
||||
loading={isLoading}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 20,
|
||||
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
|
||||
size: 'small',
|
||||
total: requestParams.total,
|
||||
current: requestParams.index,
|
||||
pageSize: requestParams.size,
|
||||
onChange: (page, pageSize) =>
|
||||
setRequestParams(prev => ({ ...prev, index: page, size: pageSize })),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Projects;
|
||||
@@ -0,0 +1,3 @@
|
||||
.admin-center-sidebar-button {
|
||||
border-radius: 0.5rem !important;
|
||||
}
|
||||
55
worklenz-frontend/src/pages/admin-center/sidebar/sidebar.tsx
Normal file
55
worklenz-frontend/src/pages/admin-center/sidebar/sidebar.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { RightOutlined } from '@ant-design/icons';
|
||||
import { ConfigProvider, Flex, Menu, MenuProps } from 'antd';
|
||||
import React from 'react';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { colors } from '../../../styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { adminCenterItems } from '../admin-center-constants';
|
||||
import './sidebar.css';
|
||||
|
||||
const AdminCenterSidebar: React.FC = () => {
|
||||
const { t } = useTranslation('admin-center/sidebar');
|
||||
const location = useLocation();
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
const menuItems = adminCenterItems;
|
||||
|
||||
const items: MenuItem[] = [
|
||||
...menuItems.map(item => ({
|
||||
key: item.key,
|
||||
label: (
|
||||
<Flex gap={8} justify="space-between" className="admin-center-sidebar-button">
|
||||
<Flex gap={8}>
|
||||
{item.icon}
|
||||
<Link to={`/worklenz/admin-center/${item.endpoint}`}>{t(item.name)}</Link>
|
||||
</Flex>
|
||||
<RightOutlined style={{ fontSize: 12, fontWeight: 'bold' }} />
|
||||
</Flex>
|
||||
),
|
||||
})),
|
||||
];
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Menu: {
|
||||
itemHoverBg: colors.transparent,
|
||||
itemHoverColor: colors.skyBlue,
|
||||
borderRadius: 12,
|
||||
itemMarginBlock: 4,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
items={items}
|
||||
selectedKeys={[location.pathname.split('/worklenz/admin-center/')[1] || '']}
|
||||
mode="vertical"
|
||||
style={{ border: 'none', width: '100%' }}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminCenterSidebar;
|
||||
7
worklenz-frontend/src/pages/admin-center/teams/teams.css
Normal file
7
worklenz-frontend/src/pages/admin-center/teams/teams.css
Normal file
@@ -0,0 +1,7 @@
|
||||
.team-table-row .row-buttons {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
.team-table-row:hover .row-buttons {
|
||||
visibility: visible;
|
||||
}
|
||||
132
worklenz-frontend/src/pages/admin-center/teams/teams.tsx
Normal file
132
worklenz-frontend/src/pages/admin-center/teams/teams.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { SearchOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { PageHeader } from '@ant-design/pro-components';
|
||||
import { Button, Flex, Input, Tooltip } from 'antd';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import {
|
||||
adminCenterApiService,
|
||||
IOrganizationTeamRequestParams,
|
||||
} from '@/api/admin-center/admin-center.api.service';
|
||||
|
||||
import { IOrganizationTeam } from '@/types/admin-center/admin-center.types';
|
||||
import './teams.css';
|
||||
import TeamsTable from '@/components/admin-center/teams/teams-table/teams-table';
|
||||
|
||||
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { RootState } from '@/app/store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import AddTeamDrawer from '@/components/admin-center/teams/add-team-drawer/add-team-drawer';
|
||||
|
||||
export interface IRequestParams extends IOrganizationTeamRequestParams {
|
||||
total: number;
|
||||
}
|
||||
|
||||
const Teams: React.FC = () => {
|
||||
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
|
||||
const { t } = useTranslation('admin-center/teams');
|
||||
|
||||
const [showAddTeamDrawer, setShowAddTeamDrawer] = useState(false);
|
||||
|
||||
const [teams, setTeams] = useState<IOrganizationTeam[]>([]);
|
||||
const [currentTeam, setCurrentTeam] = useState<IOrganizationTeam | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const [requestParams, setRequestParams] = useState<IRequestParams>({
|
||||
total: 0,
|
||||
index: 1,
|
||||
size: DEFAULT_PAGE_SIZE,
|
||||
field: 'name',
|
||||
order: 'desc',
|
||||
search: '',
|
||||
});
|
||||
|
||||
const fetchTeams = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await adminCenterApiService.getOrganizationTeams(requestParams);
|
||||
if (res.done) {
|
||||
setRequestParams(prev => ({ ...prev, total: res.body.total ?? 0 }));
|
||||
const mergedTeams = [...(res.body.data ?? [])];
|
||||
if (res.body.current_team_data) {
|
||||
mergedTeams.unshift(res.body.current_team_data);
|
||||
}
|
||||
setTeams(mergedTeams);
|
||||
setCurrentTeam(res.body.current_team_data ?? null);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching teams', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTeams();
|
||||
}, [requestParams.search]);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<PageHeader title={<span>{t('title')}</span>} style={{ padding: '16px 0' }} />
|
||||
<PageHeader
|
||||
style={{
|
||||
paddingLeft: 0,
|
||||
paddingTop: 0,
|
||||
paddingRight: '24px',
|
||||
paddingBottom: '16px',
|
||||
}}
|
||||
subTitle={
|
||||
<span
|
||||
style={{
|
||||
color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
|
||||
fontWeight: 500,
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
{requestParams.total} {t('subtitle')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Flex gap={8} align="center">
|
||||
<Tooltip title={t('tooltip')}>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<SyncOutlined spin={isLoading} />}
|
||||
onClick={() => fetchTeams()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Input
|
||||
placeholder={t('placeholder')}
|
||||
suffix={<SearchOutlined />}
|
||||
type="text"
|
||||
value={requestParams.search ?? ''}
|
||||
onChange={e => setRequestParams(prev => ({ ...prev, search: e.target.value }))}
|
||||
/>
|
||||
<Button type="primary" onClick={() => setShowAddTeamDrawer(true)}>
|
||||
{t('addTeam')}
|
||||
</Button>
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
|
||||
<TeamsTable
|
||||
teams={teams}
|
||||
currentTeam={currentTeam}
|
||||
t={t}
|
||||
loading={isLoading}
|
||||
reloadTeams={fetchTeams}
|
||||
/>
|
||||
|
||||
<AddTeamDrawer
|
||||
isDrawerOpen={showAddTeamDrawer}
|
||||
onClose={() => setShowAddTeamDrawer(false)}
|
||||
reloadTeams={fetchTeams}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Teams;
|
||||
142
worklenz-frontend/src/pages/admin-center/users/users.tsx
Normal file
142
worklenz-frontend/src/pages/admin-center/users/users.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { SearchOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { PageHeader } from '@ant-design/pro-components';
|
||||
import { Button, Card, Flex, Input, Table, TableProps, Tooltip, Typography } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { RootState } from '@/app/store';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
|
||||
import { IOrganizationUser } from '@/types/admin-center/admin-center.types';
|
||||
import { DEFAULT_PAGE_SIZE, PAGE_SIZE_OPTIONS } from '@/shared/constants';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
|
||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||
|
||||
const Users: React.FC = () => {
|
||||
const { t } = useTranslation('admin-center/users');
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [users, setUsers] = useState<IOrganizationUser[]>([]);
|
||||
const [requestParams, setRequestParams] = useState({
|
||||
total: 0,
|
||||
page: 1,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
sort: 'name',
|
||||
order: 'desc',
|
||||
searchTerm: '',
|
||||
});
|
||||
|
||||
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await adminCenterApiService.getOrganizationUsers(requestParams);
|
||||
if (res.done) {
|
||||
setUsers(res.body.data ?? []);
|
||||
setRequestParams(prev => ({ ...prev, total: res.body.total ?? 0 }));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching users', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableProps<IOrganizationUser>['columns'] = [
|
||||
{
|
||||
title: t('user'),
|
||||
dataIndex: 'user',
|
||||
key: 'user',
|
||||
render: (_, record) => (
|
||||
<Flex gap={8} align="center">
|
||||
<SingleAvatar avatarUrl={record.avatar_url} name={record.name} />
|
||||
<Typography.Text>{record.name}</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('email'),
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
render: text => (
|
||||
<span className="email-hover">
|
||||
<Typography.Text copyable={{ text }}>
|
||||
{text}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: t('lastActivity'),
|
||||
dataIndex: 'last_logged',
|
||||
key: 'last_logged',
|
||||
render: text => <span>{formatDateTimeWithLocale(text) || '-'}</span>,
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [requestParams.searchTerm, requestParams.page, requestParams.pageSize]);
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<PageHeader title={<span>{t('title')}</span>} style={{ padding: '16px 0' }} />
|
||||
<PageHeader
|
||||
style={{
|
||||
paddingLeft: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: '16px',
|
||||
}}
|
||||
subTitle={
|
||||
<span
|
||||
style={{
|
||||
color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
|
||||
fontWeight: 500,
|
||||
fontSize: '16px',
|
||||
}}
|
||||
>
|
||||
{requestParams.total} {t('subTitle')}
|
||||
</span>
|
||||
}
|
||||
extra={
|
||||
<Flex gap={8} align="center">
|
||||
<Tooltip title={t('refresh')}>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<SyncOutlined spin={isLoading} />}
|
||||
onClick={() => fetchUsers()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Input
|
||||
placeholder={t('placeholder')}
|
||||
suffix={<SearchOutlined />}
|
||||
type="text"
|
||||
value={requestParams.searchTerm}
|
||||
onChange={e => setRequestParams(prev => ({ ...prev, searchTerm: e.target.value }))}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
<Card>
|
||||
<Table
|
||||
rowClassName="users-table-row"
|
||||
size="small"
|
||||
columns={columns}
|
||||
dataSource={users}
|
||||
pagination={{
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE,
|
||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||
size: 'small',
|
||||
showSizeChanger: true,
|
||||
total: requestParams.total,
|
||||
onChange: (page, pageSize) => setRequestParams(prev => ({ ...prev, page, pageSize })),
|
||||
}}
|
||||
loading={isLoading}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Users;
|
||||
81
worklenz-frontend/src/pages/auth/authenticating.tsx
Normal file
81
worklenz-frontend/src/pages/auth/authenticating.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Card, Flex, Spin, Typography } from 'antd/es';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
import { verifyAuthentication } from '@/features/auth/authSlice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setSession } from '@/utils/session-helper';
|
||||
import { setUser } from '@/features/user/userSlice';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { WORKLENZ_REDIRECT_PROJ_KEY } from '@/shared/constants';
|
||||
|
||||
const REDIRECT_DELAY = 500; // Delay in milliseconds before redirecting
|
||||
|
||||
const AuthenticatingPage: React.FC = () => {
|
||||
const { t } = useTranslation('auth/auth-common');
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleSuccessRedirect = () => {
|
||||
const project = localStorage.getItem(WORKLENZ_REDIRECT_PROJ_KEY);
|
||||
if (project) {
|
||||
localStorage.removeItem(WORKLENZ_REDIRECT_PROJ_KEY);
|
||||
window.location.href = `/worklenz/projects/${project}?tab=tasks-list`;
|
||||
return;
|
||||
}
|
||||
|
||||
navigate('/worklenz/home');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleAuthentication = async () => {
|
||||
try {
|
||||
const session = await dispatch(verifyAuthentication()).unwrap();
|
||||
|
||||
if (!session.authenticated) {
|
||||
return navigate('/auth/login');
|
||||
}
|
||||
|
||||
// Set user session and state
|
||||
setSession(session.user);
|
||||
dispatch(setUser(session.user));
|
||||
|
||||
if (!session.user.setup_completed) {
|
||||
return navigate('/worklenz/setup');
|
||||
}
|
||||
|
||||
// Redirect based on setup status
|
||||
setTimeout(() => {
|
||||
handleSuccessRedirect();
|
||||
}, REDIRECT_DELAY);
|
||||
} catch (error) {
|
||||
logger.error('Authentication verification failed:', error);
|
||||
navigate('/auth/login');
|
||||
}
|
||||
};
|
||||
|
||||
void handleAuthentication();
|
||||
}, [dispatch, navigate]);
|
||||
|
||||
const cardStyles = {
|
||||
width: '100%',
|
||||
boxShadow: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={cardStyles}>
|
||||
<Flex vertical align="center" gap="middle">
|
||||
<Spin size="large" />
|
||||
<Typography.Title level={3}>
|
||||
{t('authenticating', { defaultValue: 'Authenticating...' })}
|
||||
</Typography.Title>
|
||||
<Typography.Text>
|
||||
{t('gettingThingsReady', { defaultValue: 'Getting things ready for you...' })}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AuthenticatingPage;
|
||||
158
worklenz-frontend/src/pages/auth/forgot-password-page.tsx
Normal file
158
worklenz-frontend/src/pages/auth/forgot-password-page.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { UserOutlined } from '@ant-design/icons';
|
||||
import { Form, Card, Input, Flex, Button, Typography, Result } from 'antd/es';
|
||||
|
||||
import PageHeader from '@components/AuthPageHeader';
|
||||
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { evt_forgot_password_page_visit, evt_reset_password_click } from '@/shared/worklenz-analytics-events';
|
||||
import { resetPassword, verifyAuthentication } from '@features/auth/authSlice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setSession } from '@/utils/session-helper';
|
||||
import { setUser } from '@features/user/userSlice';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
const ForgotPasswordPage = () => {
|
||||
const [form] = Form.useForm();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [urlParams, setUrlParams] = useState({
|
||||
teamId: '',
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
useDocumentTitle('Forgot Password');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Localization
|
||||
const { t } = useTranslation('auth/forgot-password');
|
||||
|
||||
// media queries from react-responsive package
|
||||
const isMobile = useMediaQuery({ query: '(max-width: 576px)' });
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_forgot_password_page_visit);
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
setUrlParams({
|
||||
teamId: searchParams.get('team') || '',
|
||||
});
|
||||
const verifyAuthStatus = async () => {
|
||||
try {
|
||||
const session = await dispatch(verifyAuthentication()).unwrap();
|
||||
if (session?.authenticated) {
|
||||
setSession(session.user);
|
||||
dispatch(setUser(session.user));
|
||||
navigate('/worklenz/home');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify authentication status', error);
|
||||
}
|
||||
};
|
||||
void verifyAuthStatus();
|
||||
}, [dispatch, navigate, trackMixpanelEvent]);
|
||||
|
||||
const onFinish = useCallback(
|
||||
async (values: any) => {
|
||||
if (values.email.trim() === '') return;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await dispatch(resetPassword(values.email)).unwrap();
|
||||
if (result.done) {
|
||||
trackMixpanelEvent(evt_reset_password_click);
|
||||
setIsSuccess(true);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset password', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[dispatch, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
paddingInline: isMobile ? 24 : 48,
|
||||
},
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
{isSuccess ? (
|
||||
<Result status="success" title={t('successTitle')} subTitle={t('successMessage')} />
|
||||
) : (
|
||||
<>
|
||||
<PageHeader description={t('headerDescription')} />
|
||||
<Form
|
||||
name="forgot-password"
|
||||
form={form}
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
requiredMark="optional"
|
||||
initialValues={{ remember: true }}
|
||||
onFinish={onFinish}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Form.Item
|
||||
name="email"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
type: 'email',
|
||||
message: t('emailRequired'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder={t('emailPlaceholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Flex vertical gap={8}>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
loading={isLoading}
|
||||
style={{ borderRadius: 4 }}
|
||||
>
|
||||
{t('resetPasswordButton')}
|
||||
</Button>
|
||||
<Typography.Text style={{ textAlign: 'center' }}>{t('orText')}</Typography.Text>
|
||||
<Link to="/auth/login">
|
||||
<Button
|
||||
block
|
||||
type="default"
|
||||
size="large"
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{t('returnToLoginButton')}
|
||||
</Button>
|
||||
</Link>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ForgotPasswordPage;
|
||||
41
worklenz-frontend/src/pages/auth/logging-out.tsx
Normal file
41
worklenz-frontend/src/pages/auth/logging-out.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Card, Flex, Spin, Typography } from 'antd/es';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import { authApiService } from '@/api/auth/auth.api.service';
|
||||
|
||||
const LoggingOutPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuthService();
|
||||
const { t } = useTranslation('auth/auth-common');
|
||||
const isMobile = useMediaQuery({ query: '(max-width: 576px)' });
|
||||
|
||||
useEffect(() => {
|
||||
const logout = async () => {
|
||||
await auth.signOut();
|
||||
await authApiService.logout();
|
||||
setTimeout(() => {
|
||||
window.location.href = '/';
|
||||
}, 1500);
|
||||
};
|
||||
void logout();
|
||||
}, [auth, navigate]);
|
||||
|
||||
const cardStyles = {
|
||||
width: '100%',
|
||||
boxShadow: 'none',
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={cardStyles}>
|
||||
<Flex vertical align="center" justify="center" gap="middle">
|
||||
<Spin size="large" />
|
||||
<Typography.Title level={3}>{t('loggingOut')}</Typography.Title>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoggingOutPage;
|
||||
256
worklenz-frontend/src/pages/auth/login-page.tsx
Normal file
256
worklenz-frontend/src/pages/auth/login-page.tsx
Normal file
@@ -0,0 +1,256 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Card, Input, Flex, Checkbox, Button, Typography, Space, Form, message } from 'antd/es';
|
||||
import { Rule } from 'antd/es/form';
|
||||
|
||||
import { LockOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import PageHeader from '@components/AuthPageHeader';
|
||||
import googleIcon from '@assets/images/google-icon.png';
|
||||
import { login, verifyAuthentication } from '@/features/auth/authSlice';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { setUser } from '@/features/user/userSlice';
|
||||
import { setSession } from '@/utils/session-helper';
|
||||
import {
|
||||
evt_login_page_visit,
|
||||
evt_login_with_email_click,
|
||||
evt_login_with_google_click,
|
||||
evt_login_remember_me_click,
|
||||
} from '@/shared/worklenz-analytics-events';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import alertService from '@/services/alerts/alertService';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { WORKLENZ_REDIRECT_PROJ_KEY } from '@/shared/constants';
|
||||
|
||||
interface LoginFormValues {
|
||||
email: string;
|
||||
password: string;
|
||||
remember?: boolean;
|
||||
}
|
||||
|
||||
const LoginPage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('auth/login');
|
||||
const isMobile = useMediaQuery({ query: '(max-width: 576px)' });
|
||||
const dispatch = useAppDispatch();
|
||||
const { isLoading } = useAppSelector(state => state.auth);
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const [form] = Form.useForm<LoginFormValues>();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const [urlParams, setUrlParams] = useState({
|
||||
teamId: '',
|
||||
projectId: '',
|
||||
});
|
||||
|
||||
const enableGoogleLogin = import.meta.env.VITE_ENABLE_GOOGLE_LOGIN === 'true' || false;
|
||||
|
||||
useDocumentTitle('Login');
|
||||
|
||||
const validationRules = {
|
||||
email: [
|
||||
{ required: true, message: t('emailRequired') },
|
||||
{ type: 'email', message: t('validationMessages.email') },
|
||||
],
|
||||
password: [
|
||||
{ required: true, message: t('passwordRequired') },
|
||||
{ min: 8, message: t('validationMessages.password') },
|
||||
],
|
||||
};
|
||||
|
||||
const verifyAuthStatus = async () => {
|
||||
try {
|
||||
const session = await dispatch(verifyAuthentication()).unwrap();
|
||||
|
||||
if (session?.authenticated) {
|
||||
setSession(session.user);
|
||||
dispatch(setUser(session.user));
|
||||
navigate('/worklenz/home');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to verify authentication status', error);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_login_page_visit);
|
||||
if (currentSession && !currentSession?.setup_completed) {
|
||||
navigate('/worklenz/setup');
|
||||
return;
|
||||
}
|
||||
void verifyAuthStatus();
|
||||
}, [dispatch, navigate, trackMixpanelEvent]);
|
||||
|
||||
const onFinish = useCallback(
|
||||
async (values: LoginFormValues) => {
|
||||
try {
|
||||
trackMixpanelEvent(evt_login_with_email_click);
|
||||
|
||||
// if (teamId) {
|
||||
// localStorage.setItem(WORKLENZ_REDIRECT_PROJ_KEY, teamId);
|
||||
// }
|
||||
|
||||
const result = await dispatch(login(values)).unwrap();
|
||||
if (result.authenticated) {
|
||||
message.success(t('successMessage'));
|
||||
setSession(result.user);
|
||||
dispatch(setUser(result.user));
|
||||
navigate('/auth/authenticating');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Login failed', error);
|
||||
alertService.error(
|
||||
t('errorMessages.loginErrorTitle'),
|
||||
t('errorMessages.loginErrorMessage')
|
||||
);
|
||||
}
|
||||
},
|
||||
[dispatch, navigate, t, trackMixpanelEvent]
|
||||
);
|
||||
|
||||
const handleGoogleLogin = useCallback(() => {
|
||||
try {
|
||||
trackMixpanelEvent(evt_login_with_google_click);
|
||||
window.location.href = `${import.meta.env.VITE_API_URL}/secure/google`;
|
||||
} catch (error) {
|
||||
logger.error('Google login failed', error);
|
||||
}
|
||||
}, [trackMixpanelEvent, t]);
|
||||
|
||||
const handleRememberMeChange = useCallback(
|
||||
(checked: boolean) => {
|
||||
trackMixpanelEvent(evt_login_remember_me_click, { checked });
|
||||
},
|
||||
[trackMixpanelEvent]
|
||||
);
|
||||
|
||||
const styles = {
|
||||
card: {
|
||||
width: '100%',
|
||||
boxShadow: 'none',
|
||||
},
|
||||
button: {
|
||||
borderRadius: 4,
|
||||
},
|
||||
googleButton: {
|
||||
borderRadius: 4,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
link: {
|
||||
fontSize: 14,
|
||||
},
|
||||
googleIcon: {
|
||||
maxWidth: 20,
|
||||
marginRight: 8,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={styles.card}
|
||||
styles={{ body: { paddingInline: isMobile ? 24 : 48 } }}
|
||||
variant="outlined"
|
||||
>
|
||||
<PageHeader description={t('headerDescription')} />
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
name="login"
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
requiredMark="optional"
|
||||
initialValues={{ remember: true }}
|
||||
onFinish={onFinish}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Form.Item name="email" rules={validationRules.email as Rule[]}>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder={t('emailPlaceholder')}
|
||||
size="large"
|
||||
style={styles.button}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="password" rules={validationRules.password}>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder={t('passwordPlaceholder')}
|
||||
size="large"
|
||||
style={styles.button}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Flex justify="space-between" align="center">
|
||||
<Form.Item name="remember" valuePropName="checked" noStyle>
|
||||
<Checkbox onChange={e => handleRememberMeChange(e.target.checked)}>
|
||||
{t('rememberMe')}
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Link
|
||||
to="/auth/forgot-password"
|
||||
className="ant-typography ant-typography-link blue-link"
|
||||
style={styles.link}
|
||||
>
|
||||
{t('forgotPasswordButton')}
|
||||
</Link>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Flex vertical gap={8}>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
loading={isLoading}
|
||||
style={styles.button}
|
||||
>
|
||||
{t('loginButton')}
|
||||
</Button>
|
||||
|
||||
{enableGoogleLogin && (
|
||||
<>
|
||||
<Typography.Text style={{ textAlign: 'center' }}>{t('orText')}</Typography.Text>
|
||||
|
||||
<Button
|
||||
block
|
||||
type="default"
|
||||
size="large"
|
||||
onClick={handleGoogleLogin}
|
||||
style={styles.googleButton}
|
||||
>
|
||||
<img src={googleIcon} alt="Google" style={styles.googleIcon} />
|
||||
{t('signInWithGoogleButton')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Typography.Text style={styles.link}>{t('dontHaveAccountText')}</Typography.Text>
|
||||
<Link
|
||||
to="/auth/signup"
|
||||
className="ant-typography ant-typography-link blue-link"
|
||||
style={styles.link}
|
||||
>
|
||||
{t('signupButton')}
|
||||
</Link>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
366
worklenz-frontend/src/pages/auth/signup-page.tsx
Normal file
366
worklenz-frontend/src/pages/auth/signup-page.tsx
Normal file
@@ -0,0 +1,366 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import { LockOutlined, MailOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { Form, Card, Input, Flex, Button, Typography, Space, message } from 'antd/es';
|
||||
import { Rule } from 'antd/es/form';
|
||||
|
||||
import googleIcon from '@/assets/images/google-icon.png';
|
||||
import PageHeader from '@components/AuthPageHeader';
|
||||
|
||||
import { authApiService } from '@/api/auth/auth.api.service';
|
||||
import { IUserSignUpRequest } from '@/types/auth/signup.types';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { signUp } from '@/features/auth/authSlice';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import {
|
||||
evt_signup_page_visit,
|
||||
evt_signup_with_email_click,
|
||||
evt_signup_with_google_click,
|
||||
} from '@/shared/worklenz-analytics-events';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import alertService from '@/services/alerts/alertService';
|
||||
import { WORKLENZ_REDIRECT_PROJ_KEY } from '@/shared/constants';
|
||||
|
||||
const SignupPage = () => {
|
||||
const [form] = Form.useForm();
|
||||
const navigate = useNavigate();
|
||||
const dispatch = useAppDispatch();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
const { t } = useTranslation('auth/signup');
|
||||
const isMobile = useMediaQuery({ query: '(max-width: 576px)' });
|
||||
|
||||
useDocumentTitle('Signup');
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [validating, setValidating] = useState(false);
|
||||
const [urlParams, setUrlParams] = useState({
|
||||
email: '',
|
||||
name: '',
|
||||
teamId: '',
|
||||
teamMemberId: '',
|
||||
projectId: '',
|
||||
});
|
||||
|
||||
const setProjectId = (projectId: string) => {
|
||||
if (!projectId) {
|
||||
localStorage.removeItem(WORKLENZ_REDIRECT_PROJ_KEY);
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(WORKLENZ_REDIRECT_PROJ_KEY, projectId);
|
||||
};
|
||||
|
||||
const getProjectId = () => {
|
||||
return localStorage.getItem(WORKLENZ_REDIRECT_PROJ_KEY);
|
||||
};
|
||||
|
||||
const enableGoogleLogin = import.meta.env.VITE_ENABLE_GOOGLE_LOGIN === 'true' || false;
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_signup_page_visit);
|
||||
const searchParams = new URLSearchParams(window.location.search);
|
||||
setUrlParams({
|
||||
email: searchParams.get('email') || '',
|
||||
name: searchParams.get('name') || '',
|
||||
teamId: searchParams.get('team') || '',
|
||||
teamMemberId: searchParams.get('user') || '',
|
||||
projectId: searchParams.get('project') || '',
|
||||
});
|
||||
|
||||
setProjectId(searchParams.get('project') || '');
|
||||
|
||||
form.setFieldsValue({
|
||||
email: searchParams.get('email') || '',
|
||||
name: searchParams.get('name') || '',
|
||||
});
|
||||
}, [trackMixpanelEvent]);
|
||||
|
||||
useEffect(() => {
|
||||
const script = document.createElement('script');
|
||||
script.src = `https://www.google.com/recaptcha/api.js?render=${import.meta.env.VITE_RECAPTCHA_SITE_KEY}`;
|
||||
script.async = true;
|
||||
script.defer = true;
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
if (script && script.parentNode) {
|
||||
script.parentNode.removeChild(script);
|
||||
}
|
||||
|
||||
const recaptchaElements = document.getElementsByClassName('grecaptcha-badge');
|
||||
while (recaptchaElements.length > 0) {
|
||||
const element = recaptchaElements[0];
|
||||
if (element.parentNode) {
|
||||
element.parentNode.removeChild(element);
|
||||
}
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const getInvitationQueryParams = () => {
|
||||
const params = [`team=${urlParams.teamId}`, `teamMember=${urlParams.teamMemberId}`];
|
||||
if (getProjectId()) {
|
||||
params.push(`project=${getProjectId()}`);
|
||||
}
|
||||
return urlParams.teamId && urlParams.teamMemberId ? `?${params.join('&')}` : '';
|
||||
};
|
||||
|
||||
const getRecaptchaToken = async () => {
|
||||
return new Promise<string>(resolve => {
|
||||
window.grecaptcha?.ready(() => {
|
||||
window.grecaptcha
|
||||
?.execute(import.meta.env.VITE_RECAPTCHA_SITE_KEY, { action: 'signup' })
|
||||
.then((token: string) => {
|
||||
resolve(token);
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const onFinish = async (values: IUserSignUpRequest) => {
|
||||
try {
|
||||
setValidating(true);
|
||||
const token = await getRecaptchaToken();
|
||||
|
||||
if (!token) {
|
||||
logger.error('Failed to get reCAPTCHA token');
|
||||
alertService.error(t('reCAPTCHAVerificationError'), t('reCAPTCHAVerificationErrorMessage'));
|
||||
return;
|
||||
}
|
||||
|
||||
const veriftToken = await authApiService.verifyRecaptchaToken(token);
|
||||
|
||||
if (!veriftToken.done) {
|
||||
logger.error('Failed to verify reCAPTCHA token');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
name: values.name,
|
||||
email: values.email,
|
||||
password: values.password,
|
||||
};
|
||||
|
||||
const res = await authApiService.signUpCheck(body);
|
||||
if (res.done) {
|
||||
await signUpWithEmail(body);
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || 'Failed to validate signup details');
|
||||
} finally {
|
||||
setValidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const signUpWithEmail = async (body: IUserSignUpRequest) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
trackMixpanelEvent(evt_signup_with_email_click, {
|
||||
email: body.email,
|
||||
name: body.name,
|
||||
});
|
||||
if (urlParams.teamId) {
|
||||
body.team_id = urlParams.teamId;
|
||||
}
|
||||
if (urlParams.teamMemberId) {
|
||||
body.team_member_id = urlParams.teamMemberId;
|
||||
}
|
||||
if (urlParams.projectId) {
|
||||
body.project_id = urlParams.projectId;
|
||||
}
|
||||
const result = await dispatch(signUp(body)).unwrap();
|
||||
if (result?.authenticated) {
|
||||
message.success('Successfully signed up!');
|
||||
setTimeout(() => {
|
||||
navigate('/auth/authenticating');
|
||||
}, 1000);
|
||||
}
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || 'Failed to sign up');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onGoogleSignUpClick = () => {
|
||||
try {
|
||||
trackMixpanelEvent(evt_signup_with_google_click);
|
||||
const queryParams = getInvitationQueryParams();
|
||||
const url = `${import.meta.env.VITE_API_URL}/secure/google${queryParams ? `?${queryParams}` : ''}`;
|
||||
window.location.href = url;
|
||||
} catch (error) {
|
||||
message.error('Failed to redirect to Google sign up');
|
||||
}
|
||||
};
|
||||
|
||||
const formRules = {
|
||||
name: [
|
||||
{
|
||||
required: true,
|
||||
message: t('nameRequired'),
|
||||
whitespace: true,
|
||||
},
|
||||
{
|
||||
min: 4,
|
||||
message: t('nameMinCharacterRequired'),
|
||||
},
|
||||
],
|
||||
email: [
|
||||
{
|
||||
required: true,
|
||||
type: 'email',
|
||||
message: t('emailRequired'),
|
||||
},
|
||||
],
|
||||
password: [
|
||||
{
|
||||
required: true,
|
||||
message: t('passwordRequired'),
|
||||
},
|
||||
{
|
||||
min: 8,
|
||||
message: t('passwordMinCharacterRequired'),
|
||||
},
|
||||
{
|
||||
pattern: /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&#])/,
|
||||
message: t('passwordPatternRequired'),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
paddingInline: isMobile ? 24 : 48,
|
||||
},
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
<PageHeader description={t('headerDescription')} />
|
||||
<Form
|
||||
form={form}
|
||||
name="signup"
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
requiredMark="optional"
|
||||
onFinish={onFinish}
|
||||
style={{ width: '100%' }}
|
||||
initialValues={{
|
||||
email: urlParams.email,
|
||||
name: urlParams.name,
|
||||
}}
|
||||
>
|
||||
<Form.Item name="name" label={t('nameLabel')} rules={formRules.name}>
|
||||
<Input
|
||||
prefix={<UserOutlined />}
|
||||
placeholder={t('namePlaceholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="email" label={t('emailLabel')} rules={formRules.email as Rule[]}>
|
||||
<Input
|
||||
prefix={<MailOutlined />}
|
||||
placeholder={t('emailPlaceholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="password" label={t('passwordLabel')} rules={formRules.password}>
|
||||
<div>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder={t('strongPasswordPlaceholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('passwordValidationAltText')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Typography.Paragraph style={{ fontSize: 14 }}>
|
||||
{t('bySigningUpText')}{' '}
|
||||
<a
|
||||
href="https://worklenz.com/privacy/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{t('privacyPolicyLink')}</a>{' '}
|
||||
{t('andText')}{' '}
|
||||
<a
|
||||
href="https://worklenz.com/terms/"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>{t('termsOfUseLink')}</a>.
|
||||
</Typography.Paragraph>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Flex vertical gap={8}>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
loading={loading || validating}
|
||||
style={{ borderRadius: 4 }}
|
||||
>
|
||||
{t('signupButton')}
|
||||
</Button>
|
||||
|
||||
{enableGoogleLogin && (
|
||||
<>
|
||||
<Typography.Text style={{ textAlign: 'center' }}>{t('orText')}</Typography.Text>
|
||||
|
||||
<Button
|
||||
block
|
||||
type="default"
|
||||
size="large"
|
||||
onClick={onGoogleSignUpClick}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
<img src={googleIcon} alt="google icon" style={{ maxWidth: 20, width: '100%' }} />
|
||||
{t('signInWithGoogleButton')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Typography.Text style={{ fontSize: 14 }}>
|
||||
{t('alreadyHaveAccountText')}
|
||||
</Typography.Text>
|
||||
|
||||
<Link
|
||||
to="/auth/login"
|
||||
className="ant-typography ant-typography-link blue-link"
|
||||
style={{ fontSize: 14 }}
|
||||
>
|
||||
{t('loginButton')}
|
||||
</Link>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignupPage;
|
||||
176
worklenz-frontend/src/pages/auth/verify-reset-email.tsx
Normal file
176
worklenz-frontend/src/pages/auth/verify-reset-email.tsx
Normal file
@@ -0,0 +1,176 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import { Form, Card, Input, Flex, Button, Typography, Result } from 'antd/es';
|
||||
import { LockOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
|
||||
import PageHeader from '@components/AuthPageHeader';
|
||||
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
|
||||
import { updatePassword } from '@/features/auth/authSlice';
|
||||
import { evt_verify_reset_email_page_visit } from '@/shared/worklenz-analytics-events';
|
||||
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { IUpdatePasswordRequest } from '@/types/auth/verify-reset-email.types';
|
||||
|
||||
const VerifyResetEmailPage = () => {
|
||||
const [form] = Form.useForm();
|
||||
const { hash, user } = useParams();
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSuccess, setIsSuccess] = useState(false);
|
||||
const [urlParams, setUrlParams] = useState({
|
||||
hash: hash || '',
|
||||
user: user || '',
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
useDocumentTitle('Verify Reset Email');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { t } = useTranslation('auth/verify-reset-email');
|
||||
|
||||
const isMobile = useMediaQuery({ query: '(max-width: 576px)' });
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_verify_reset_email_page_visit);
|
||||
console.log(urlParams);
|
||||
}, [trackMixpanelEvent]);
|
||||
|
||||
const onFinish = useCallback(
|
||||
async (values: any) => {
|
||||
if (values.newPassword.trim() === '' || values.confirmPassword.trim() === '') return;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const body: IUpdatePasswordRequest = {
|
||||
hash: urlParams.hash,
|
||||
user: urlParams.user,
|
||||
password: values.newPassword,
|
||||
confirmPassword: values.confirmPassword,
|
||||
};
|
||||
const result = await dispatch(updatePassword(body)).unwrap();
|
||||
if (result.done) {
|
||||
setIsSuccess(true);
|
||||
navigate('/auth/login');
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to reset password', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
},
|
||||
[dispatch, t]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{
|
||||
width: '100%',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
styles={{
|
||||
body: {
|
||||
paddingInline: isMobile ? 24 : 48,
|
||||
},
|
||||
}}
|
||||
variant="outlined"
|
||||
>
|
||||
{isSuccess ? (
|
||||
<Result status="success" title={t('successTitle')} subTitle={t('successMessage')} />
|
||||
) : (
|
||||
<>
|
||||
<PageHeader description={t('description')} />
|
||||
<Form
|
||||
name="verify-reset-email"
|
||||
form={form}
|
||||
layout="vertical"
|
||||
autoComplete="off"
|
||||
requiredMark={true}
|
||||
onFinish={onFinish}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
required
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('passwordRequired'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
placeholder={t('placeholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
required
|
||||
dependencies={['newPassword']}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('confirmPasswordRequired'),
|
||||
},
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('newPassword') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(t('passwordMismatch')));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Input.Password
|
||||
onPaste={e => e.preventDefault()}
|
||||
prefix={<LockOutlined />}
|
||||
placeholder={t('confirmPasswordPlaceholder')}
|
||||
size="large"
|
||||
style={{ borderRadius: 4 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Flex vertical gap={8}>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
size="large"
|
||||
loading={isLoading}
|
||||
style={{ borderRadius: 4 }}
|
||||
>
|
||||
{t('resetPasswordButton')}
|
||||
</Button>
|
||||
<Typography.Text style={{ textAlign: 'center' }}>{t('orText')}</Typography.Text>
|
||||
<Link to="/auth/forgot-password">
|
||||
<Button
|
||||
block
|
||||
type="default"
|
||||
size="large"
|
||||
style={{
|
||||
borderRadius: 4,
|
||||
}}
|
||||
>
|
||||
{t('resendResetEmail')}
|
||||
</Button>
|
||||
</Link>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default VerifyResetEmailPage;
|
||||
30
worklenz-frontend/src/pages/home/greeting-with-time.tsx
Normal file
30
worklenz-frontend/src/pages/home/greeting-with-time.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import Flex from 'antd/es/flex';
|
||||
import Typography from 'antd/es/typography';
|
||||
|
||||
import { colors } from '@/styles/colors';
|
||||
import { greetingString } from '@/utils/greetingString';
|
||||
import { getUserSession } from '@/utils/session-helper';
|
||||
import { currentDateString } from '@/utils/current-date-string';
|
||||
|
||||
const GreetingWithTime = () => {
|
||||
const userDetails = getUserSession();
|
||||
const firstName = userDetails?.name?.split(' ')[0] || '';
|
||||
|
||||
const greet = greetingString(firstName);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={8} align="center">
|
||||
<Typography.Title level={3} style={{ fontWeight: 500, marginBlock: 0 }}>
|
||||
{greet}
|
||||
</Typography.Title>
|
||||
<Typography.Title
|
||||
level={4}
|
||||
style={{ fontSize: 16, fontWeight: 400, marginBlock: 0, color: colors.skyBlue }}
|
||||
>
|
||||
{currentDateString()}
|
||||
</Typography.Title>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default GreetingWithTime;
|
||||
90
worklenz-frontend/src/pages/home/home-page.tsx
Normal file
90
worklenz-frontend/src/pages/home/home-page.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useMediaQuery } from 'react-responsive';
|
||||
import Col from 'antd/es/col';
|
||||
import Flex from 'antd/es/flex';
|
||||
|
||||
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 { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
|
||||
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
|
||||
import { fetchProjectCategories } from '@/features/projects/lookups/projectCategories/projectCategoriesSlice';
|
||||
import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/projectHealthSlice';
|
||||
import { fetchProjects } from '@/features/home-page/home-page.slice';
|
||||
import { createPortal } from 'react-dom';
|
||||
import React from 'react';
|
||||
|
||||
const DESKTOP_MIN_WIDTH = 1024;
|
||||
const TASK_LIST_MIN_WIDTH = 500;
|
||||
const SIDEBAR_MAX_WIDTH = 400;
|
||||
const TaskDrawer = React.lazy(() => import('@components/task-drawer/task-drawer'));
|
||||
const HomePage = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const isDesktop = useMediaQuery({ query: `(min-width: ${DESKTOP_MIN_WIDTH}px)` });
|
||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||
useDocumentTitle('Home');
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLookups = async () => {
|
||||
const fetchPromises = [
|
||||
dispatch(fetchProjectHealth()),
|
||||
dispatch(fetchProjectCategories()),
|
||||
dispatch(fetchProjectStatuses()),
|
||||
dispatch(fetchProjects()),
|
||||
].filter(Boolean);
|
||||
|
||||
await Promise.all(fetchPromises);
|
||||
};
|
||||
fetchLookups();
|
||||
}, [dispatch]);
|
||||
|
||||
const CreateProjectButtonComponent = () =>
|
||||
isDesktop ? (
|
||||
<div className="absolute right-0 top-1/2 -translate-y-1/2">
|
||||
{isOwnerOrAdmin && <CreateProjectButton />}
|
||||
</div>
|
||||
) : (
|
||||
isOwnerOrAdmin && <CreateProjectButton />
|
||||
);
|
||||
|
||||
const MainContent = () =>
|
||||
isDesktop ? (
|
||||
<Flex gap={24} align="flex-start" className="w-full mt-12">
|
||||
<Flex style={{ minWidth: TASK_LIST_MIN_WIDTH, width: '100%' }}>
|
||||
<TasksList />
|
||||
</Flex>
|
||||
<Flex vertical gap={24} style={{ width: '100%', maxWidth: SIDEBAR_MAX_WIDTH }}>
|
||||
<TodoList />
|
||||
<RecentAndFavouriteProjectList />
|
||||
</Flex>
|
||||
</Flex>
|
||||
) : (
|
||||
<Flex vertical gap={24} className="mt-6">
|
||||
<TasksList />
|
||||
<TodoList />
|
||||
<RecentAndFavouriteProjectList />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="my-24 min-h-[90vh]">
|
||||
<Col className="flex flex-col gap-6">
|
||||
<GreetingWithTime />
|
||||
<CreateProjectButtonComponent />
|
||||
</Col>
|
||||
|
||||
<MainContent />
|
||||
{createPortal(<TaskDrawer />, document.body, 'home-task-drawer')}
|
||||
{createPortal(<ProjectDrawer onClose={() => {}} />, document.body, 'project-drawer')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomePage;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { StarFilled } from '@ant-design/icons';
|
||||
import { Button, ConfigProvider, Tooltip } from 'antd';
|
||||
import { useMemo } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||
import { projectsApiService } from '@/api/projects/projects.api.service';
|
||||
|
||||
type AddFavouriteProjectButtonProps = {
|
||||
record: IProjectViewModel;
|
||||
handleRefresh: () => void;
|
||||
};
|
||||
|
||||
const AddFavouriteProjectButton = ({ record, handleRefresh }: AddFavouriteProjectButtonProps) => {
|
||||
const checkIconColor = useMemo(
|
||||
() => (record.favorite ? colors.yellow : colors.lightGray),
|
||||
[record.favorite]
|
||||
);
|
||||
|
||||
const handleToggleFavoriteProject = async () => {
|
||||
if (!record.id) return;
|
||||
await projectsApiService.toggleFavoriteProject(record.id);
|
||||
handleRefresh();
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigProvider wave={{ disabled: true }}>
|
||||
<Tooltip title={record.favorite ? 'Remove from favorites' : 'Add to favourites'}>
|
||||
<Button
|
||||
type="text"
|
||||
className="borderless-icon-btn"
|
||||
style={{ backgroundColor: colors.transparent }}
|
||||
shape="circle"
|
||||
icon={<StarFilled style={{ color: checkIconColor }} />}
|
||||
onClick={handleToggleFavoriteProject}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddFavouriteProjectButton;
|
||||
@@ -0,0 +1,160 @@
|
||||
import { SyncOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Flex,
|
||||
Segmented,
|
||||
Table,
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AddFavouriteProjectButton from './add-favourite-project-button';
|
||||
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||
import { useGetProjectsQuery } from '@/api/home-page/home-page.api.service';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
const MY_PROJECTS_FILTER_KEY = 'my-dashboard-active-projects-filter';
|
||||
|
||||
const RecentAndFavouriteProjectList = () => {
|
||||
const { t } = useTranslation('home');
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [projectSegment, setProjectSegment] = useState<'Recent' | 'Favourites'>('Recent');
|
||||
|
||||
const getActiveProjectsFilter = useCallback(() => {
|
||||
return +(localStorage.getItem(MY_PROJECTS_FILTER_KEY) || 0);
|
||||
}, []);
|
||||
|
||||
const setActiveProjectsFilter = useCallback((value: number) => {
|
||||
localStorage.setItem(MY_PROJECTS_FILTER_KEY, value.toString());
|
||||
}, []);
|
||||
|
||||
// Initialize projectSegment from localStorage on component mount
|
||||
useEffect(() => {
|
||||
const filterValue = getActiveProjectsFilter();
|
||||
setProjectSegment(filterValue === 0 ? 'Recent' : 'Favourites');
|
||||
}, [getActiveProjectsFilter]);
|
||||
|
||||
const {
|
||||
data: projectsData,
|
||||
isFetching: projectsIsFetching,
|
||||
error: projectsError,
|
||||
refetch,
|
||||
} = useGetProjectsQuery({ view: getActiveProjectsFilter() });
|
||||
|
||||
// Refetch data when projectSegment changes
|
||||
useEffect(() => {
|
||||
refetch();
|
||||
}, [projectSegment, refetch]);
|
||||
|
||||
const handleSegmentChange = useCallback(
|
||||
(value: 'Recent' | 'Favourites') => {
|
||||
setProjectSegment(value);
|
||||
setActiveProjectsFilter(value === 'Recent' ? 0 : 1);
|
||||
refetch();
|
||||
},
|
||||
[refetch]
|
||||
);
|
||||
|
||||
// Table columns configuration
|
||||
const columns = useMemo<TableProps<IProjectViewModel>['columns']>(
|
||||
() => [
|
||||
{
|
||||
key: 'completeBtn',
|
||||
width: 32,
|
||||
render: (record: IProjectViewModel) => (
|
||||
<AddFavouriteProjectButton key={record.id} record={record} handleRefresh={refetch} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
render: (record: IProjectViewModel) => (
|
||||
<Typography.Paragraph
|
||||
key={record.id}
|
||||
style={{ margin: 0, paddingInlineEnd: 6, cursor: 'pointer' }}
|
||||
onClick={() => navigate(`/worklenz/projects/${record.id}?tab=tasks-list&pinned_tab=tasks-list`)}
|
||||
>
|
||||
<Badge color={record.color_code} style={{ marginInlineEnd: 4 }} />
|
||||
{record.name}
|
||||
</Typography.Paragraph>
|
||||
),
|
||||
},
|
||||
],
|
||||
[refetch]
|
||||
);
|
||||
|
||||
// Empty state message
|
||||
const emptyDescription = useMemo(
|
||||
() => (
|
||||
<Typography.Text>
|
||||
{projectSegment === 'Recent'
|
||||
? t('projects.noRecentProjects')
|
||||
: t('projects.noFavouriteProjects')}
|
||||
</Typography.Text>
|
||||
),
|
||||
[projectSegment, t]
|
||||
);
|
||||
|
||||
// Card header components
|
||||
const cardTitle = (
|
||||
<Typography.Title level={5} style={{ marginBlockEnd: 0 }}>
|
||||
{t('projects.title')} ({projectsData?.body?.length})
|
||||
</Typography.Title>
|
||||
);
|
||||
|
||||
const cardExtra = (
|
||||
<Flex gap={8} align="center">
|
||||
<Tooltip title={t('projects.refreshProjects')}>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<SyncOutlined spin={projectsIsFetching} />}
|
||||
onClick={refetch}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Segmented<'Recent' | 'Favourites'>
|
||||
options={[
|
||||
{ value: 'Recent', label: t('projects.recent') },
|
||||
{ value: 'Favourites', label: t('projects.favourites') }
|
||||
]}
|
||||
defaultValue={getActiveProjectsFilter() === 0 ? 'Recent' : 'Favourites'}
|
||||
onChange={handleSegmentChange}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card title={cardTitle} extra={cardExtra} style={{ width: '100%' }}>
|
||||
<div style={{ maxHeight: 420, overflow: 'auto' }}>
|
||||
{projectsData?.body?.length === 0 ? (
|
||||
<Empty
|
||||
image="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||
imageStyle={{ height: 60 }}
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
description={emptyDescription}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
rowKey="id"
|
||||
dataSource={projectsData?.body}
|
||||
columns={columns}
|
||||
showHeader={false}
|
||||
pagination={false}
|
||||
loading={projectsIsFetching}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default RecentAndFavouriteProjectList;
|
||||
@@ -0,0 +1,266 @@
|
||||
import { Alert, DatePicker, Flex, Form, Input, InputRef, Select, Typography } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { TFunction } from 'i18next';
|
||||
import {
|
||||
useGetMyTasksQuery,
|
||||
useGetProjectsByTeamQuery,
|
||||
} from '@/api/home-page/home-page.api.service';
|
||||
import { IProject } from '@/types/project/project.types';
|
||||
import { IHomeTaskCreateRequest } from '@/types/tasks/task-create-request.types';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { IMyTask } from '@/types/home/my-tasks.types';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface AddTaskInlineFormProps {
|
||||
t: TFunction;
|
||||
calendarView: boolean;
|
||||
}
|
||||
|
||||
const AddTaskInlineForm = ({ t, calendarView }: AddTaskInlineFormProps) => {
|
||||
const [isAlertShowing, setIsAlertShowing] = useState(false);
|
||||
const [isDueDateFieldShowing, setIsDueDateFieldShowing] = useState(false);
|
||||
const [isProjectFieldShowing, setIsProjectFieldShowing] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const { socket } = useSocket();
|
||||
|
||||
const { data: projectListData, isFetching: projectListFetching } = useGetProjectsByTeamQuery();
|
||||
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
||||
const { refetch } = useGetMyTasksQuery(homeTasksConfig);
|
||||
|
||||
const taskInputRef = useRef<InputRef | null>(null);
|
||||
|
||||
const dueDateOptions = [
|
||||
{
|
||||
value: 'Today',
|
||||
label: t('home:tasks.today'),
|
||||
},
|
||||
{
|
||||
value: 'Tomorrow',
|
||||
label: t('home:tasks.tomorrow'),
|
||||
},
|
||||
{
|
||||
value: 'Next Week',
|
||||
label: t('home:tasks.nextWeek'),
|
||||
},
|
||||
{
|
||||
value: 'Next Month',
|
||||
label: t('home:tasks.nextMonth'),
|
||||
},
|
||||
{
|
||||
value: 'No Due Date',
|
||||
label: t('home:tasks.noDueDate'),
|
||||
},
|
||||
];
|
||||
|
||||
const calculateEndDate = (dueDate: string): Date | undefined => {
|
||||
const today = new Date();
|
||||
switch (dueDate) {
|
||||
case 'Today':
|
||||
return today;
|
||||
case 'Tomorrow':
|
||||
return new Date(today.setDate(today.getDate() + 1));
|
||||
case 'Next Week':
|
||||
return new Date(today.setDate(today.getDate() + 7));
|
||||
case 'Next Month':
|
||||
return new Date(today.setMonth(today.getMonth() + 1));
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const projectOptions = [
|
||||
...(projectListData?.body?.map((project: IProject) => ({
|
||||
key: project.id,
|
||||
value: project.id,
|
||||
label: project.name,
|
||||
})) || []),
|
||||
];
|
||||
|
||||
const handleTaskSubmit = (values: { name: string; project: string; dueDate: string }) => {
|
||||
const newTask: IHomeTaskCreateRequest = {
|
||||
name: values.name,
|
||||
project_id: values.project,
|
||||
reporter_id: currentSession?.id,
|
||||
team_id: currentSession?.team_id,
|
||||
end_date: (calendarView ? homeTasksConfig.selected_date?.format('YYYY-MM-DD') : calculateEndDate(values.dueDate)),
|
||||
};
|
||||
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(newTask));
|
||||
socket?.on(SocketEvents.QUICK_TASK.toString(), (task: IMyTask) => {
|
||||
if (task) {
|
||||
const taskBody = {
|
||||
team_member_id: currentSession?.team_member_id,
|
||||
project_id: task.project_id,
|
||||
task_id: task.id,
|
||||
reporter_id: currentSession?.id,
|
||||
mode: 0,
|
||||
};
|
||||
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(taskBody));
|
||||
socket?.once(
|
||||
SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(),
|
||||
(response: ITaskAssigneesUpdateResponse) => {
|
||||
refetch();
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (taskInputRef.current) {
|
||||
taskInputRef.current.focus({
|
||||
cursor: 'start',
|
||||
});
|
||||
}
|
||||
form.resetFields();
|
||||
setIsDueDateFieldShowing(false);
|
||||
setIsProjectFieldShowing(false);
|
||||
}, 100);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
form.setFieldValue('dueDate', homeTasksConfig.selected_date || dayjs());
|
||||
}, [homeTasksConfig.selected_date]);
|
||||
|
||||
useEffect(() => {
|
||||
if (calendarView) {
|
||||
form.setFieldValue('dueDate', homeTasksConfig.selected_date || dayjs());
|
||||
} else {
|
||||
form.setFieldValue('dueDate', dueDateOptions[0]?.value);
|
||||
}
|
||||
return () => {
|
||||
socket?.off(SocketEvents.QUICK_TASK.toString());
|
||||
socket?.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString());
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleTaskSubmit}
|
||||
style={{ display: 'flex', gap: 8 }}
|
||||
initialValues={{
|
||||
dueDate: calendarView ? homeTasksConfig.selected_date || dayjs() : dueDateOptions[0]?.value,
|
||||
project: projectOptions[0]?.value,
|
||||
}}
|
||||
>
|
||||
<Form.Item
|
||||
name="name"
|
||||
style={{ width: '100%', maxWidth: 400 }}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('home:tasks.taskRequired'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Flex vertical gap={4}>
|
||||
<Input
|
||||
ref={taskInputRef}
|
||||
placeholder={t('home:tasks.addTask')}
|
||||
style={{ width: '100%' }}
|
||||
onChange={e => {
|
||||
const inputValue = e.currentTarget.value;
|
||||
if (inputValue.length >= 1) setIsAlertShowing(true);
|
||||
else if (inputValue === '') setIsAlertShowing(false);
|
||||
}}
|
||||
onKeyDown={e => {
|
||||
const inputValue = e.currentTarget.value;
|
||||
if (inputValue.trim() === '') return;
|
||||
if (e.key === 'Tab' || e.key === 'Enter') {
|
||||
setIsAlertShowing(false);
|
||||
if (!calendarView) {
|
||||
setIsDueDateFieldShowing(true);
|
||||
} else {
|
||||
setIsProjectFieldShowing(true);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{isAlertShowing && (
|
||||
<Alert
|
||||
message={
|
||||
<Typography.Text style={{ fontSize: 11 }}>
|
||||
{t('home:tasks.pressTabToSelectDueDateAndProject')}
|
||||
</Typography.Text>
|
||||
}
|
||||
type="info"
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 2,
|
||||
padding: '0 6px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="dueDate" style={{ width: '100%', maxWidth: 200 }}>
|
||||
{isDueDateFieldShowing && !calendarView && (
|
||||
<Select
|
||||
suffixIcon={null}
|
||||
options={dueDateOptions}
|
||||
defaultOpen
|
||||
onSelect={() => {
|
||||
setIsProjectFieldShowing(true);
|
||||
}}
|
||||
onChange={() => {
|
||||
setIsProjectFieldShowing(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{calendarView && (
|
||||
<DatePicker
|
||||
disabled
|
||||
value={homeTasksConfig.selected_date || dayjs()}
|
||||
onChange={() => {
|
||||
setIsProjectFieldShowing(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="project"
|
||||
style={{ width: '100%', maxWidth: 200 }}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('home:tasks.projectRequired'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
{isProjectFieldShowing && (
|
||||
<Select
|
||||
suffixIcon={null}
|
||||
placeholder={'Project'}
|
||||
options={projectOptions}
|
||||
defaultOpen
|
||||
showSearch
|
||||
autoFocus={!calendarView}
|
||||
optionFilterProp="label"
|
||||
filterSort={(optionA, optionB) =>
|
||||
(optionA?.label ?? '')
|
||||
.toLowerCase()
|
||||
.localeCompare((optionB?.label ?? '').toLowerCase())
|
||||
}
|
||||
onSelect={() => {
|
||||
form.submit();
|
||||
}}
|
||||
onInputKeyDown={e => {
|
||||
if (e.key === 'Tab' || e.key === 'Enter') {
|
||||
form.submit();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddTaskInlineForm;
|
||||
48
worklenz-frontend/src/pages/home/task-list/calendar-view.tsx
Normal file
48
worklenz-frontend/src/pages/home/task-list/calendar-view.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import HomeCalendar from '../../../components/calendars/homeCalendar/HomeCalendar';
|
||||
import { Tag, Typography } from 'antd';
|
||||
import { ClockCircleOutlined } from '@ant-design/icons';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import AddTaskInlineForm from './add-task-inline-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEffect } from 'react';
|
||||
import { setHomeTasksConfig } from '@/features/home-page/home-page.slice';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
const CalendarView = () => {
|
||||
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
||||
const { t } = useTranslation('home');
|
||||
|
||||
useEffect(() => {
|
||||
if (!homeTasksConfig.selected_date) {
|
||||
setHomeTasksConfig({
|
||||
...homeTasksConfig,
|
||||
selected_date: dayjs(),
|
||||
});
|
||||
}
|
||||
}, [homeTasksConfig.selected_date]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<HomeCalendar />
|
||||
|
||||
<Tag
|
||||
icon={<ClockCircleOutlined style={{ fontSize: 16 }} />}
|
||||
color="success"
|
||||
style={{
|
||||
display: 'flex',
|
||||
width: '100%',
|
||||
padding: '8px 12px',
|
||||
marginBlock: 12,
|
||||
}}
|
||||
>
|
||||
<Typography.Text>
|
||||
{t('home:tasks.dueOn')} {homeTasksConfig.selected_date?.format('MMM DD, YYYY')}
|
||||
</Typography.Text>
|
||||
</Tag>
|
||||
|
||||
<AddTaskInlineForm t={t} calendarView={true} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CalendarView;
|
||||
55
worklenz-frontend/src/pages/home/task-list/list-view.tsx
Normal file
55
worklenz-frontend/src/pages/home/task-list/list-view.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { Tabs } from 'antd';
|
||||
import AddTaskInlineForm from './add-task-inline-form';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IHomeTasksModel } from '@/types/home/home-page.types';
|
||||
import { useState } from 'react';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setHomeTasksConfig } from '@/features/home-page/home-page.slice';
|
||||
|
||||
interface ListViewProps {
|
||||
model: IHomeTasksModel;
|
||||
refetch: () => void;
|
||||
}
|
||||
|
||||
const ListView = ({ model, refetch }: ListViewProps) => {
|
||||
const { t } = useTranslation('home');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'All',
|
||||
label: `${t('tasks.all')} (${model.total})`,
|
||||
children: <AddTaskInlineForm t={t} calendarView={false} />,
|
||||
},
|
||||
{
|
||||
key: 'Today',
|
||||
label: `${t('tasks.today')} (${model.today})`,
|
||||
},
|
||||
{
|
||||
key: 'Upcoming',
|
||||
label: `${t('tasks.upcoming')} (${model.upcoming})`,
|
||||
},
|
||||
{
|
||||
key: 'Overdue',
|
||||
label: `${t('tasks.overdue')} (${model.overdue})`,
|
||||
},
|
||||
{
|
||||
key: 'NoDueDate',
|
||||
label: `${t('tasks.noDueDate')} (${model.no_due_date})`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
type="card"
|
||||
activeKey={homeTasksConfig.current_tab || 'All'}
|
||||
items={tabItems}
|
||||
onChange={key => dispatch(setHomeTasksConfig({ ...homeTasksConfig, current_tab: key }))}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ListView;
|
||||
@@ -0,0 +1,8 @@
|
||||
.row-action-button {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.ant-table-row:hover .row-action-button {
|
||||
opacity: 1;
|
||||
}
|
||||
293
worklenz-frontend/src/pages/home/task-list/tasks-list.tsx
Normal file
293
worklenz-frontend/src/pages/home/task-list/tasks-list.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
import { ExpandAltOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Segmented,
|
||||
Select,
|
||||
Skeleton,
|
||||
Table,
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
Pagination,
|
||||
} from 'antd';
|
||||
import React, { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
|
||||
import ListView from './list-view';
|
||||
import CalendarView from './calendar-view';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import EmptyListPlaceholder from '@components/EmptyListPlaceholder';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { setHomeTasksConfig } from '@/features/home-page/home-page.slice';
|
||||
import { IMyTask } from '@/types/home/my-tasks.types';
|
||||
import { setSelectedTaskId, setShowTaskDrawer, fetchTask } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { useGetMyTasksQuery } from '@/api/home-page/home-page.api.service';
|
||||
import { IHomeTasksModel } from '@/types/home/home-page.types';
|
||||
import './tasks-list.css';
|
||||
import HomeTasksStatusDropdown from '@/components/home-tasks/statusDropdown/home-tasks-status-dropdown';
|
||||
import HomeTasksDatePicker from '@/components/home-tasks/taskDatePicker/home-tasks-date-picker';
|
||||
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
||||
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||
import { setProjectId } from '@/features/project/project.slice';
|
||||
import { getTeamMembers } from '@/features/team-members/team-members.slice';
|
||||
|
||||
|
||||
const TasksList: React.FC = React.memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [viewOptions, setViewOptions] = useState<'List' | 'Calendar'>('List');
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const [pageSize] = useState<number>(10);
|
||||
const [skipAutoRefetch, setSkipAutoRefetch] = useState<boolean>(false);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
|
||||
const {
|
||||
data,
|
||||
isFetching: homeTasksFetching,
|
||||
refetch: originalRefetch,
|
||||
isLoading,
|
||||
} = useGetMyTasksQuery(homeTasksConfig, {
|
||||
skip: skipAutoRefetch,
|
||||
refetchOnMountOrArgChange: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnFocus: false
|
||||
});
|
||||
|
||||
const { t } = useTranslation('home');
|
||||
const { model } = useAppSelector(state => state.homePageReducer);
|
||||
|
||||
const taskModes = useMemo(
|
||||
() => [
|
||||
{
|
||||
value: 0,
|
||||
label: t('home:tasks.assignedToMe'),
|
||||
},
|
||||
{
|
||||
value: 1,
|
||||
label: t('home:tasks.assignedByMe'),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleSegmentChange = (value: 'List' | 'Calendar') => {
|
||||
setSkipAutoRefetch(false);
|
||||
setViewOptions(value);
|
||||
dispatch(setHomeTasksConfig({ ...homeTasksConfig, is_calendar_view: value === 'Calendar' }));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchLabels());
|
||||
dispatch(fetchPriorities());
|
||||
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
||||
}, [dispatch]);
|
||||
|
||||
const handleSelectTask = useCallback((task : IMyTask) => {
|
||||
dispatch(setSelectedTaskId(task.id || ''));
|
||||
dispatch(fetchTask({ taskId: task.id || '', projectId: task.project_id || '' }));
|
||||
dispatch(setProjectId(task.project_id || ''));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
dispatch(setHomeTasksConfig({ ...homeTasksConfig, selected_task_id: task.id || '' }));
|
||||
}, [dispatch, setSelectedTaskId, setShowTaskDrawer, fetchTask, homeTasksConfig]);
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
setSkipAutoRefetch(false);
|
||||
originalRefetch();
|
||||
}, [originalRefetch]);
|
||||
|
||||
const handlePageChange = (page: number) => {
|
||||
setSkipAutoRefetch(true);
|
||||
setCurrentPage(page);
|
||||
};
|
||||
|
||||
const columns: TableProps<IMyTask>['columns'] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'name',
|
||||
title: (
|
||||
<Flex justify="space-between" align="center" style={{ width: '100%' }}>
|
||||
<span>{t('tasks.name')}</span>
|
||||
</Flex>
|
||||
),
|
||||
width: '150px',
|
||||
render: (_, record) => (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Tooltip title={record.name}>
|
||||
<Typography.Text
|
||||
ellipsis={{ tooltip: true }}
|
||||
style={{ maxWidth: 150 }}
|
||||
>
|
||||
{record.name}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
<div className="row-action-button">
|
||||
<Tooltip title={'Click open task form'}>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ExpandAltOutlined />}
|
||||
onClick={() => {
|
||||
handleSelectTask(record);
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: colors.transparent,
|
||||
padding: 0,
|
||||
height: 'fit-content',
|
||||
}}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'project',
|
||||
title: t('tasks.project'),
|
||||
width: '120px',
|
||||
render: (_, record) => {
|
||||
return (
|
||||
<Tooltip title={record.project_name}>
|
||||
<Typography.Paragraph style={{ margin: 0, paddingInlineEnd: 6, maxWidth:120 }} ellipsis={{ tooltip: true }}>
|
||||
<Badge color={record.phase_color || 'blue'} style={{ marginInlineEnd: 4 }} />
|
||||
{record.project_name}
|
||||
</Typography.Paragraph>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: t('tasks.status'),
|
||||
width: '180px',
|
||||
render: (_, record) => (
|
||||
<HomeTasksStatusDropdown task={record} teamId={record.team_id || ''} />
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dueDate',
|
||||
title: t('tasks.dueDate'),
|
||||
width: '180px',
|
||||
dataIndex: 'end_date',
|
||||
render: (_, record) => (
|
||||
<HomeTasksDatePicker record={record} />
|
||||
),
|
||||
},
|
||||
],
|
||||
[t, data?.body?.total, currentPage, pageSize, handlePageChange]
|
||||
);
|
||||
|
||||
const handleTaskModeChange = (value: number) => {
|
||||
setSkipAutoRefetch(false);
|
||||
dispatch(setHomeTasksConfig({ ...homeTasksConfig, tasks_group_by: value }));
|
||||
setCurrentPage(1);
|
||||
};
|
||||
|
||||
// Add effect to handle task config changes
|
||||
useEffect(() => {
|
||||
// Only refetch if we're not skipping auto refetch
|
||||
if (!skipAutoRefetch) {
|
||||
originalRefetch();
|
||||
}
|
||||
}, [homeTasksConfig, skipAutoRefetch, originalRefetch]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchLabels());
|
||||
dispatch(fetchPriorities());
|
||||
dispatch(getTeamMembers({ index: 0, size: 100, field: null, order: null, search: null, all: true }));
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Flex gap={8} align="center">
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
{t('tasks.tasks')}
|
||||
</Typography.Title>
|
||||
<Select
|
||||
defaultValue={taskModes[0].label}
|
||||
options={taskModes}
|
||||
onChange={value => handleTaskModeChange(+value)}
|
||||
fieldNames={{ label: 'label', value: 'value' }}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
extra={
|
||||
<Flex gap={8} align="center">
|
||||
<Tooltip title={t('tasks.refresh')} trigger={'hover'}>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<SyncOutlined spin={homeTasksFetching} />}
|
||||
onClick={refetch}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Segmented<'List' | 'Calendar'>
|
||||
options={[
|
||||
{ value: 'List', label: t('tasks.list') },
|
||||
{ value: 'Calendar', label: t('tasks.calendar') }
|
||||
]}
|
||||
defaultValue="List"
|
||||
onChange={handleSegmentChange}
|
||||
/>
|
||||
</Flex>
|
||||
}
|
||||
style={{
|
||||
width: '100%',
|
||||
border: '1px solid transparent',
|
||||
boxShadow:
|
||||
themeMode === 'dark'
|
||||
? 'rgba(0, 0, 0, 0.4) 0px 4px 12px, rgba(255, 255, 255, 0.06) 0px 2px 4px'
|
||||
: '#7a7a7a26 0 5px 16px',
|
||||
}}
|
||||
>
|
||||
{/* toggle task view list / calendar */}
|
||||
{viewOptions === 'List' ? (
|
||||
<ListView refetch={refetch} model={data?.body || (model as IHomeTasksModel)} />
|
||||
) : (
|
||||
<CalendarView />
|
||||
)}
|
||||
|
||||
{/* task list table --> render with different filters and views */}
|
||||
{!data?.body || isLoading ? (
|
||||
<Skeleton active />
|
||||
) : data?.body.total === 0 ? (
|
||||
<EmptyListPlaceholder
|
||||
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||
text=" No tasks to show."
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={data?.body.tasks ? data.body.tasks.slice((currentPage - 1) * pageSize, currentPage * pageSize) : []}
|
||||
rowKey={record => record.id || ''}
|
||||
columns={columns as TableProps<IMyTask>['columns']}
|
||||
size="middle"
|
||||
rowClassName={() => 'custom-row-height'}
|
||||
loading={homeTasksFetching && !skipAutoRefetch}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
<div style={{ marginTop: 16, textAlign: 'right', display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Pagination
|
||||
current={currentPage}
|
||||
pageSize={pageSize}
|
||||
total={data?.body.total || 0}
|
||||
onChange={handlePageChange}
|
||||
showSizeChanger={false}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
});
|
||||
|
||||
export default TasksList;
|
||||
@@ -0,0 +1,41 @@
|
||||
import { CheckCircleOutlined } from '@ant-design/icons';
|
||||
import ConfigProvider from 'antd/es/config-provider';
|
||||
import Button from 'antd/es/button';
|
||||
import Tooltip from 'antd/es/tooltip';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { colors } from '@/styles/colors';
|
||||
import { IMyTask } from '@/types/home/my-tasks.types';
|
||||
|
||||
type TodoDoneButtonProps = {
|
||||
record: IMyTask;
|
||||
};
|
||||
|
||||
const TodoDoneButton = ({ record }: TodoDoneButtonProps) => {
|
||||
const [checkIconColor, setCheckIconColor] = useState<string>(colors.lightGray);
|
||||
|
||||
const handleCompleteTodo = () => {
|
||||
setCheckIconColor(colors.limeGreen);
|
||||
|
||||
setTimeout(() => {
|
||||
setCheckIconColor(colors.lightGray);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigProvider wave={{ disabled: true }}>
|
||||
<Tooltip title={'Mark as done'}>
|
||||
<Button
|
||||
type="text"
|
||||
className="borderless-icon-btn"
|
||||
style={{ backgroundColor: colors.transparent }}
|
||||
shape="circle"
|
||||
icon={<CheckCircleOutlined style={{ color: checkIconColor }} />}
|
||||
onClick={handleCompleteTodo}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodoDoneButton;
|
||||
171
worklenz-frontend/src/pages/home/todo-list/todo-list.tsx
Normal file
171
worklenz-frontend/src/pages/home/todo-list/todo-list.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
import { CheckCircleOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import { useRef, useState } from 'react';
|
||||
import Form from 'antd/es/form';
|
||||
import Input, { InputRef } from 'antd/es/input';
|
||||
import Flex from 'antd/es/flex';
|
||||
import Card from 'antd/es/card';
|
||||
import ConfigProvider from 'antd/es/config-provider';
|
||||
import Table, { TableProps } from 'antd/es/table';
|
||||
import Tooltip from 'antd/es/tooltip';
|
||||
import Typography from 'antd/es/typography';
|
||||
import Button from 'antd/es/button';
|
||||
import Alert from 'antd/es/alert';
|
||||
|
||||
import EmptyListPlaceholder from '@components/EmptyListPlaceholder';
|
||||
import { IMyTask } from '@/types/home/my-tasks.types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { colors } from '@/styles/colors';
|
||||
import {
|
||||
useGetPersonalTasksQuery,
|
||||
useMarkPersonalTaskAsDoneMutation,
|
||||
} from '@/api/home-page/home-page.api.service';
|
||||
import { useCreatePersonalTaskMutation } from '@/api/home-page/home-page.api.service';
|
||||
|
||||
const TodoList = () => {
|
||||
const [isAlertShowing, setIsAlertShowing] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const { t } = useTranslation('home');
|
||||
|
||||
const [createPersonalTask, { isLoading: isCreatingPersonalTask }] =
|
||||
useCreatePersonalTaskMutation();
|
||||
const [markPersonalTaskAsDone, { isLoading: isMarkingPersonalTaskAsDone }] =
|
||||
useMarkPersonalTaskAsDoneMutation();
|
||||
const { data, isFetching, refetch } = useGetPersonalTasksQuery();
|
||||
|
||||
// ref for todo input field
|
||||
const todoInputRef = useRef<InputRef | null>(null);
|
||||
|
||||
// function to handle todo submit
|
||||
const handleTodoSubmit = async (values: any) => {
|
||||
if (!values.name || values.name.trim() === '') return;
|
||||
const newTodo: IMyTask = {
|
||||
name: values.name,
|
||||
done: false,
|
||||
is_task: false,
|
||||
color_code: '#000',
|
||||
};
|
||||
|
||||
const res = await createPersonalTask(newTodo);
|
||||
if (res.data) {
|
||||
refetch();
|
||||
}
|
||||
|
||||
setIsAlertShowing(false);
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
const handleCompleteTodo = async (id: string | undefined) => {
|
||||
if (!id) return;
|
||||
const res = await markPersonalTaskAsDone(id);
|
||||
if (res.data) {
|
||||
refetch();
|
||||
}
|
||||
};
|
||||
|
||||
// table columns
|
||||
const columns: TableProps<IMyTask>['columns'] = [
|
||||
{
|
||||
key: 'completeBtn',
|
||||
width: 32,
|
||||
render: (record: IMyTask) => (
|
||||
<ConfigProvider wave={{ disabled: true }}>
|
||||
<Tooltip title={t('home:todoList.markAsDone')}>
|
||||
<Button
|
||||
type="text"
|
||||
className="borderless-icon-btn"
|
||||
style={{ backgroundColor: colors.transparent }}
|
||||
shape="circle"
|
||||
icon={
|
||||
<CheckCircleOutlined
|
||||
style={{ color: record.done ? colors.limeGreen : colors.lightGray }}
|
||||
/>
|
||||
}
|
||||
onClick={() => handleCompleteTodo(record.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ConfigProvider>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
render: (record: IMyTask) => (
|
||||
<Typography.Paragraph style={{ margin: 0, paddingInlineEnd: 6 }}>
|
||||
<Tooltip title={record.name}>{record.name}</Tooltip>
|
||||
</Typography.Paragraph>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
title={
|
||||
<Typography.Title level={5} style={{ marginBlockEnd: 0 }}>
|
||||
{t('home:todoList.title')} ({data?.body.length})
|
||||
</Typography.Title>
|
||||
}
|
||||
extra={
|
||||
<Tooltip title={t('home:todoList.refreshTasks')}>
|
||||
<Button shape="circle" icon={<SyncOutlined spin={isFetching} />} onClick={refetch} />
|
||||
</Tooltip>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<div>
|
||||
<Form form={form} onFinish={handleTodoSubmit}>
|
||||
<Form.Item name="name">
|
||||
<Flex vertical>
|
||||
<Input
|
||||
ref={todoInputRef}
|
||||
placeholder={t('home:todoList.addTask')}
|
||||
onChange={e => {
|
||||
const inputValue = e.currentTarget.value;
|
||||
|
||||
if (inputValue.length >= 1) setIsAlertShowing(true);
|
||||
else if (inputValue === '') setIsAlertShowing(false);
|
||||
}}
|
||||
/>
|
||||
{isAlertShowing && (
|
||||
<Alert
|
||||
message={
|
||||
<Typography.Text style={{ fontSize: 11 }}>
|
||||
{t('home:todoList.pressEnter')} <strong>Enter</strong>{' '}
|
||||
{t('home:todoList.toCreate')}
|
||||
</Typography.Text>
|
||||
}
|
||||
type="info"
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 2,
|
||||
padding: '0 6px',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<div style={{ maxHeight: 420, overflow: 'auto' }}>
|
||||
{data?.body.length === 0 ? (
|
||||
<EmptyListPlaceholder
|
||||
imageSrc="https://app.worklenz.com/assets/images/empty-box.webp"
|
||||
text={t('home:todoList.noTasks')}
|
||||
/>
|
||||
) : (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
rowKey={record => record.id || ''}
|
||||
dataSource={data?.body}
|
||||
columns={columns}
|
||||
showHeader={false}
|
||||
pagination={false}
|
||||
size="small"
|
||||
loading={isFetching}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TodoList;
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Button, Result } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
|
||||
// Simple license expired page that doesn't trigger verification
|
||||
const LicenseExpired = () => {
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation('license-expired');
|
||||
const authService = useAuthService();
|
||||
|
||||
// Direct fallback content in case of translation issues
|
||||
const fallbackTitle = "Your Worklenz trial has expired!";
|
||||
const fallbackSubtitle = "Please upgrade now.";
|
||||
const fallbackButton = "Upgrade now";
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
marginBlock: 65,
|
||||
minHeight: '90vh',
|
||||
padding: '20px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center'
|
||||
}}>
|
||||
<Result
|
||||
status="warning"
|
||||
title={t('title') || fallbackTitle}
|
||||
subTitle={t('subtitle') || fallbackSubtitle}
|
||||
style={{ padding: '30px', borderRadius: '8px', boxShadow: '0 2px 8px rgba(0,0,0,0.1)' }}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
key="console"
|
||||
size="large"
|
||||
onClick={() => navigate('/worklenz/admin-center/billing')}
|
||||
>
|
||||
{t('button') || fallbackButton}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LicenseExpired;
|
||||
27
worklenz-frontend/src/pages/projects/project-list.css
Normal file
27
worklenz-frontend/src/pages/projects/project-list.css
Normal file
@@ -0,0 +1,27 @@
|
||||
.ant-segmented-item-selected {
|
||||
background-color: #fff;
|
||||
border-radius: 4px;
|
||||
box-shadow:
|
||||
0 2px 8px -2px #0000000d,
|
||||
0 1px 4px -1px #00000012,
|
||||
0 0 1px #00000014;
|
||||
color: #262626;
|
||||
}
|
||||
|
||||
:where(.css-dev-only-do-not-override-c1iapc).ant-space-compact-block {
|
||||
width: unset !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.project-card.ant-card .ant-card-body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* remove the border after the tabs in project view */
|
||||
:where(.css-dev-only-do-not-override-17sis5b).ant-tabs-top > .ant-tabs-nav::before,
|
||||
:where(.css-dev-only-do-not-override-17sis5b).ant-tabs-bottom > .ant-tabs-nav::before,
|
||||
:where(.css-dev-only-do-not-override-17sis5b).ant-tabs-top > div > .ant-tabs-nav::before,
|
||||
:where(.css-dev-only-do-not-override-17sis5b).ant-tabs-bottom > div > .ant-tabs-nav::before {
|
||||
border: none;
|
||||
}
|
||||
267
worklenz-frontend/src/pages/projects/project-list.tsx
Normal file
267
worklenz-frontend/src/pages/projects/project-list.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Empty,
|
||||
Flex,
|
||||
Input,
|
||||
Segmented,
|
||||
Skeleton,
|
||||
Table,
|
||||
TablePaginationConfig,
|
||||
Tooltip,
|
||||
} from 'antd';
|
||||
import { PageHeader } from '@ant-design/pro-components';
|
||||
import { SearchOutlined, SyncOutlined } from '@ant-design/icons';
|
||||
import type { FilterValue, SorterResult } from 'antd/es/table/interface';
|
||||
|
||||
import ProjectDrawer from '@/components/projects/project-drawer/project-drawer';
|
||||
import CreateProjectButton from '@/components/projects/project-create-button/project-create-button';
|
||||
import TableColumns from '@/components/project-list/TableColumns';
|
||||
|
||||
import { useGetProjectsQuery } from '@/api/projects/projects.v1.api.service';
|
||||
|
||||
import {
|
||||
DEFAULT_PAGE_SIZE,
|
||||
FILTER_INDEX_KEY,
|
||||
PAGE_SIZE_OPTIONS,
|
||||
PROJECT_SORT_FIELD,
|
||||
PROJECT_SORT_ORDER,
|
||||
} from '@/shared/constants';
|
||||
import { IProjectFilter } from '@/types/project/project.types';
|
||||
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
|
||||
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import './project-list.css';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
setFilteredCategories,
|
||||
setFilteredStatuses,
|
||||
setRequestParams,
|
||||
} from '@/features/projects/projectsSlice';
|
||||
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
|
||||
import { fetchProjectCategories } from '@/features/projects/lookups/projectCategories/projectCategoriesSlice';
|
||||
import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/projectHealthSlice';
|
||||
import { setProjectId, setStatuses } from '@/features/project/project.slice';
|
||||
import { setProject } from '@/features/project/project.slice';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { evt_projects_page_visit, evt_projects_refresh_click, evt_projects_search } from '@/shared/worklenz-analytics-events';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
|
||||
const ProjectList: React.FC = () => {
|
||||
const [filteredInfo, setFilteredInfo] = useState<Record<string, FilterValue | null>>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { t } = useTranslation('all-project-list');
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
useDocumentTitle('Projects');
|
||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
const getFilterIndex = useCallback(() => {
|
||||
return +(localStorage.getItem(FILTER_INDEX_KEY) || 0);
|
||||
}, []);
|
||||
|
||||
const setFilterIndex = useCallback((index: number) => {
|
||||
localStorage.setItem(FILTER_INDEX_KEY, index.toString());
|
||||
}, []);
|
||||
|
||||
const setSortingValues = useCallback((field: string, order: string) => {
|
||||
localStorage.setItem(PROJECT_SORT_FIELD, field);
|
||||
localStorage.setItem(PROJECT_SORT_ORDER, order);
|
||||
}, []);
|
||||
|
||||
const { requestParams } = useAppSelector(state => state.projectsReducer);
|
||||
|
||||
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
|
||||
const { projectHealths } = useAppSelector(state => state.projectHealthReducer);
|
||||
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
|
||||
|
||||
const {
|
||||
data: projectsData,
|
||||
isLoading: loadingProjects,
|
||||
isFetching: isFetchingProjects,
|
||||
refetch: refetchProjects,
|
||||
} = useGetProjectsQuery(requestParams);
|
||||
|
||||
const filters = useMemo(() => Object.values(IProjectFilter), []);
|
||||
|
||||
// Create translated segment options for the filters
|
||||
const segmentOptions = useMemo(() => {
|
||||
return filters.map(filter => ({
|
||||
value: filter,
|
||||
label: t(filter.toLowerCase())
|
||||
}));
|
||||
}, [filters, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setIsLoading(loadingProjects || isFetchingProjects);
|
||||
}, [loadingProjects, isFetchingProjects]);
|
||||
|
||||
useEffect(() => {
|
||||
const filterIndex = getFilterIndex();
|
||||
dispatch(setRequestParams({ filter: filterIndex }));
|
||||
}, [dispatch, getFilterIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_projects_page_visit);
|
||||
refetchProjects();
|
||||
}, [requestParams, refetchProjects]);
|
||||
|
||||
const handleTableChange = useCallback(
|
||||
(
|
||||
newPagination: TablePaginationConfig,
|
||||
filters: Record<string, FilterValue | null>,
|
||||
sorter: SorterResult<IProjectViewModel> | SorterResult<IProjectViewModel>[]
|
||||
) => {
|
||||
const newParams: Partial<typeof requestParams> = {};
|
||||
if (!filters?.status_id) {
|
||||
newParams.statuses = null;
|
||||
dispatch(setFilteredStatuses([]));
|
||||
} else {
|
||||
// dispatch(setFilteredStatuses(filters.status_id as Array<string>));
|
||||
newParams.statuses = filters.status_id.join(' ');
|
||||
}
|
||||
|
||||
if (!filters?.category_id) {
|
||||
newParams.categories = null;
|
||||
dispatch(setFilteredCategories([]));
|
||||
} else {
|
||||
// dispatch(setFilteredCategories(filters.category_id as Array<string>));
|
||||
newParams.categories = filters.category_id.join(' ');
|
||||
}
|
||||
|
||||
const newOrder = Array.isArray(sorter) ? sorter[0].order : sorter.order;
|
||||
const newField = (Array.isArray(sorter) ? sorter[0].columnKey : sorter.columnKey) as string;
|
||||
|
||||
if (newOrder && newField) {
|
||||
newParams.order = newOrder ?? 'ascend';
|
||||
newParams.field = newField ?? 'name';
|
||||
setSortingValues(newParams.field, newParams.order);
|
||||
}
|
||||
|
||||
newParams.index = newPagination.current || 1;
|
||||
newParams.size = newPagination.pageSize || DEFAULT_PAGE_SIZE;
|
||||
|
||||
dispatch(setRequestParams(newParams));
|
||||
setFilteredInfo(filters);
|
||||
},
|
||||
[setSortingValues]
|
||||
);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
trackMixpanelEvent(evt_projects_refresh_click);
|
||||
refetchProjects();
|
||||
}, [refetchProjects, requestParams]);
|
||||
|
||||
const handleSegmentChange = useCallback(
|
||||
(value: IProjectFilter) => {
|
||||
const newFilterIndex = filters.indexOf(value);
|
||||
setFilterIndex(newFilterIndex);
|
||||
dispatch(setRequestParams({ filter: newFilterIndex }));
|
||||
refetchProjects();
|
||||
},
|
||||
[filters, setFilterIndex, refetchProjects]
|
||||
);
|
||||
|
||||
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
trackMixpanelEvent(evt_projects_search);
|
||||
const value = e.target.value;
|
||||
dispatch(setRequestParams({ search: value }));
|
||||
}, []);
|
||||
|
||||
const paginationConfig = useMemo(
|
||||
() => ({
|
||||
current: requestParams.index,
|
||||
pageSize: requestParams.size,
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE,
|
||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||
size: 'small' as const,
|
||||
total: projectsData?.body?.total,
|
||||
}),
|
||||
[requestParams.index, requestParams.size, projectsData?.body?.total]
|
||||
);
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
dispatch(setProject({} as IProjectViewModel));
|
||||
dispatch(setProjectId(null));
|
||||
};
|
||||
const navigateToProject = (project_id: string | undefined, default_view: string | undefined) => {
|
||||
if (project_id) {
|
||||
navigate(`/worklenz/projects/${project_id}?tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}&pinned_tab=${default_view === 'BOARD' ? 'board' : 'tasks-list'}`); // Update the route as per your project structure
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (projectStatuses.length === 0) dispatch(fetchProjectStatuses());
|
||||
if (projectCategories.length === 0) dispatch(fetchProjectCategories());
|
||||
if (projectHealths.length === 0) dispatch(fetchProjectHealth());
|
||||
}, [requestParams]);
|
||||
|
||||
return (
|
||||
<div style={{ marginBlock: 65, minHeight: '90vh' }}>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={`${projectsData?.body?.total || 0} ${t('projects')}`}
|
||||
style={{ padding: '16px 0' }}
|
||||
extra={
|
||||
<Flex gap={8} align="center">
|
||||
<Tooltip title={t('refreshProjects')}>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<SyncOutlined spin={isFetchingProjects} />}
|
||||
onClick={handleRefresh}
|
||||
aria-label="Refresh projects"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Segmented<IProjectFilter>
|
||||
options={segmentOptions}
|
||||
defaultValue={filters[getFilterIndex()] ?? filters[0]}
|
||||
onChange={handleSegmentChange}
|
||||
/>
|
||||
<Input
|
||||
placeholder={t('placeholder')}
|
||||
suffix={<SearchOutlined />}
|
||||
type="text"
|
||||
value={requestParams.search}
|
||||
onChange={handleSearchChange}
|
||||
aria-label="Search projects"
|
||||
/>
|
||||
{isOwnerOrAdmin && <CreateProjectButton />}
|
||||
</Flex>
|
||||
}
|
||||
/>
|
||||
<Card className="project-card">
|
||||
<Skeleton active loading={isLoading} className='mt-4 p-4'>
|
||||
<Table<IProjectViewModel>
|
||||
columns={TableColumns({
|
||||
navigate,
|
||||
filteredInfo,
|
||||
})}
|
||||
dataSource={projectsData?.body?.data || []}
|
||||
rowKey={record => record.id || ''}
|
||||
loading={loadingProjects}
|
||||
size="small"
|
||||
onChange={handleTableChange}
|
||||
pagination={paginationConfig}
|
||||
locale={{ emptyText: <Empty description={t('noProjects')} /> }}
|
||||
onRow={record => ({
|
||||
onClick: () => navigateToProject(record.id, record.team_member_default_view), // Navigate to project on row click
|
||||
})}
|
||||
/>
|
||||
</Skeleton>
|
||||
|
||||
</Card>
|
||||
|
||||
{createPortal(<ProjectDrawer onClose={handleDrawerClose} />, document.body, 'project-drawer')}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectList;
|
||||
@@ -0,0 +1,35 @@
|
||||
import { FC } from 'react';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
export type CardType = {
|
||||
id: string;
|
||||
title: string;
|
||||
};
|
||||
|
||||
const Card: FC<CardType> = ({ id, title }) => {
|
||||
// useSortableに指定するidは一意になるよう設定する必要があります。s
|
||||
const { attributes, listeners, setNodeRef, transform } = useSortable({
|
||||
id: id,
|
||||
});
|
||||
|
||||
const style = {
|
||||
margin: '10px',
|
||||
opacity: 1,
|
||||
color: '#333',
|
||||
background: 'white',
|
||||
padding: '10px',
|
||||
transform: CSS.Transform.toString(transform),
|
||||
};
|
||||
|
||||
return (
|
||||
// attributes、listenersはDOMイベントを検知するために利用します。
|
||||
// listenersを任意の領域に付与することで、ドラッグするためのハンドルを作ることもできます。
|
||||
<div ref={setNodeRef} {...attributes} {...listeners} style={style}>
|
||||
<div id={id}>
|
||||
<p>{title}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Card;
|
||||
@@ -0,0 +1,45 @@
|
||||
import { FC } from 'react';
|
||||
import { SortableContext, rectSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import Card, { CardType } from './card';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
|
||||
export type ColumnType = {
|
||||
id: string;
|
||||
title: string;
|
||||
cards: IProjectTask[];
|
||||
};
|
||||
|
||||
const Column: FC<ColumnType> = ({ id, title, cards }) => {
|
||||
const { setNodeRef } = useDroppable({ id: id });
|
||||
return (
|
||||
// ソートを行うためのContextです。
|
||||
// strategyは4つほど存在しますが、今回は縦・横移動可能なリストを作るためrectSortingStrategyを採用
|
||||
<SortableContext id={id} items={cards} strategy={rectSortingStrategy}>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={{
|
||||
width: '200px',
|
||||
background: 'rgba(245,247,249,1.00)',
|
||||
marginRight: '10px',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
style={{
|
||||
padding: '5px 20px',
|
||||
textAlign: 'left',
|
||||
fontWeight: '500',
|
||||
color: '#575757',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
{cards.map(card => (
|
||||
<Card key={card.id} id={card.id} title={card.title}></Card>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
);
|
||||
};
|
||||
|
||||
export default Column;
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import TaskListFilters from '../taskList/taskListFilters/TaskListFilters';
|
||||
import { Button, Skeleton } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { toggleDrawer } from '@/features/projects/status/StatusSlice';
|
||||
import KanbanGroup from '@/components/board/kanban-group/kanban-group';
|
||||
|
||||
const ProjectViewBoard: React.FC = () => {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { taskGroups, loadingGroups } = useAppSelector(state => state.taskReducer);
|
||||
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
|
||||
const groupBy = useAppSelector(state => state.groupByFilterDropdownReducer.groupBy);
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
|
||||
useEffect(() => {
|
||||
console.log('projectId', projectId);
|
||||
// if (projectId) {
|
||||
// const config: ITaskListConfigV2 = {
|
||||
// id: projectId,
|
||||
// field: 'id',
|
||||
// order: 'desc',
|
||||
// search: '',
|
||||
// statuses: '',
|
||||
// members: '',
|
||||
// projects: '',
|
||||
// isSubtasksInclude: false,
|
||||
// };
|
||||
// dispatch(fetchTaskGroups(config) as any);
|
||||
// }
|
||||
// if (!statusCategories.length) {
|
||||
// dispatch(fetchStatusesCategories() as any);
|
||||
// }
|
||||
}, [dispatch, projectId, groupBy]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, {
|
||||
activationConstraint: {
|
||||
distance: 8,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragOver = (event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeTask = active.data.current?.task;
|
||||
const overId = over.id;
|
||||
|
||||
// Find which group the task is being dragged over
|
||||
const targetGroup = taskGroups.find(
|
||||
group => group.id === overId || group.tasks.some(task => task.id === overId)
|
||||
);
|
||||
|
||||
if (targetGroup && activeTask) {
|
||||
// Here you would dispatch an action to update the task's status
|
||||
// For example:
|
||||
// dispatch(updateTaskStatus({ taskId: activeTask.id, newStatus: targetGroup.id }));
|
||||
console.log('Moving task', activeTask.id, 'to group', targetGroup.id);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeTask = active.data.current?.task;
|
||||
const overId = over.id;
|
||||
|
||||
// Similar to handleDragOver, but this is where you'd make the final update
|
||||
const targetGroup = taskGroups.find(
|
||||
group => group.id === overId || group.tasks.some(task => task.id === overId)
|
||||
);
|
||||
|
||||
if (targetGroup && activeTask) {
|
||||
// Make the final update to your backend/state
|
||||
console.log('Final move of task', activeTask.id, 'to group', targetGroup.id);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%', display: 'flex', flexDirection: 'column' }}>
|
||||
<TaskListFilters position={'board'} />
|
||||
|
||||
<Skeleton active loading={loadingGroups}>
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '0 12px',
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
flexDirection: 'column',
|
||||
marginTop: '14px',
|
||||
}}
|
||||
>
|
||||
<DndContext sensors={sensors} onDragOver={handleDragOver} onDragEnd={handleDragEnd}>
|
||||
<div
|
||||
style={{
|
||||
paddingTop: '6px',
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
overflowX: 'scroll',
|
||||
paddingBottom: '10px',
|
||||
}}
|
||||
>
|
||||
{taskGroups.map(group => (
|
||||
<KanbanGroup
|
||||
key={group.id}
|
||||
title={group.name}
|
||||
tasks={group.tasks}
|
||||
id={group.id}
|
||||
color={group.color_code}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => dispatch(toggleDrawer())}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</div>
|
||||
</DndContext>
|
||||
</div>
|
||||
</Skeleton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewBoard;
|
||||
@@ -0,0 +1,116 @@
|
||||
:root {
|
||||
--odd-row-color: #fff;
|
||||
--even-row-color: #4e4e4e10;
|
||||
--text-color: #181818;
|
||||
--border: 1px solid #e0e0e0;
|
||||
--stroke: #e0e0e0;
|
||||
|
||||
--calender-header-bg: #fafafa;
|
||||
}
|
||||
|
||||
.dark-theme {
|
||||
--odd-row-color: #141414;
|
||||
--even-row-color: #4e4e4e10;
|
||||
--text-color: #fff;
|
||||
--border: 1px solid #505050;
|
||||
--stroke: #505050;
|
||||
|
||||
--calender-header-bg: #1d1d1d;
|
||||
}
|
||||
|
||||
/* scroll bar size override */
|
||||
._2k9Ys {
|
||||
scrollbar-width: unset;
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* task details side even rows */
|
||||
._34SS0:nth-of-type(even) {
|
||||
background-color: var(--even-row-color);
|
||||
}
|
||||
|
||||
/* task details side header and body */
|
||||
._3_ygE {
|
||||
border-top: var(--border);
|
||||
border-left: var(--border);
|
||||
position: relative;
|
||||
}
|
||||
._2B2zv {
|
||||
border-bottom: var(--border);
|
||||
border-left: var(--border);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
._3ZbQT {
|
||||
border: none;
|
||||
}
|
||||
|
||||
._3_ygE::after,
|
||||
._2B2zv::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -25px;
|
||||
width: 30px;
|
||||
height: 100%;
|
||||
box-shadow: inset 10px 0 8px -8px #00000026;
|
||||
}
|
||||
|
||||
/* ._3lLk3:nth-child(1),
|
||||
._WuQ0f:nth-child(1) {
|
||||
min-width: 300px !important;
|
||||
max-width: 300px !important;
|
||||
}
|
||||
|
||||
._2eZzQ,
|
||||
._WuQ0f:nth-child(3),
|
||||
._WuQ0f:last-child,
|
||||
._3lLk3:nth-child(2),
|
||||
._3lLk3:nth-child(3) {
|
||||
display: none;
|
||||
} */
|
||||
|
||||
/* ----------------------------------------------------------------------- */
|
||||
/* calender side header */
|
||||
._35nLX {
|
||||
fill: var(--calender-header-bg);
|
||||
stroke: var(--stroke);
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
/* calender side header texts */
|
||||
._9w8d5,
|
||||
._2q1Kt {
|
||||
fill: var(--text-color);
|
||||
}
|
||||
|
||||
/* calender side odd rows */
|
||||
._2dZTy:nth-child(odd) {
|
||||
fill: var(--odd-row-color);
|
||||
}
|
||||
/* calender side even rows */
|
||||
._2dZTy:nth-child(even) {
|
||||
fill: var(--even-row-color);
|
||||
}
|
||||
|
||||
/* calender side body row lines */
|
||||
._3rUKi {
|
||||
stroke: var(--stroke);
|
||||
stroke-width: 0.3px;
|
||||
}
|
||||
|
||||
/* calender side body ticks */
|
||||
._RuwuK {
|
||||
stroke: var(--stroke);
|
||||
stroke-width: 0.3px;
|
||||
}
|
||||
|
||||
/* calender side header ticks */
|
||||
._1rLuZ {
|
||||
stroke: var(--stroke);
|
||||
stroke-width: 1px;
|
||||
}
|
||||
|
||||
.roadmap-table .ant-table-thead .ant-table-cell {
|
||||
height: 50px;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useState } from 'react';
|
||||
import { ViewMode } from 'gantt-task-react';
|
||||
import 'gantt-task-react/dist/index.css';
|
||||
import './project-view-roadmap.css';
|
||||
import { Flex } from 'antd';
|
||||
import { useAppSelector } from '../../../../hooks/useAppSelector';
|
||||
import { TimeFilter } from './time-filter';
|
||||
import RoadmapTable from './roadmap-table/roadmap-table';
|
||||
import RoadmapGrantChart from './roadmap-grant-chart';
|
||||
|
||||
const ProjectViewRoadmap = () => {
|
||||
const [view, setView] = useState<ViewMode>(ViewMode.Day);
|
||||
|
||||
// get theme details
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
return (
|
||||
<Flex vertical className={`${themeMode === 'dark' ? 'dark-theme' : ''}`}>
|
||||
{/* time filter */}
|
||||
<TimeFilter onViewModeChange={viewMode => setView(viewMode)} />
|
||||
|
||||
<Flex>
|
||||
{/* table */}
|
||||
<div className="after:content relative h-fit w-full max-w-[500px] after:absolute after:-right-3 after:top-0 after:z-10 after:min-h-full after:w-3 after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent">
|
||||
<RoadmapTable />
|
||||
</div>
|
||||
|
||||
{/* gantt Chart */}
|
||||
<RoadmapGrantChart view={view} />
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewRoadmap;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Gantt, Task, ViewMode } from 'gantt-task-react';
|
||||
import React from 'react';
|
||||
import { colors } from '../../../../styles/colors';
|
||||
import {
|
||||
NewTaskType,
|
||||
updateTaskDate,
|
||||
updateTaskProgress,
|
||||
} from '../../../../features/roadmap/roadmap-slice';
|
||||
import { useAppSelector } from '../../../../hooks/useAppSelector';
|
||||
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
|
||||
import { toggleTaskDrawer } from '../../../../features/tasks/tasks.slice';
|
||||
|
||||
type RoadmapGrantChartProps = {
|
||||
view: ViewMode;
|
||||
};
|
||||
|
||||
const RoadmapGrantChart = ({ view }: RoadmapGrantChartProps) => {
|
||||
// get task list from roadmap slice
|
||||
const tasks = useAppSelector(state => state.roadmapReducer.tasksList);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// column widths for each view mods
|
||||
let columnWidth = 60;
|
||||
if (view === ViewMode.Year) {
|
||||
columnWidth = 350;
|
||||
} else if (view === ViewMode.Month) {
|
||||
columnWidth = 300;
|
||||
} else if (view === ViewMode.Week) {
|
||||
columnWidth = 250;
|
||||
}
|
||||
|
||||
// function to handle double click
|
||||
const handleDoubleClick = () => {
|
||||
dispatch(toggleTaskDrawer());
|
||||
};
|
||||
|
||||
// function to handle date change
|
||||
const handleTaskDateChange = (task: Task) => {
|
||||
dispatch(updateTaskDate({ taskId: task.id, start: task.start, end: task.end }));
|
||||
};
|
||||
|
||||
// function to handle progress change
|
||||
const handleTaskProgressChange = (task: Task) => {
|
||||
dispatch(updateTaskProgress({ taskId: task.id, progress: task.progress }));
|
||||
};
|
||||
|
||||
// function to convert the tasklist comming form roadmap slice which has NewTaskType converted to Task type which is the default type of the tasks list in the grant chart
|
||||
const flattenTasks = (tasks: NewTaskType[]): Task[] => {
|
||||
const flattened: Task[] = [];
|
||||
|
||||
const addTaskAndSubTasks = (task: NewTaskType, parentExpanded: boolean) => {
|
||||
// add the task to the flattened list if its parent is expanded or it is a top-level task
|
||||
if (parentExpanded) {
|
||||
const { subTasks, isExpanded, ...rest } = task; // destructure to exclude properties not in Task type
|
||||
flattened.push(rest as Task);
|
||||
|
||||
// recursively add subtasks if this task is expanded
|
||||
if (subTasks && isExpanded) {
|
||||
subTasks.forEach(subTask => addTaskAndSubTasks(subTask as NewTaskType, true));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// top-level tasks are always visible, start with parentExpanded = true
|
||||
tasks.forEach(task => addTaskAndSubTasks(task, true));
|
||||
|
||||
return flattened;
|
||||
};
|
||||
|
||||
const flattenedTasksList = flattenTasks(tasks);
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-[900px] overflow-x-auto">
|
||||
<Gantt
|
||||
tasks={flattenedTasksList}
|
||||
viewMode={view}
|
||||
onDateChange={handleTaskDateChange}
|
||||
onProgressChange={handleTaskProgressChange}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
listCellWidth={''}
|
||||
columnWidth={columnWidth}
|
||||
todayColor={`rgba(64, 150, 255, 0.2)`}
|
||||
projectProgressColor={colors.limeGreen}
|
||||
projectBackgroundColor={colors.lightGreen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoadmapGrantChart;
|
||||
@@ -0,0 +1,189 @@
|
||||
import React from 'react';
|
||||
import { DatePicker, Typography } from 'antd';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { NewTaskType, updateTaskDate } from '@features/roadmap/roadmap-slice';
|
||||
import { colors } from '@/styles/colors';
|
||||
import RoadmapTaskCell from './roadmap-task-cell';
|
||||
|
||||
const RoadmapTable = () => {
|
||||
// Get task list and expanded tasks from roadmap slice
|
||||
const tasks = useAppSelector(state => state.roadmapReducer.tasksList);
|
||||
|
||||
// Get theme data from theme slice
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// function to handle date changes
|
||||
const handleDateChange = (taskId: string, dateType: 'start' | 'end', date: Dayjs) => {
|
||||
const updatedDate = date.toDate();
|
||||
|
||||
dispatch(
|
||||
updateTaskDate({
|
||||
taskId,
|
||||
start: dateType === 'start' ? updatedDate : new Date(),
|
||||
end: dateType === 'end' ? updatedDate : new Date(),
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Adjusted column type with a string or ReactNode for the title
|
||||
const columns: { key: string; title: React.ReactNode; width: number }[] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Task Name',
|
||||
width: 240,
|
||||
},
|
||||
{
|
||||
key: 'start',
|
||||
title: 'Start Date',
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
key: 'end',
|
||||
title: 'End Date',
|
||||
width: 130,
|
||||
},
|
||||
];
|
||||
|
||||
// Function to render the column content based on column key
|
||||
const renderColumnContent = (
|
||||
columnKey: string,
|
||||
task: NewTaskType,
|
||||
isSubtask: boolean = false
|
||||
) => {
|
||||
switch (columnKey) {
|
||||
case 'name':
|
||||
return <RoadmapTaskCell task={task} isSubtask={isSubtask} />;
|
||||
case 'start':
|
||||
const startDayjs = task.start ? dayjs(task.start) : null;
|
||||
return (
|
||||
<DatePicker
|
||||
placeholder="Set Start Date"
|
||||
defaultValue={startDayjs}
|
||||
format={'MMM DD, YYYY'}
|
||||
suffixIcon={null}
|
||||
disabled={task.type === 'project'}
|
||||
onChange={date => handleDateChange(task.id, 'end', date)}
|
||||
style={{
|
||||
backgroundColor: colors.transparent,
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 'end':
|
||||
const endDayjs = task.end ? dayjs(task.end) : null;
|
||||
return (
|
||||
<DatePicker
|
||||
placeholder="Set End Date"
|
||||
defaultValue={endDayjs}
|
||||
format={'MMM DD, YYYY'}
|
||||
suffixIcon={null}
|
||||
disabled={task.type === 'project'}
|
||||
onChange={date => handleDateChange(task.id, 'end', date)}
|
||||
style={{
|
||||
backgroundColor: colors.transparent,
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const dataSource = tasks.map(task => ({
|
||||
id: task.id,
|
||||
name: task.name,
|
||||
start: task.start,
|
||||
end: task.end,
|
||||
type: task.type,
|
||||
progress: task.progress,
|
||||
subTasks: task.subTasks,
|
||||
isExpanded: task.isExpanded,
|
||||
}));
|
||||
|
||||
// Layout styles for table and columns
|
||||
const customHeaderColumnStyles = `border px-2 h-[50px] text-left z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
|
||||
|
||||
const customBodyColumnStyles = `border px-2 h-[50px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-h-[40px] after:w-1.5 after:bg-transparent after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent ${themeMode === 'dark' ? 'bg-transparent border-[#303030]' : 'bg-transparent'}`;
|
||||
|
||||
const rowBackgroundStyles =
|
||||
themeMode === 'dark' ? 'even:bg-[#1b1b1b] odd:bg-[#141414]' : 'even:bg-[#f4f4f4] odd:bg-white';
|
||||
|
||||
return (
|
||||
<div className="relative w-full max-w-[1000px]">
|
||||
<table className={`rounded-2 w-full min-w-max border-collapse`}>
|
||||
<thead className="h-[50px]">
|
||||
<tr>
|
||||
{/* table header */}
|
||||
{columns.map(column => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`${customHeaderColumnStyles}`}
|
||||
style={{ width: column.width, fontWeight: 500 }}
|
||||
>
|
||||
<Typography.Text style={{ fontWeight: 500 }}>{column.title}</Typography.Text>
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{dataSource.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={columns.length} className="text-center">
|
||||
No tasks available
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
dataSource.map(task => (
|
||||
<React.Fragment key={task.id}>
|
||||
<tr
|
||||
key={task.id}
|
||||
className={`group cursor-pointer ${dataSource.length === 0 ? 'h-0' : 'h-[50px]'} ${rowBackgroundStyles}`}
|
||||
>
|
||||
{columns.map(column => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={`${customBodyColumnStyles}`}
|
||||
style={{
|
||||
width: column.width,
|
||||
}}
|
||||
>
|
||||
{renderColumnContent(column.key, task)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{/* subtasks */}
|
||||
{task.isExpanded &&
|
||||
task?.subTasks?.map(subtask => (
|
||||
<tr key={`subtask-${subtask.id}`} className={`h-[50px] ${rowBackgroundStyles}`}>
|
||||
{columns.map(column => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={`${customBodyColumnStyles}`}
|
||||
style={{
|
||||
width: column.width,
|
||||
}}
|
||||
>
|
||||
{renderColumnContent(column.key, subtask, true)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoadmapTable;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Flex, Typography, Button, Tooltip } from 'antd';
|
||||
import {
|
||||
DoubleRightOutlined,
|
||||
DownOutlined,
|
||||
RightOutlined,
|
||||
ExpandAltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { NewTaskType, toggleTaskExpansion } from '@features/roadmap/roadmap-slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleTaskDrawer } from '@features/tasks/taskSlice';
|
||||
import { colors } from '@/styles/colors';
|
||||
|
||||
type RoadmapTaskCellProps = {
|
||||
task: NewTaskType;
|
||||
isSubtask?: boolean;
|
||||
};
|
||||
|
||||
const RoadmapTaskCell = ({ task, isSubtask = false }: RoadmapTaskCellProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// render the toggle arrow icon for tasks with subtasks
|
||||
const renderToggleButtonForHasSubTasks = (id: string, hasSubtasks: boolean) => {
|
||||
if (!hasSubtasks) return null;
|
||||
return (
|
||||
<button
|
||||
onClick={() => dispatch(toggleTaskExpansion(id))}
|
||||
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
|
||||
>
|
||||
{task.isExpanded ? <DownOutlined /> : <RightOutlined />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// show expand button on hover for tasks without subtasks
|
||||
const renderToggleButtonForNonSubtasks = (id: string, isSubtask: boolean) => {
|
||||
return !isSubtask ? (
|
||||
<button
|
||||
onClick={() => dispatch(toggleTaskExpansion(id))}
|
||||
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
|
||||
>
|
||||
{task.isExpanded ? <DownOutlined /> : <RightOutlined />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="h-4 w-4"></div>
|
||||
);
|
||||
};
|
||||
|
||||
// render the double arrow icon and count label for tasks with subtasks
|
||||
const renderSubtasksCountLabel = (id: string, isSubtask: boolean, subTasksCount: number) => {
|
||||
return (
|
||||
!isSubtask && (
|
||||
<Button
|
||||
onClick={() => dispatch(toggleTaskExpansion(id))}
|
||||
size="small"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
paddingInline: 4,
|
||||
alignItems: 'center',
|
||||
justifyItems: 'center',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Typography.Text style={{ fontSize: 12, lineHeight: 1 }}>{subTasksCount}</Typography.Text>
|
||||
<DoubleRightOutlined style={{ fontSize: 10 }} />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex gap={8} align="center" justify="space-between">
|
||||
<Flex gap={8} align="center">
|
||||
{!!task?.subTasks?.length ? (
|
||||
renderToggleButtonForHasSubTasks(task.id, !!task?.subTasks?.length)
|
||||
) : (
|
||||
<div className="h-4 w-4 opacity-0 group-hover:opacity-100 group-focus:opacity-100">
|
||||
{renderToggleButtonForNonSubtasks(task.id, isSubtask)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSubtask && <DoubleRightOutlined style={{ fontSize: 12 }} />}
|
||||
|
||||
<Tooltip title={task.name}>
|
||||
<Typography.Text ellipsis={{ expanded: false }} style={{ maxWidth: 100 }}>
|
||||
{task.name}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
|
||||
{renderSubtasksCountLabel(task.id, isSubtask, task?.subTasks?.length || 0)}
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ExpandAltOutlined />}
|
||||
onClick={() => {
|
||||
dispatch(toggleTaskDrawer());
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: colors.transparent,
|
||||
padding: 0,
|
||||
height: 'fit-content',
|
||||
}}
|
||||
className="hidden group-hover:block group-focus:block"
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default RoadmapTaskCell;
|
||||
@@ -0,0 +1,74 @@
|
||||
import React from 'react';
|
||||
import 'gantt-task-react/dist/index.css';
|
||||
import { ViewMode } from 'gantt-task-react';
|
||||
import { Flex, Select } from 'antd';
|
||||
type TimeFilterProps = {
|
||||
onViewModeChange: (viewMode: ViewMode) => void;
|
||||
};
|
||||
export const TimeFilter = ({ onViewModeChange }: TimeFilterProps) => {
|
||||
// function to handle time change
|
||||
const handleChange = (value: string) => {
|
||||
switch (value) {
|
||||
case 'hour':
|
||||
return onViewModeChange(ViewMode.Hour);
|
||||
case 'quaterDay':
|
||||
return onViewModeChange(ViewMode.QuarterDay);
|
||||
case 'halfDay':
|
||||
return onViewModeChange(ViewMode.HalfDay);
|
||||
case 'day':
|
||||
return onViewModeChange(ViewMode.Day);
|
||||
case 'week':
|
||||
return onViewModeChange(ViewMode.Week);
|
||||
case 'month':
|
||||
return onViewModeChange(ViewMode.Month);
|
||||
case 'year':
|
||||
return onViewModeChange(ViewMode.Year);
|
||||
default:
|
||||
return onViewModeChange(ViewMode.Day);
|
||||
}
|
||||
};
|
||||
|
||||
const timeFilterItems = [
|
||||
{
|
||||
value: 'hour',
|
||||
label: 'Hour',
|
||||
},
|
||||
{
|
||||
value: 'quaterDay',
|
||||
label: 'Quater Day',
|
||||
},
|
||||
{
|
||||
value: 'halfDay',
|
||||
label: 'Half Day',
|
||||
},
|
||||
{
|
||||
value: 'day',
|
||||
label: 'Day',
|
||||
},
|
||||
{
|
||||
value: 'week',
|
||||
label: 'Week',
|
||||
},
|
||||
{
|
||||
value: 'month',
|
||||
label: 'Month',
|
||||
},
|
||||
{
|
||||
value: 'year',
|
||||
label: 'Year',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Flex gap={12} align="center" justify="flex-end" style={{ marginBlockEnd: 24 }}>
|
||||
<Select
|
||||
className="ViewModeSelect"
|
||||
style={{ minWidth: 120 }}
|
||||
placeholder="Select View Mode"
|
||||
onChange={handleChange}
|
||||
options={timeFilterItems}
|
||||
defaultValue={'day'}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,142 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Checkbox, Flex, Tag, Tooltip } from 'antd';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
|
||||
const TaskListTable = ({
|
||||
taskListGroup,
|
||||
visibleColumns,
|
||||
onTaskSelect,
|
||||
onTaskExpand,
|
||||
}: {
|
||||
taskListGroup: ITaskListGroup;
|
||||
tableId: string;
|
||||
visibleColumns: Array<{ key: string; width: number }>;
|
||||
onTaskSelect?: (taskId: string) => void;
|
||||
onTaskExpand?: (taskId: string) => void;
|
||||
}) => {
|
||||
const [hoverRow, setHoverRow] = useState<string | null>(null);
|
||||
const tableRef = useRef<HTMLDivElement | null>(null);
|
||||
const parentRef = useRef<HTMLDivElement | null>(null);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// Memoize all tasks including subtasks for virtualization
|
||||
const flattenedTasks = useMemo(() => {
|
||||
return taskListGroup.tasks.reduce((acc: IProjectTask[], task: IProjectTask) => {
|
||||
acc.push(task);
|
||||
if (task.sub_tasks?.length) {
|
||||
acc.push(...task.sub_tasks.map((st: any) => ({ ...st, isSubtask: true })));
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
}, [taskListGroup.tasks]);
|
||||
|
||||
// Virtual row renderer
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: flattenedTasks.length,
|
||||
getScrollElement: () => parentRef.current,
|
||||
estimateSize: () => 42, // row height
|
||||
overscan: 5,
|
||||
});
|
||||
|
||||
// Memoize cell render functions
|
||||
const renderCell = useCallback(
|
||||
(columnKey: string | number, task: IProjectTask, isSubtask = false) => {
|
||||
const cellContent = {
|
||||
taskId: () => {
|
||||
const key = task.task_key?.toString() || '';
|
||||
return (
|
||||
<Tooltip title={key}>
|
||||
<Tag>{key}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
task: () => (
|
||||
<Flex align="center" className="pl-2">
|
||||
{task.name}
|
||||
</Flex>
|
||||
),
|
||||
// Add other cell renderers as needed...
|
||||
}[columnKey];
|
||||
|
||||
return cellContent ? cellContent() : null;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// Memoize header rendering
|
||||
const TableHeader = useMemo(
|
||||
() => (
|
||||
<div className="sticky top-0 z-20 flex border-b" style={{ height: 42 }}>
|
||||
<div className="sticky left-0 z-30 w-8 bg-white dark:bg-gray-900 flex items-center justify-center">
|
||||
<Checkbox />
|
||||
</div>
|
||||
{visibleColumns.map(column => (
|
||||
<div
|
||||
key={column.key}
|
||||
className="flex items-center px-3 border-r"
|
||||
style={{ width: column.width }}
|
||||
>
|
||||
{column.key}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
),
|
||||
[visibleColumns]
|
||||
);
|
||||
|
||||
// Handle scroll shadows
|
||||
const handleScroll = useCallback((e: { target: any }) => {
|
||||
const target = e.target;
|
||||
const hasHorizontalShadow = target.scrollLeft > 0;
|
||||
target.classList.toggle('show-shadow', hasHorizontalShadow);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="h-[400px] overflow-auto" onScroll={handleScroll}>
|
||||
{TableHeader}
|
||||
|
||||
<div
|
||||
ref={tableRef}
|
||||
style={{
|
||||
height: `${rowVirtualizer.getTotalSize()}px`,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{rowVirtualizer.getVirtualItems().map(virtualRow => {
|
||||
const task = flattenedTasks[virtualRow.index];
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="absolute top-0 left-0 flex w-full border-b hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
style={{
|
||||
height: 42,
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
}}
|
||||
>
|
||||
<div className="sticky left-0 z-10 w-8 flex items-center justify-center">
|
||||
{/* <Checkbox checked={task.selected} /> */}
|
||||
</div>
|
||||
{visibleColumns.map(column => (
|
||||
<div
|
||||
key={column.key}
|
||||
className={`flex items-center px-3 border-r ${
|
||||
hoverRow === task.id ? 'bg-gray-50 dark:bg-gray-800' : ''
|
||||
}`}
|
||||
style={{ width: column.width }}
|
||||
>
|
||||
{renderCell(column.key, task, task.is_sub_task)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListTable;
|
||||
@@ -0,0 +1,239 @@
|
||||
import { Avatar, Checkbox, DatePicker, Flex, Select, Tag } from 'antd';
|
||||
import { createColumnHelper, ColumnDef } from '@tanstack/react-table';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { HolderOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import StatusDropdown from '@/components/task-list-common/status-dropdown/status-dropdown';
|
||||
import Avatars from '@/components/avatars/avatars';
|
||||
import LabelsSelector from '@/components/task-list-common/labelsSelector/labels-selector';
|
||||
import CustomColorLabel from '@/components/task-list-common/labelsSelector/custom-color-label';
|
||||
import TaskRowName from '@/components/task-list-common/task-row/task-row-name/task-row-name';
|
||||
import TaskRowDescription from '@/components/task-list-common/task-row/task-row-description/task-row-description';
|
||||
import TaskRowProgress from '@/components/task-list-common/task-row/task-row-progress/task-row-progress';
|
||||
import TaskRowDueTime from '@/components/task-list-common/task-row/task-list-due-time-cell/task-row-due-time';
|
||||
import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
||||
|
||||
interface CreateColumnsProps {
|
||||
expandedRows: Record<string, boolean>;
|
||||
statuses: any[];
|
||||
handleTaskSelect: (taskId: string) => void;
|
||||
getCurrentSession: () => any;
|
||||
}
|
||||
|
||||
export const createColumns = ({
|
||||
expandedRows,
|
||||
statuses,
|
||||
handleTaskSelect,
|
||||
getCurrentSession,
|
||||
}: CreateColumnsProps): ColumnDef<IProjectTask, any>[] => {
|
||||
const columnHelper = createColumnHelper<IProjectTask>();
|
||||
|
||||
return [
|
||||
columnHelper.display({
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={table.getIsAllRowsSelected()}
|
||||
indeterminate={table.getIsSomeRowsSelected()}
|
||||
onChange={table.getToggleAllRowsSelectedHandler()}
|
||||
style={{ padding: '8px 6px 8px 0!important' }}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Flex align="center" gap={4}>
|
||||
<HolderOutlined style={{ cursor: 'move' }} />
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
disabled={!row.getCanSelect()}
|
||||
indeterminate={row.getIsSomeSelected()}
|
||||
onChange={row.getToggleSelectedHandler()}
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
size: 47,
|
||||
minSize: 47,
|
||||
maxSize: 47,
|
||||
enablePinning: true,
|
||||
meta: {
|
||||
style: { position: 'sticky', left: 0, zIndex: 1 },
|
||||
},
|
||||
}),
|
||||
columnHelper.accessor('task_key', {
|
||||
header: 'Key',
|
||||
id: COLUMN_KEYS.KEY,
|
||||
size: 85,
|
||||
minSize: 85,
|
||||
maxSize: 85,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<Tag onClick={() => handleTaskSelect(row.original.id || '')} style={{ cursor: 'pointer' }}>
|
||||
{row.original.task_key}
|
||||
</Tag>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('name', {
|
||||
header: 'Task',
|
||||
id: COLUMN_KEYS.NAME,
|
||||
size: 450,
|
||||
enablePinning: true,
|
||||
meta: {
|
||||
style: { position: 'sticky', left: '47px', zIndex: 1 },
|
||||
},
|
||||
cell: ({ row }) => (
|
||||
<TaskRowName
|
||||
task={row.original}
|
||||
isSubTask={false}
|
||||
expandedTasks={Object.keys(expandedRows)}
|
||||
setSelectedTaskId={() => {}}
|
||||
toggleTaskExpansion={() => {}}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('description', {
|
||||
header: 'Description',
|
||||
id: COLUMN_KEYS.DESCRIPTION,
|
||||
size: 225,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => <TaskRowDescription description={row.original.description || ''} />,
|
||||
}),
|
||||
|
||||
columnHelper.accessor('progress', {
|
||||
header: 'Progress',
|
||||
id: COLUMN_KEYS.PROGRESS,
|
||||
size: 80,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<TaskRowProgress
|
||||
progress={row.original.progress || 0}
|
||||
numberOfSubTasks={row.original.sub_tasks_count || 0}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('names', {
|
||||
header: 'Assignees',
|
||||
id: COLUMN_KEYS.ASSIGNEES,
|
||||
size: 159,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Avatars
|
||||
key={`${row.original.id}-assignees`}
|
||||
members={row.original.names || []}
|
||||
maxCount={3}
|
||||
/>
|
||||
<Avatar
|
||||
size={28}
|
||||
icon={<PlusOutlined />}
|
||||
className="avatar-add"
|
||||
style={{
|
||||
backgroundColor: '#ffffff',
|
||||
border: '1px dashed #c4c4c4',
|
||||
color: '#000000D9',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('end_date', {
|
||||
header: 'Due Date',
|
||||
id: COLUMN_KEYS.DUE_DATE,
|
||||
size: 149,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
<DatePicker
|
||||
key={`${row.original.id}-end-date`}
|
||||
placeholder="Set a due date"
|
||||
suffixIcon={null}
|
||||
variant="borderless"
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('due_time', {
|
||||
header: 'Due Time',
|
||||
id: COLUMN_KEYS.DUE_TIME,
|
||||
size: 120,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => <TaskRowDueTime dueTime={row.original.due_time || ''} />,
|
||||
}),
|
||||
|
||||
columnHelper.accessor('status', {
|
||||
header: 'Status',
|
||||
id: COLUMN_KEYS.STATUS,
|
||||
size: 120,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<StatusDropdown
|
||||
key={`${row.original.id}-status`}
|
||||
statusList={statuses}
|
||||
task={row.original}
|
||||
teamId={getCurrentSession()?.team_id || ''}
|
||||
onChange={statusId => {
|
||||
console.log('Status changed:', statusId);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('labels', {
|
||||
header: 'Labels',
|
||||
id: COLUMN_KEYS.LABELS,
|
||||
size: 225,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<Flex>
|
||||
{row.original.labels?.map(label => (
|
||||
<CustomColorLabel key={`${row.original.id}-${label.id}`} label={label} />
|
||||
))}
|
||||
<LabelsSelector taskId={row.original.id} />
|
||||
</Flex>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('start_date', {
|
||||
header: 'Start Date',
|
||||
id: COLUMN_KEYS.START_DATE,
|
||||
size: 149,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
<DatePicker placeholder="Set a start date" suffixIcon={null} variant="borderless" />
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
|
||||
columnHelper.accessor('priority', {
|
||||
header: 'Priority',
|
||||
id: COLUMN_KEYS.PRIORITY,
|
||||
size: 120,
|
||||
enablePinning: false,
|
||||
cell: ({ row }) => (
|
||||
<span>
|
||||
<Select
|
||||
variant="borderless"
|
||||
options={[
|
||||
{ value: 'high', label: 'High' },
|
||||
{ value: 'medium', label: 'Medium' },
|
||||
{ value: 'low', label: 'Low' },
|
||||
]}
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
}),
|
||||
|
||||
// columnHelper.accessor('time_tracking', {
|
||||
// header: 'Time Tracking',
|
||||
// size: 120,
|
||||
// enablePinning: false,
|
||||
// cell: ({ row }) => (
|
||||
// <TaskRowTimeTracking taskId={row.original.id || null} />
|
||||
// )
|
||||
// })
|
||||
];
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
.table-header {
|
||||
border-bottom: 1px solid #d9d9d9;
|
||||
/* Border below header */
|
||||
}
|
||||
|
||||
.table-body {
|
||||
background-color: #ffffff;
|
||||
/* White background for body */
|
||||
}
|
||||
|
||||
.table-row {
|
||||
display: flex;
|
||||
/* Use flexbox for row layout */
|
||||
align-items: center;
|
||||
/* Center items vertically */
|
||||
transition: background-color 0.2s;
|
||||
/* Smooth background transition */
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background-color: #f5f5f5;
|
||||
/* Light gray background on hover */
|
||||
}
|
||||
|
||||
/* Optional: Add styles for sticky headers */
|
||||
.table-header > div {
|
||||
position: sticky;
|
||||
/* Make header cells sticky */
|
||||
top: 0;
|
||||
/* Stick to the top */
|
||||
z-index: 1;
|
||||
/* Ensure it stays above other content */
|
||||
}
|
||||
|
||||
/* Optional: Add styles for cell borders */
|
||||
.table-row > div {
|
||||
border-right: 1px solid #d9d9d9;
|
||||
/* Right border for cells */
|
||||
}
|
||||
|
||||
.table-row > div:last-child {
|
||||
border-right: none;
|
||||
/* Remove right border for last cell */
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
import { useCallback, useMemo, useRef, useState } from 'react';
|
||||
import { Checkbox, theme } from 'antd';
|
||||
import {
|
||||
useReactTable,
|
||||
getCoreRowModel,
|
||||
getFilteredRowModel,
|
||||
getPaginationRowModel,
|
||||
flexRender,
|
||||
VisibilityState,
|
||||
Row,
|
||||
Column,
|
||||
} from '@tanstack/react-table';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import React from 'react';
|
||||
import './task-list-custom.css';
|
||||
import TaskListInstantTaskInput from './task-list-instant-task-input/task-list-instant-task-input';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { createColumns } from './task-list-columns/task-list-columns';
|
||||
|
||||
interface TaskListCustomProps {
|
||||
tasks: IProjectTask[];
|
||||
color: string;
|
||||
groupId?: string | null;
|
||||
onTaskSelect?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
const TaskListCustom: React.FC<TaskListCustomProps> = ({ tasks, color, groupId, onTaskSelect }) => {
|
||||
const [rowSelection, setRowSelection] = useState({});
|
||||
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
|
||||
const [expandedRows, setExpandedRows] = useState<Record<string, boolean>>({});
|
||||
|
||||
const statuses = useAppSelector(state => state.taskStatusReducer.status);
|
||||
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||
const { token } = theme.useToken();
|
||||
const { getCurrentSession } = useAuthService();
|
||||
|
||||
const handleExpandClick = useCallback((rowId: string) => {
|
||||
setExpandedRows(prev => ({
|
||||
...prev,
|
||||
[rowId]: !prev[rowId],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleTaskSelect = useCallback(
|
||||
(taskId: string) => {
|
||||
onTaskSelect?.(taskId);
|
||||
},
|
||||
[onTaskSelect]
|
||||
);
|
||||
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
createColumns({
|
||||
expandedRows,
|
||||
statuses,
|
||||
handleTaskSelect,
|
||||
getCurrentSession,
|
||||
}),
|
||||
[expandedRows, statuses, handleTaskSelect, getCurrentSession]
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data: tasks,
|
||||
columns,
|
||||
state: {
|
||||
rowSelection,
|
||||
columnVisibility,
|
||||
},
|
||||
enableRowSelection: true,
|
||||
onRowSelectionChange: setRowSelection,
|
||||
onColumnVisibilityChange: setColumnVisibility,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getFilteredRowModel: getFilteredRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel(),
|
||||
});
|
||||
|
||||
const { rows } = table.getRowModel();
|
||||
|
||||
const rowVirtualizer = useVirtualizer({
|
||||
count: rows.length,
|
||||
getScrollElement: () => tableContainerRef.current,
|
||||
estimateSize: () => 50,
|
||||
overscan: 20,
|
||||
});
|
||||
|
||||
const virtualRows = rowVirtualizer.getVirtualItems();
|
||||
const totalSize = rowVirtualizer.getTotalSize();
|
||||
const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0;
|
||||
const paddingBottom =
|
||||
virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0;
|
||||
|
||||
const columnToggleItems = columns.map(column => ({
|
||||
key: column.id as string,
|
||||
label: (
|
||||
<span>
|
||||
<Checkbox checked={table.getColumn(column.id as string)?.getIsVisible()}>
|
||||
{typeof column.header === 'string' ? column.header : column.id}
|
||||
</Checkbox>
|
||||
</span>
|
||||
),
|
||||
onClick: () => {
|
||||
const columnData = table.getColumn(column.id as string);
|
||||
if (columnData) {
|
||||
columnData.toggleVisibility();
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
return (
|
||||
<div
|
||||
className="task-list-custom"
|
||||
style={{
|
||||
maxHeight: '80vh',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
borderLeft: `4px solid ${color}`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={tableContainerRef}
|
||||
style={{
|
||||
flex: 1,
|
||||
minHeight: 0,
|
||||
overflowX: 'auto',
|
||||
maxHeight: '100%',
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 'fit-content', borderCollapse: 'collapse' }}>
|
||||
<div className="table-header">
|
||||
{table.getHeaderGroups().map(headerGroup => (
|
||||
<div key={headerGroup.id} className="table-row">
|
||||
{headerGroup.headers.map((header, index) => (
|
||||
<div
|
||||
key={header.id}
|
||||
className={`${header.column.getIsPinned() === 'left' ? 'sticky left-0 z-10' : ''}`}
|
||||
style={{
|
||||
width: header.getSize(),
|
||||
position: index < 2 ? 'sticky' : 'relative',
|
||||
left: index === 0 ? 0 : index === 1 ? '47px' : 'auto',
|
||||
background: token.colorBgElevated,
|
||||
zIndex: 1,
|
||||
color: token.colorText,
|
||||
height: '40px',
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
textAlign: index === 0 ? 'right' : 'left',
|
||||
fontWeight: 'normal',
|
||||
padding: '8px 0px 8px 8px',
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(header.column.columnDef.header, header.getContext())}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="table-body">
|
||||
{paddingTop > 0 && <div style={{ height: `${paddingTop}px` }} />}
|
||||
{virtualRows.map(virtualRow => {
|
||||
const row = rows[virtualRow.index];
|
||||
return (
|
||||
<React.Fragment key={row.id}>
|
||||
<div
|
||||
className="table-row"
|
||||
style={{
|
||||
'&:hover div': {
|
||||
background: `${token.colorFillAlter} !important`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{row.getVisibleCells().map((cell, index) => (
|
||||
<div
|
||||
key={cell.id}
|
||||
className={`${cell.column.getIsPinned() === 'left' ? 'sticky left-0 z-10' : ''}`}
|
||||
style={{
|
||||
width: cell.column.getSize(),
|
||||
position: index < 2 ? 'sticky' : 'relative',
|
||||
left: 'auto',
|
||||
background: token.colorBgContainer,
|
||||
color: token.colorText,
|
||||
height: '42px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
padding: '8px 0px 8px 8px',
|
||||
}}
|
||||
>
|
||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{expandedRows[row.id] &&
|
||||
row.original.sub_tasks?.map(subTask => (
|
||||
<div
|
||||
key={subTask.task_key}
|
||||
className="table-row"
|
||||
style={{
|
||||
'&:hover div': {
|
||||
background: `${token.colorFillAlter} !important`,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{columns.map((col, index) => (
|
||||
<div
|
||||
key={`${subTask.task_key}-${col.id}`}
|
||||
style={{
|
||||
width: col.getSize(),
|
||||
position: index < 2 ? 'sticky' : 'relative',
|
||||
left: index < 2 ? `${index * col.getSize()}px` : 'auto',
|
||||
background: token.colorBgContainer,
|
||||
color: token.colorText,
|
||||
height: '42px',
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
borderRight: `1px solid ${token.colorBorderSecondary}`,
|
||||
paddingLeft: index === 3 ? '32px' : '8px',
|
||||
paddingRight: '8px',
|
||||
}}
|
||||
>
|
||||
{flexRender(col.cell, {
|
||||
getValue: () => subTask[col.id as keyof typeof subTask] ?? null,
|
||||
row: { original: subTask } as Row<IProjectTask>,
|
||||
column: col as Column<IProjectTask>,
|
||||
table,
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
{paddingBottom > 0 && <div style={{ height: `${paddingBottom}px` }} />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<TaskListInstantTaskInput
|
||||
session={getCurrentSession() || null}
|
||||
groupId={groupId}
|
||||
parentTask={null}
|
||||
/>
|
||||
{/* {selectedCount > 0 && (
|
||||
<Flex
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
background: token.colorBgElevated,
|
||||
borderTop: `1px solid ${token.colorBorderSecondary}`,
|
||||
position: 'sticky',
|
||||
bottom: 0,
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<span>{selectedCount} tasks selected</span>
|
||||
<Flex gap={8}>
|
||||
<Button icon={<EditOutlined />}>Edit</Button>
|
||||
<Button danger icon={<DeleteOutlined />}>
|
||||
Delete
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListCustom;
|
||||
@@ -0,0 +1,116 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Dropdown, Input, Menu, Badge, Tooltip } from 'antd';
|
||||
import {
|
||||
RightOutlined,
|
||||
LoadingOutlined,
|
||||
EllipsisOutlined,
|
||||
EditOutlined,
|
||||
RetweetOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { ITaskStatusCategory } from '@/types/status.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
// import WorklenzTaskListPhaseDuration from "./WorklenzTaskListPhaseDuration";
|
||||
// import WorklenzTasksProgressBar from "./WorklenzTasksProgressBar";
|
||||
|
||||
interface Props {
|
||||
group: ITaskListGroup;
|
||||
projectId: string | null;
|
||||
categories: ITaskStatusCategory[];
|
||||
}
|
||||
|
||||
const TaskListGroupSettings: React.FC<Props> = ({ group, projectId, categories }) => {
|
||||
const [edit, setEdit] = useState(false);
|
||||
const [showMenu, setShowMenu] = useState(false);
|
||||
const [isEditColProgress, setIsEditColProgress] = useState(false);
|
||||
const [isGroupByPhases, setIsGroupByPhases] = useState(false);
|
||||
const [isGroupByStatus, setIsGroupByStatus] = useState(false);
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
|
||||
const menu = (
|
||||
<Menu>
|
||||
<Menu.Item key="edit">
|
||||
<EditOutlined className="me-2" />
|
||||
Rename
|
||||
</Menu.Item>
|
||||
{isGroupByStatus && (
|
||||
<Menu.SubMenu
|
||||
key="change-category"
|
||||
title={
|
||||
<>
|
||||
<RetweetOutlined className="me-2" />
|
||||
Change category
|
||||
</>
|
||||
}
|
||||
>
|
||||
{categories.map(item => (
|
||||
<Tooltip key={item.id} title={item.description || ''} placement="right">
|
||||
<Menu.Item
|
||||
style={{
|
||||
fontWeight: item.id === group.category_id ? 'bold' : undefined,
|
||||
}}
|
||||
>
|
||||
<Badge color={item.color_code} text={item.name || ''} />
|
||||
</Menu.Item>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Menu.SubMenu>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
|
||||
const onBlurEditColumn = (group: ITaskListGroup) => {
|
||||
setEdit(false);
|
||||
};
|
||||
|
||||
const onToggleClick = () => {
|
||||
console.log('onToggleClick');
|
||||
};
|
||||
|
||||
const canDisplayActions = () => {
|
||||
return true;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="d-flex justify-content-between align-items-center position-relative">
|
||||
<div className="d-flex align-items-center">
|
||||
<Button
|
||||
className={`collapse btn border-0 ${group.tasks.length ? 'active' : ''}`}
|
||||
onClick={onToggleClick}
|
||||
style={{ backgroundColor: group.color_code }}
|
||||
>
|
||||
<RightOutlined className="collapse-icon" />
|
||||
{`${group.name} (${group.tasks.length})`}
|
||||
</Button>
|
||||
|
||||
{canDisplayActions() && (
|
||||
<Dropdown
|
||||
overlay={menu}
|
||||
trigger={['click']}
|
||||
onVisibleChange={visible => setShowMenu(visible)}
|
||||
>
|
||||
<Button className="p-0" type="text">
|
||||
<EllipsisOutlined />
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* {isGroupByPhases && group.name !== "Unmapped" && (
|
||||
<div className="d-flex align-items-center me-2 ms-auto">
|
||||
<WorklenzTaskListPhaseDuration group={group} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isProgressBarAvailable() && (
|
||||
<WorklenzTasksProgressBar
|
||||
todoProgress={group.todo_progress}
|
||||
doingProgress={group.doing_progress}
|
||||
doneProgress={group.done_progress}
|
||||
/>
|
||||
)} */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListGroupSettings;
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Input, InputRef, theme } from 'antd';
|
||||
import React, { useState, useMemo, useRef } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ILocalSession } from '@/types/auth/local-session.types';
|
||||
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
|
||||
import {
|
||||
addTask,
|
||||
getCurrentGroup,
|
||||
GROUP_BY_PHASE_VALUE,
|
||||
GROUP_BY_PRIORITY_VALUE,
|
||||
GROUP_BY_STATUS_VALUE,
|
||||
} from '@/features/tasks/tasks.slice';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { DRAWER_ANIMATION_INTERVAL } from '@/shared/constants';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
|
||||
interface ITaskListInstantTaskInputProps {
|
||||
session: ILocalSession | null;
|
||||
groupId?: string | null;
|
||||
parentTask?: string | null;
|
||||
}
|
||||
interface IAddNewTask extends IProjectTask {
|
||||
groupId: string;
|
||||
}
|
||||
|
||||
const TaskListInstantTaskInput = ({
|
||||
session,
|
||||
groupId = null,
|
||||
parentTask = null,
|
||||
}: ITaskListInstantTaskInputProps) => {
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
const [taskName, setTaskName] = useState<string>('');
|
||||
const [creatingTask, setCreatingTask] = useState<boolean>(false);
|
||||
const taskInputRef = useRef<InputRef>(null);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { socket } = useSocket();
|
||||
const { token } = theme.useToken();
|
||||
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const customBorderColor = useMemo(() => themeMode === 'dark' && ' border-[#303030]', [themeMode]);
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
|
||||
const createRequestBody = (): ITaskCreateRequest | null => {
|
||||
if (!projectId || !session) return null;
|
||||
const body: ITaskCreateRequest = {
|
||||
project_id: projectId,
|
||||
name: taskName,
|
||||
reporter_id: session.id,
|
||||
team_id: session.team_id,
|
||||
};
|
||||
|
||||
const groupBy = getCurrentGroup();
|
||||
if (groupBy.value === GROUP_BY_STATUS_VALUE) {
|
||||
body.status_id = groupId || undefined;
|
||||
} else if (groupBy.value === GROUP_BY_PRIORITY_VALUE) {
|
||||
body.priority_id = groupId || undefined;
|
||||
} else if (groupBy.value === GROUP_BY_PHASE_VALUE) {
|
||||
body.phase_id = groupId || undefined;
|
||||
}
|
||||
|
||||
if (parentTask) {
|
||||
body.parent_task_id = parentTask;
|
||||
}
|
||||
console.log('createRequestBody', body);
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
const reset = (scroll = true) => {
|
||||
setIsEdit(false);
|
||||
|
||||
setCreatingTask(false);
|
||||
|
||||
setTaskName('');
|
||||
setIsEdit(true);
|
||||
|
||||
setTimeout(() => {
|
||||
taskInputRef.current?.focus();
|
||||
if (scroll) window.scrollTo(0, document.body.scrollHeight);
|
||||
}, DRAWER_ANIMATION_INTERVAL); // wait for the animation end
|
||||
};
|
||||
|
||||
const onNewTaskReceived = (task: IAddNewTask) => {
|
||||
if (!groupId) return;
|
||||
console.log('onNewTaskReceived', task);
|
||||
task.groupId = groupId;
|
||||
if (groupId && task.id) {
|
||||
dispatch(addTask(task));
|
||||
reset(false);
|
||||
// if (this.map.has(task.id)) return;
|
||||
|
||||
// this.service.addTask(task, this.groupId);
|
||||
// this.reset(false);
|
||||
}
|
||||
};
|
||||
|
||||
const addInstantTask = () => {
|
||||
if (creatingTask) return;
|
||||
console.log('addInstantTask', projectId, taskName.trim());
|
||||
if (!projectId || !session || taskName.trim() === '') return;
|
||||
|
||||
try {
|
||||
setCreatingTask(true);
|
||||
const body = createRequestBody();
|
||||
if (!body) return;
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => {
|
||||
setCreatingTask(false);
|
||||
if (task.parent_task_id) {
|
||||
}
|
||||
onNewTaskReceived(task as IAddNewTask);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
} finally {
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTask = () => {
|
||||
setIsEdit(false);
|
||||
addInstantTask();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`border-t border-b-[1px] border-r-[1px]`}
|
||||
style={{ borderColor: token.colorBorderSecondary }}
|
||||
>
|
||||
{isEdit ? (
|
||||
<Input
|
||||
className="w-full rounded-none"
|
||||
style={{ borderColor: colors.skyBlue, height: '40px' }}
|
||||
placeholder={t('addTaskInputPlaceholder')}
|
||||
onChange={e => setTaskName(e.target.value)}
|
||||
onBlur={handleAddTask}
|
||||
onPressEnter={handleAddTask}
|
||||
ref={taskInputRef}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
onFocus={() => setIsEdit(true)}
|
||||
className="w-[300px] border-none"
|
||||
style={{ height: '34px' }}
|
||||
value={t('addTaskText')}
|
||||
ref={taskInputRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListInstantTaskInput;
|
||||
@@ -0,0 +1,471 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Avatar, Checkbox, DatePicker, Flex, Tag, Tooltip, Typography } from 'antd';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { columnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList';
|
||||
import AddTaskListRow from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddTaskListRow';
|
||||
|
||||
import CustomAvatar from '@/components/CustomAvatar';
|
||||
import LabelsSelector from '@components/task-list-common/labelsSelector/labels-selector';
|
||||
import { useSelectedProject } from '@/hooks/useSelectedProject';
|
||||
import StatusDropdown from '@/components/task-list-common/status-dropdown/status-dropdown';
|
||||
import PriorityDropdown from '@/components/task-list-common/priorityDropdown/priority-dropdown';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import { durationDateFormat } from '@/utils/durationDateFormat';
|
||||
import CustomColorLabel from '@components/task-list-common/labelsSelector/custom-color-label';
|
||||
import CustomNumberLabel from '@components/task-list-common/labelsSelector/custom-number-label';
|
||||
import PhaseDropdown from '@components/task-list-common/phaseDropdown/PhaseDropdown';
|
||||
import AssigneeSelector from '@components/task-list-common/assigneeSelector/AssigneeSelector';
|
||||
import TaskCell from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskCell';
|
||||
import AddSubTaskListRow from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableRows/AddSubTaskListRow';
|
||||
import { colors } from '@/styles/colors';
|
||||
import TimeTracker from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TimeTracker';
|
||||
import TaskContextMenu from '@/pages/projects/project-view-1/taskList/taskListTable/contextMenu/TaskContextMenu';
|
||||
import TaskProgress from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/TaskProgress';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import Avatars from '@/components/avatars/avatars';
|
||||
|
||||
const TaskListTable = ({
|
||||
taskList,
|
||||
tableId,
|
||||
}: {
|
||||
taskList: ITaskListGroup;
|
||||
tableId: string | undefined;
|
||||
}) => {
|
||||
// these states manage the necessary states
|
||||
const [hoverRow, setHoverRow] = useState<string | null>(null);
|
||||
const [selectedRows, setSelectedRows] = useState<string[]>([]);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [expandedTasks, setExpandedTasks] = useState<string[]>([]);
|
||||
const [isSelectAll, setIsSelectAll] = useState(false);
|
||||
// context menu state
|
||||
const [contextMenuVisible, setContextMenuVisible] = useState(false);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
// state to check scroll
|
||||
const [scrollingTables, setScrollingTables] = useState<{
|
||||
[key: string]: boolean;
|
||||
}>({});
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// get data theme data from redux
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// get the selected project details
|
||||
const selectedProject = useSelectedProject();
|
||||
|
||||
// get columns list details
|
||||
const columnsVisibility = useAppSelector(
|
||||
state => state.projectViewTaskListColumnsReducer.columnsVisibility
|
||||
);
|
||||
const visibleColumns = columnList.filter(
|
||||
column => columnsVisibility[column.key as keyof typeof columnsVisibility]
|
||||
);
|
||||
|
||||
// toggle subtasks visibility
|
||||
const toggleTaskExpansion = (taskId: string) => {
|
||||
setExpandedTasks(prev =>
|
||||
prev.includes(taskId) ? prev.filter(id => id !== taskId) : [...prev, taskId]
|
||||
);
|
||||
};
|
||||
|
||||
// toggle all task select when header checkbox click
|
||||
const toggleSelectAll = () => {
|
||||
if (isSelectAll) {
|
||||
setSelectedRows([]);
|
||||
dispatch(deselectAll());
|
||||
} else {
|
||||
// const allTaskIds =
|
||||
// task-list?.flatMap((task) => [
|
||||
// task.taskId,
|
||||
// ...(task.subTasks?.map((subtask) => subtask.taskId) || []),
|
||||
// ]) || [];
|
||||
// setSelectedRows(allTaskIds);
|
||||
// dispatch(selectTaskIds(allTaskIds));
|
||||
// console.log('selected tasks and subtasks (all):', allTaskIds);
|
||||
}
|
||||
setIsSelectAll(!isSelectAll);
|
||||
};
|
||||
|
||||
// toggle selected row
|
||||
const toggleRowSelection = (task: IProjectTask) => {
|
||||
setSelectedRows(prevSelectedRows =>
|
||||
prevSelectedRows.includes(task.id || '')
|
||||
? prevSelectedRows.filter(id => id !== task.id)
|
||||
: [...prevSelectedRows, task.id || '']
|
||||
);
|
||||
};
|
||||
|
||||
// this use effect for realtime update the selected rows
|
||||
useEffect(() => {
|
||||
console.log('Selected tasks and subtasks:', selectedRows);
|
||||
}, [selectedRows]);
|
||||
|
||||
// select one row this triggers only in handle the context menu ==> righ click mouse event
|
||||
const selectOneRow = (task: IProjectTask) => {
|
||||
setSelectedRows([task.id || '']);
|
||||
|
||||
// log the task object when selected
|
||||
if (!selectedRows.includes(task.id || '')) {
|
||||
console.log('Selected task:', task);
|
||||
}
|
||||
};
|
||||
|
||||
// handle custom task context menu
|
||||
const handleContextMenu = (e: React.MouseEvent, task: IProjectTask) => {
|
||||
e.preventDefault();
|
||||
setSelectedTaskId(task.id || '');
|
||||
selectOneRow(task);
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY });
|
||||
setContextMenuVisible(true);
|
||||
};
|
||||
|
||||
// trigger the table scrolling
|
||||
useEffect(() => {
|
||||
const tableContainer = document.querySelector(`.tasklist-container-${tableId}`);
|
||||
const handleScroll = () => {
|
||||
if (tableContainer) {
|
||||
setScrollingTables(prev => ({
|
||||
...prev,
|
||||
[tableId]: tableContainer.scrollLeft > 0,
|
||||
}));
|
||||
}
|
||||
};
|
||||
tableContainer?.addEventListener('scroll', handleScroll);
|
||||
return () => tableContainer?.removeEventListener('scroll', handleScroll);
|
||||
}, [tableId]);
|
||||
|
||||
// layout styles for table and the columns
|
||||
const customBorderColor = themeMode === 'dark' && ' border-[#303030]';
|
||||
|
||||
const customHeaderColumnStyles = (key: string) =>
|
||||
`border px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
|
||||
|
||||
const customBodyColumnStyles = (key: string) =>
|
||||
`border px-2 ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-h-[40px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#141414] border-[#303030]' : 'bg-white'}`;
|
||||
|
||||
// function to render the column content based on column key
|
||||
const renderColumnContent = (
|
||||
columnKey: string,
|
||||
task: IProjectTask,
|
||||
isSubtask: boolean = false
|
||||
) => {
|
||||
switch (columnKey) {
|
||||
// task ID column
|
||||
case 'taskId':
|
||||
return (
|
||||
<Tooltip title={task.task_key || ''} className="flex justify-center">
|
||||
<Tag>{task.task_key || ''}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
// task name column
|
||||
case 'task':
|
||||
return (
|
||||
// custom task cell component
|
||||
<TaskCell
|
||||
task={task}
|
||||
isSubTask={isSubtask}
|
||||
expandedTasks={expandedTasks}
|
||||
hoverRow={hoverRow}
|
||||
setSelectedTaskId={setSelectedTaskId}
|
||||
toggleTaskExpansion={toggleTaskExpansion}
|
||||
/>
|
||||
);
|
||||
|
||||
// description column
|
||||
case 'description':
|
||||
return <Typography.Text style={{ width: 200 }}></Typography.Text>;
|
||||
|
||||
// progress column
|
||||
case 'progress': {
|
||||
return task?.progress || task?.progress === 0 ? (
|
||||
<TaskProgress progress={task?.progress} numberOfSubTasks={task?.sub_tasks?.length || 0} />
|
||||
) : (
|
||||
<div></div>
|
||||
);
|
||||
}
|
||||
|
||||
// members column
|
||||
case 'members':
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
<Avatars members={task.names || []} />
|
||||
{/* <Avatar.Group>
|
||||
{task.assignees?.map(member => (
|
||||
<CustomAvatar key={member.id} avatarName={member.name} size={26} />
|
||||
))}
|
||||
</Avatar.Group> */}
|
||||
<AssigneeSelector taskId={selectedTaskId || '0'} />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
// labels column
|
||||
case 'labels':
|
||||
return (
|
||||
<Flex>
|
||||
{task?.labels && task?.labels?.length <= 2 ? (
|
||||
task?.labels?.map(label => <CustomColorLabel label={label} />)
|
||||
) : (
|
||||
<Flex>
|
||||
<CustomColorLabel label={task?.labels ? task.labels[0] : null} />
|
||||
<CustomColorLabel label={task?.labels ? task.labels[1] : null} />
|
||||
{/* this component show other label names */}
|
||||
<CustomNumberLabel
|
||||
// this label list get the labels without 1, 2 elements
|
||||
labelList={task?.labels ? task.labels : null}
|
||||
/>
|
||||
</Flex>
|
||||
)}
|
||||
<LabelsSelector taskId={task.id} />
|
||||
</Flex>
|
||||
);
|
||||
|
||||
// phase column
|
||||
case 'phases':
|
||||
return <PhaseDropdown projectId={selectedProject?.id || ''} />;
|
||||
|
||||
// status column
|
||||
case 'status':
|
||||
return <StatusDropdown currentStatus={task.status || ''} />;
|
||||
|
||||
// priority column
|
||||
case 'priority':
|
||||
return <PriorityDropdown currentPriority={task.priority || ''} />;
|
||||
|
||||
// time tracking column
|
||||
case 'timeTracking':
|
||||
return <TimeTracker taskId={task.id} initialTime={task.timer_start_time || 0} />;
|
||||
|
||||
// estimation column
|
||||
case 'estimation':
|
||||
return <Typography.Text>0h 0m</Typography.Text>;
|
||||
|
||||
// start date column
|
||||
case 'startDate':
|
||||
return task.start_date ? (
|
||||
<Typography.Text>{simpleDateFormat(task.start_date)}</Typography.Text>
|
||||
) : (
|
||||
<DatePicker
|
||||
placeholder="Set a start date"
|
||||
suffixIcon={null}
|
||||
style={{ border: 'none', width: '100%', height: '100%' }}
|
||||
/>
|
||||
);
|
||||
|
||||
// due date column
|
||||
case 'dueDate':
|
||||
return task.end_date ? (
|
||||
<Typography.Text>{simpleDateFormat(task.end_date)}</Typography.Text>
|
||||
) : (
|
||||
<DatePicker
|
||||
placeholder="Set a due date"
|
||||
suffixIcon={null}
|
||||
style={{ border: 'none', width: '100%', height: '100%' }}
|
||||
/>
|
||||
);
|
||||
|
||||
// completed date column
|
||||
case 'completedDate':
|
||||
return <Typography.Text>{durationDateFormat(task.completed_at || null)}</Typography.Text>;
|
||||
|
||||
// created date column
|
||||
case 'createdDate':
|
||||
return <Typography.Text>{durationDateFormat(task.created_at || null)}</Typography.Text>;
|
||||
|
||||
// last updated column
|
||||
case 'lastUpdated':
|
||||
return <Typography.Text>{durationDateFormat(task.updated_at || null)}</Typography.Text>;
|
||||
|
||||
// recorder column
|
||||
case 'reporter':
|
||||
return <Typography.Text>{task.reporter}</Typography.Text>;
|
||||
|
||||
// default case for unsupported columns
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`border-x border-b ${customBorderColor}`}>
|
||||
<div className={`tasklist-container-${tableId} min-h-0 max-w-full overflow-x-auto`}>
|
||||
<table className={`rounded-2 w-full min-w-max border-collapse`}>
|
||||
<thead className="h-[42px]">
|
||||
<tr>
|
||||
{/* this cell render the select all task checkbox */}
|
||||
<th
|
||||
key={'selector'}
|
||||
className={`${customHeaderColumnStyles('selector')}`}
|
||||
style={{ width: 20, fontWeight: 500 }}
|
||||
>
|
||||
<Checkbox checked={isSelectAll} onChange={toggleSelectAll} />
|
||||
</th>
|
||||
{/* other header cells */}
|
||||
{visibleColumns.map(column => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`${customHeaderColumnStyles(column.key)}`}
|
||||
style={{ width: column.width, fontWeight: 500 }}
|
||||
>
|
||||
{column.key === 'phases'
|
||||
? column.columnHeader
|
||||
: t(`${column.columnHeader}Column`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{taskList?.tasks?.map(task => (
|
||||
<React.Fragment key={task.id}>
|
||||
<tr
|
||||
key={task.id}
|
||||
onContextMenu={e => handleContextMenu(e, task)}
|
||||
className={`${taskList.tasks.length === 0 ? 'h-0' : 'h-[42px]'}`}
|
||||
>
|
||||
{/* this cell render the select the related task checkbox */}
|
||||
<td
|
||||
key={'selector'}
|
||||
className={customBodyColumnStyles('selector')}
|
||||
style={{
|
||||
width: 20,
|
||||
backgroundColor: selectedRows.includes(task.id || '')
|
||||
? themeMode === 'dark'
|
||||
? colors.skyBlue
|
||||
: '#dceeff'
|
||||
: hoverRow === task.id
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#f8f7f9'
|
||||
: themeMode === 'dark'
|
||||
? '#181818'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(task.id || '')}
|
||||
onChange={() => toggleRowSelection(task)}
|
||||
/>
|
||||
</td>
|
||||
{/* other cells */}
|
||||
{visibleColumns.map(column => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={customBodyColumnStyles(column.key)}
|
||||
style={{
|
||||
width: column.width,
|
||||
backgroundColor: selectedRows.includes(task.id || '')
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#dceeff'
|
||||
: hoverRow === task.id
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#f8f7f9'
|
||||
: themeMode === 'dark'
|
||||
? '#181818'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
{renderColumnContent(column.key, task)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{/* this is for sub tasks */}
|
||||
{expandedTasks.includes(task.id || '') &&
|
||||
task?.sub_tasks?.map(subtask => (
|
||||
<tr
|
||||
key={subtask.id}
|
||||
onContextMenu={e => handleContextMenu(e, subtask)}
|
||||
className={`${taskList.tasks.length === 0 ? 'h-0' : 'h-[42px]'}`}
|
||||
>
|
||||
{/* this cell render the select the related task checkbox */}
|
||||
<td
|
||||
key={'selector'}
|
||||
className={customBodyColumnStyles('selector')}
|
||||
style={{
|
||||
width: 20,
|
||||
backgroundColor: selectedRows.includes(subtask.id || '')
|
||||
? themeMode === 'dark'
|
||||
? colors.skyBlue
|
||||
: '#dceeff'
|
||||
: hoverRow === subtask.id
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#f8f7f9'
|
||||
: themeMode === 'dark'
|
||||
? '#181818'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(subtask.id || '')}
|
||||
onChange={() => toggleRowSelection(subtask)}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* other sub tasks cells */}
|
||||
{visibleColumns.map(column => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={customBodyColumnStyles(column.key)}
|
||||
style={{
|
||||
width: column.width,
|
||||
backgroundColor: selectedRows.includes(subtask.id || '')
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#dceeff'
|
||||
: hoverRow === subtask.id || ''
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#f8f7f9'
|
||||
: themeMode === 'dark'
|
||||
? '#181818'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
{renderColumnContent(column.key, subtask, true)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{expandedTasks.includes(task.id || '') && (
|
||||
<tr>
|
||||
<td colSpan={visibleColumns.length}>
|
||||
<AddSubTaskListRow />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* add a main task to the table */}
|
||||
<AddTaskListRow />
|
||||
|
||||
{/* custom task context menu */}
|
||||
<TaskContextMenu
|
||||
visible={contextMenuVisible}
|
||||
position={contextMenuPosition}
|
||||
selectedTask={selectedRows[0]}
|
||||
onClose={() => setContextMenuVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListTable;
|
||||
@@ -0,0 +1,19 @@
|
||||
.tasks-table {
|
||||
width: max-content;
|
||||
margin-left: 3px;
|
||||
border-right: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
.flex-table {
|
||||
display: flex;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.position-relative {
|
||||
position: relative;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
/* custom collapse styles for content box and the left border */
|
||||
.ant-collapse-header {
|
||||
margin-bottom: 6px !important;
|
||||
}
|
||||
|
||||
.custom-collapse-content-box .ant-collapse-content-box {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:where(.css-dev-only-do-not-override-1w6wsvq).ant-collapse-ghost
|
||||
> .ant-collapse-item
|
||||
> .ant-collapse-content
|
||||
> .ant-collapse-content-box {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
import { Badge, Button, Collapse, ConfigProvider, Dropdown, Flex, Input, Typography } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { TaskType } from '@/types/task.types';
|
||||
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { colors } from '@/styles/colors';
|
||||
import './task-list-table-wrapper.css';
|
||||
import TaskListTable from '../task-list-table-old/task-list-table-old';
|
||||
import { MenuProps } from 'antd/lib';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import TaskListCustom from '../task-list-custom';
|
||||
|
||||
type TaskListTableWrapperProps = {
|
||||
taskList: ITaskListGroup;
|
||||
groupId: string | undefined;
|
||||
name: string | undefined;
|
||||
color: string | undefined;
|
||||
onRename?: (name: string) => void;
|
||||
onStatusCategoryChange?: (category: string) => void;
|
||||
};
|
||||
|
||||
const TaskListTableWrapper = ({
|
||||
taskList,
|
||||
groupId,
|
||||
name,
|
||||
color,
|
||||
onRename,
|
||||
onStatusCategoryChange,
|
||||
}: TaskListTableWrapperProps) => {
|
||||
const [tableName, setTableName] = useState<string>(name || '');
|
||||
const [isRenaming, setIsRenaming] = useState<boolean>(false);
|
||||
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
||||
|
||||
const type = 'status';
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
// function to handle toggle expand
|
||||
const handlToggleExpand = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
// these codes only for status type tables
|
||||
// function to handle rename this functionality only available for status type tables
|
||||
const handleRename = () => {
|
||||
if (onRename) {
|
||||
onRename(tableName);
|
||||
}
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
// function to handle category change
|
||||
const handleCategoryChange = (category: string) => {
|
||||
if (onStatusCategoryChange) {
|
||||
onStatusCategoryChange(category);
|
||||
}
|
||||
};
|
||||
|
||||
// find the available status for the currently active project
|
||||
const statusList = useAppSelector(state => state.statusReducer.status);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'todo':
|
||||
return '#d8d7d8';
|
||||
case 'doing':
|
||||
return '#c0d5f6';
|
||||
case 'done':
|
||||
return '#c2e4d0';
|
||||
default:
|
||||
return '#d8d7d8';
|
||||
}
|
||||
};
|
||||
|
||||
// dropdown options
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
icon: <EditOutlined />,
|
||||
label: 'Rename',
|
||||
onClick: () => setIsRenaming(true),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
icon: <RetweetOutlined />,
|
||||
label: 'Change category',
|
||||
children: statusList?.map(status => ({
|
||||
key: status.id,
|
||||
label: (
|
||||
<Flex gap={8} onClick={() => handleCategoryChange(status.category)}>
|
||||
<Badge color={getStatusColor(status.category)} />
|
||||
{status.name}
|
||||
</Flex>
|
||||
),
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
wave={{ disabled: true }}
|
||||
theme={{
|
||||
components: {
|
||||
Collapse: {
|
||||
headerPadding: 0,
|
||||
contentPadding: 0,
|
||||
},
|
||||
|
||||
Select: {
|
||||
colorBorder: colors.transparent,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flex vertical>
|
||||
<Flex style={{ transform: 'translateY(6px)' }}>
|
||||
<Button
|
||||
className="custom-collapse-button"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
border: 'none',
|
||||
borderBottomLeftRadius: isExpanded ? 0 : 4,
|
||||
borderBottomRightRadius: isExpanded ? 0 : 4,
|
||||
color: colors.darkGray,
|
||||
}}
|
||||
icon={<RightOutlined rotate={isExpanded ? 90 : 0} />}
|
||||
onClick={handlToggleExpand}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<Input
|
||||
size="small"
|
||||
value={tableName}
|
||||
onChange={e => setTableName(e.target.value)}
|
||||
onBlur={handleRename}
|
||||
onPressEnter={handleRename}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: colors.darkGray,
|
||||
}}
|
||||
>
|
||||
{['todo', 'doing', 'done', 'low', 'medium', 'high'].includes(
|
||||
tableName.replace(/\s+/g, '').toLowerCase()
|
||||
)
|
||||
? t(`${tableName.replace(/\s+/g, '').toLowerCase()}SelectorText`)
|
||||
: tableName}{' '}
|
||||
({taskList.tasks.length})
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Button>
|
||||
{type === 'status' && !isRenaming && (
|
||||
<Dropdown menu={{ items }}>
|
||||
<Button icon={<EllipsisOutlined />} className="borderless-icon-btn" />
|
||||
</Dropdown>
|
||||
)}
|
||||
</Flex>
|
||||
<Collapse
|
||||
collapsible="header"
|
||||
className="border-l-[4px]"
|
||||
bordered={false}
|
||||
ghost={true}
|
||||
expandIcon={() => null}
|
||||
activeKey={isExpanded ? groupId || '1' : undefined}
|
||||
onChange={handlToggleExpand}
|
||||
items={[
|
||||
{
|
||||
key: groupId || '1',
|
||||
className: `custom-collapse-content-box relative after:content after:absolute after:h-full after:w-1 ${color} after:z-10 after:top-0 after:left-0`,
|
||||
children: (
|
||||
<TaskListCustom
|
||||
key={groupId}
|
||||
groupId={groupId}
|
||||
tasks={taskList.tasks}
|
||||
color={color || ''}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListTableWrapper;
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Flex, Skeleton } from 'antd';
|
||||
import TaskListFilters from '@/pages/projects/project-view-1/taskList/taskListFilters/TaskListFilters';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { ITaskListConfigV2, ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { fetchTaskGroups } from '@/features/tasks/taskSlice';
|
||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||
|
||||
import { columnList } from '@/pages/projects/project-view-1/taskList/taskListTable/columns/columnList';
|
||||
import StatusGroupTables from '../taskList/statusTables/StatusGroupTables';
|
||||
|
||||
const TaskList = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { taskGroups, loadingGroups } = useAppSelector(state => state.taskReducer);
|
||||
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
|
||||
const columnsVisibility = useAppSelector(
|
||||
state => state.projectViewTaskListColumnsReducer.columnsVisibility
|
||||
);
|
||||
|
||||
const visibleColumns = columnList.filter(
|
||||
column => columnsVisibility[column.key as keyof typeof columnsVisibility]
|
||||
);
|
||||
|
||||
const onTaskSelect = (taskId: string) => {
|
||||
console.log('taskId:', taskId);
|
||||
};
|
||||
|
||||
const onTaskExpand = (taskId: string) => {
|
||||
console.log('taskId:', taskId);
|
||||
};
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
const config: ITaskListConfigV2 = {
|
||||
id: projectId,
|
||||
field: 'id',
|
||||
order: 'desc',
|
||||
search: '',
|
||||
statuses: '',
|
||||
members: '',
|
||||
projects: '',
|
||||
isSubtasksInclude: true,
|
||||
};
|
||||
dispatch(fetchTaskGroups(config));
|
||||
}
|
||||
if (!statusCategories.length) {
|
||||
dispatch(fetchStatusesCategories());
|
||||
}
|
||||
}, [dispatch, projectId]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16}>
|
||||
<TaskListFilters position="list" />
|
||||
<Skeleton active loading={loadingGroups}>
|
||||
{/* {taskGroups.map((group: ITaskListGroup) => (
|
||||
|
||||
))} */}
|
||||
</Skeleton>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskList;
|
||||
@@ -0,0 +1,56 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Flex } from 'antd';
|
||||
import TaskListFilters from './taskListFilters/TaskListFilters';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { fetchTaskGroups } from '@/features/tasks/tasks.slice';
|
||||
import { ITaskListConfigV2 } from '@/types/tasks/taskList.types';
|
||||
import TanStackTable from '../task-list/task-list-custom';
|
||||
import TaskListCustom from '../task-list/task-list-custom';
|
||||
import TaskListTableWrapper from '../task-list/task-list-table-wrapper/task-list-table-wrapper';
|
||||
|
||||
const ProjectViewTaskList = () => {
|
||||
// sample data from task reducer
|
||||
const dispatch = useAppDispatch();
|
||||
const { taskGroups, loadingGroups } = useAppSelector(state => state.taskReducer);
|
||||
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId) {
|
||||
const config: ITaskListConfigV2 = {
|
||||
id: projectId,
|
||||
field: 'id',
|
||||
order: 'desc',
|
||||
search: '',
|
||||
statuses: '',
|
||||
members: '',
|
||||
projects: '',
|
||||
isSubtasksInclude: true,
|
||||
};
|
||||
dispatch(fetchTaskGroups(config));
|
||||
}
|
||||
if (!statusCategories.length) {
|
||||
dispatch(fetchStatusesCategories());
|
||||
}
|
||||
}, [dispatch, projectId]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||
<TaskListFilters position="list" />
|
||||
|
||||
{taskGroups.map(group => (
|
||||
<TaskListTableWrapper
|
||||
key={group.id}
|
||||
taskList={group}
|
||||
name={group.name || ''}
|
||||
color={group.color_code || ''}
|
||||
groupId={group.id || ''}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewTaskList;
|
||||
@@ -0,0 +1,67 @@
|
||||
import { TaskType } from '@/types/task.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { Flex } from 'antd';
|
||||
import TaskListTableWrapper from '@/pages/projects/project-view-1/taskList/taskListTable/TaskListTableWrapper';
|
||||
import { createPortal } from 'react-dom';
|
||||
import BulkTasksActionContainer from '@/features/projects/bulkActions/BulkTasksActionContainer';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
|
||||
const StatusGroupTables = ({ group }: { group: ITaskListGroup }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// get bulk action detatils
|
||||
const selectedTaskIdsList = useAppSelector(state => state.bulkActionReducer.selectedTaskIdsList);
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// fuction for get a color regariding the status
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'todo':
|
||||
return themeMode === 'dark' ? '#3a3a3a' : '#d8d7d8';
|
||||
case 'doing':
|
||||
return themeMode === 'dark' ? '#3d506e' : '#c0d5f6';
|
||||
case 'done':
|
||||
return themeMode === 'dark' ? '#3b6149' : '#c2e4d0';
|
||||
default:
|
||||
return themeMode === 'dark' ? '#3a3a3a' : '#d8d7d8';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex gap={24} vertical>
|
||||
{group?.tasks?.map(status => (
|
||||
<TaskListTableWrapper
|
||||
key={status.id}
|
||||
taskList={group.tasks}
|
||||
tableId={status.id || ''}
|
||||
name={status.name || ''}
|
||||
type="status"
|
||||
statusCategory={status.status || ''}
|
||||
color={getStatusColor(status.status || '')}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* bulk action container ==> used tailwind to recreate the animation */}
|
||||
{createPortal(
|
||||
<div
|
||||
className={`absolute bottom-0 left-1/2 z-20 -translate-x-1/2 ${selectedTaskIdsList.length > 0 ? 'overflow-visible' : 'h-[1px] overflow-hidden'}`}
|
||||
>
|
||||
<div
|
||||
className={`${selectedTaskIdsList.length > 0 ? 'bottom-4' : 'bottom-0'} absolute left-1/2 z-[999] -translate-x-1/2 transition-all duration-300`}
|
||||
>
|
||||
<BulkTasksActionContainer
|
||||
selectedTaskIds={selectedTaskIdsList}
|
||||
closeContainer={() => dispatch(deselectAll())}
|
||||
/>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusGroupTables;
|
||||
@@ -0,0 +1,69 @@
|
||||
import { CaretDownFilled } from '@ant-design/icons';
|
||||
import { ConfigProvider, Flex, Select } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import ConfigPhaseButton from '@features/projects/singleProject/phase/ConfigPhaseButton';
|
||||
import { useSelectedProject } from '@/hooks/useSelectedProject';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import CreateStatusButton from '@/components/project-task-filters/create-status-button/create-status-button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setGroupBy } from '@features/group-by-filter-dropdown/group-by-filter-dropdown-slice';
|
||||
|
||||
const GroupByFilterDropdown = ({ position }: { position: 'list' | 'board' }) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
type GroupTypes = 'status' | 'priority' | 'phase' | 'members' | 'list';
|
||||
|
||||
const [activeGroup, setActiveGroup] = useState<GroupTypes>('status');
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setActiveGroup(value as GroupTypes);
|
||||
dispatch(setGroupBy(value as GroupTypes));
|
||||
};
|
||||
|
||||
// get selected project from useSelectedPro
|
||||
const selectedProject = useSelectedProject();
|
||||
|
||||
//get phases details from phases slice
|
||||
const phase =
|
||||
useAppSelector(state => state.phaseReducer.phaseList).find(
|
||||
phase => phase.projectId === selectedProject?.id
|
||||
) || null;
|
||||
|
||||
const groupDropdownMenuItems = [
|
||||
{ key: 'status', value: 'status', label: t('statusText') },
|
||||
{ key: 'priority', value: 'priority', label: t('priorityText') },
|
||||
{
|
||||
key: 'phase',
|
||||
value: 'phase',
|
||||
label: phase ? phase?.phase : t('phaseText'),
|
||||
},
|
||||
{ key: 'members', value: 'members', label: t('memberText') },
|
||||
{ key: 'list', value: 'list', label: t('listText') },
|
||||
];
|
||||
|
||||
return (
|
||||
<Flex align="center" gap={4} style={{ marginInlineStart: 12 }}>
|
||||
{t('groupByText')}:
|
||||
<Select
|
||||
defaultValue={'status'}
|
||||
options={groupDropdownMenuItems}
|
||||
onChange={handleChange}
|
||||
suffixIcon={<CaretDownFilled />}
|
||||
dropdownStyle={{ width: 'wrap-content' }}
|
||||
/>
|
||||
{(activeGroup === 'status' || activeGroup === 'phase') && (
|
||||
<ConfigProvider wave={{ disabled: true }}>
|
||||
{activeGroup === 'phase' && <ConfigPhaseButton color={colors.skyBlue} />}
|
||||
{activeGroup === 'status' && <CreateStatusButton />}
|
||||
</ConfigProvider>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupByFilterDropdown;
|
||||
@@ -0,0 +1,134 @@
|
||||
import { CaretDownFilled } from '@ant-design/icons';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Flex,
|
||||
Input,
|
||||
InputRef,
|
||||
List,
|
||||
Space,
|
||||
} from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const LabelsFilterDropdown = (props: { labels: ITaskLabel[] }) => {
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
const labelInputRef = useRef<InputRef>(null);
|
||||
const [selectedCount, setSelectedCount] = useState<number>(0);
|
||||
const [filteredLabelList, setFilteredLabelList] = useState<ITaskLabel[]>(props.labels);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
setFilteredLabelList(props.labels);
|
||||
}, [props.labels]);
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// handle selected filters count
|
||||
const handleSelectedFiltersCount = (checked: boolean) => {
|
||||
setSelectedCount(prev => (checked ? prev + 1 : prev - 1));
|
||||
};
|
||||
|
||||
// function to focus labels input
|
||||
const handleLabelsDropdownOpen = (open: boolean) => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
labelInputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchQuery = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const searchText = e.currentTarget.value;
|
||||
setSearchQuery(searchText);
|
||||
if (searchText.length === 0) {
|
||||
setFilteredLabelList(props.labels);
|
||||
return;
|
||||
}
|
||||
setFilteredLabelList(
|
||||
props.labels.filter(label => label.name?.toLowerCase().includes(searchText.toLowerCase()))
|
||||
);
|
||||
};
|
||||
|
||||
// custom dropdown content
|
||||
const labelsDropdownContent = (
|
||||
<Card
|
||||
className="custom-card"
|
||||
styles={{
|
||||
body: { padding: 8, width: 260, maxHeight: 250, overflow: 'hidden', overflowY: 'auto' },
|
||||
}}
|
||||
>
|
||||
<Flex vertical gap={8}>
|
||||
<Input
|
||||
ref={labelInputRef}
|
||||
value={searchQuery}
|
||||
onChange={e => handleSearchQuery(e)}
|
||||
placeholder={t('searchInputPlaceholder')}
|
||||
/>
|
||||
|
||||
<List style={{ padding: 0 }}>
|
||||
{filteredLabelList.length ? (
|
||||
filteredLabelList.map(label => (
|
||||
<List.Item
|
||||
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
key={label.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 8,
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
id={label.id}
|
||||
onChange={e => handleSelectedFiltersCount(e.target.checked)}
|
||||
/>
|
||||
|
||||
<Flex gap={8}>
|
||||
<Badge color={label.color_code} />
|
||||
{label.name}
|
||||
</Flex>
|
||||
</List.Item>
|
||||
))
|
||||
) : (
|
||||
<Empty description={t('noLabelsFound')} />
|
||||
)}
|
||||
</List>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="custom-dropdown"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => labelsDropdownContent}
|
||||
onOpenChange={handleLabelsDropdownOpen}
|
||||
>
|
||||
<Button
|
||||
icon={<CaretDownFilled />}
|
||||
iconPosition="end"
|
||||
style={{
|
||||
backgroundColor: selectedCount > 0 ? colors.paleBlue : colors.transparent,
|
||||
|
||||
color: selectedCount > 0 ? colors.darkGray : 'inherit',
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
{t('labelsText')}
|
||||
{selectedCount > 0 && <Badge size="small" count={selectedCount} color={colors.skyBlue} />}
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelsFilterDropdown;
|
||||
@@ -0,0 +1,140 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { CaretDownFilled } from '@ant-design/icons';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Flex,
|
||||
Input,
|
||||
InputRef,
|
||||
List,
|
||||
Space,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import CustomAvatar from '@components/CustomAvatar';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const MembersFilterDropdown = () => {
|
||||
const [selectedCount, setSelectedCount] = useState<number>(0);
|
||||
const membersInputRef = useRef<InputRef>(null);
|
||||
|
||||
const members = useAppSelector(state => state.memberReducer.membersList);
|
||||
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
|
||||
const membersList = [
|
||||
...members,
|
||||
useAppSelector(state => state.memberReducer.owner),
|
||||
];
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// this is for get the current string that type on search bar
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
// used useMemo hook for re render the list when searching
|
||||
const filteredMembersData = useMemo(() => {
|
||||
return membersList.filter(member =>
|
||||
member.memberName.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [membersList, searchQuery]);
|
||||
|
||||
// handle selected filters count
|
||||
const handleSelectedFiltersCount = (checked: boolean) => {
|
||||
setSelectedCount(prev => (checked ? prev + 1 : prev - 1));
|
||||
};
|
||||
|
||||
// custom dropdown content
|
||||
const membersDropdownContent = (
|
||||
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
|
||||
<Flex vertical gap={8}>
|
||||
<Input
|
||||
ref={membersInputRef}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.currentTarget.value)}
|
||||
placeholder={t('searchInputPlaceholder')}
|
||||
/>
|
||||
|
||||
<List style={{ padding: 0 }}>
|
||||
{filteredMembersData.length ? (
|
||||
filteredMembersData.map(member => (
|
||||
<List.Item
|
||||
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
key={member.memberId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
id={member.memberId}
|
||||
onChange={e => handleSelectedFiltersCount(e.target.checked)}
|
||||
/>
|
||||
<div>
|
||||
<CustomAvatar avatarName={member.memberName} />
|
||||
</div>
|
||||
<Flex vertical>
|
||||
{member.memberName}
|
||||
|
||||
<Typography.Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: colors.lightGray,
|
||||
}}
|
||||
>
|
||||
{member.memberEmail}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</List.Item>
|
||||
))
|
||||
) : (
|
||||
<Empty />
|
||||
)}
|
||||
</List>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// function to focus members input
|
||||
const handleMembersDropdownOpen = (open: boolean) => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
membersInputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="custom-dropdown"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => membersDropdownContent}
|
||||
onOpenChange={handleMembersDropdownOpen}
|
||||
>
|
||||
<Button
|
||||
icon={<CaretDownFilled />}
|
||||
iconPosition="end"
|
||||
style={{
|
||||
backgroundColor: selectedCount > 0 ? colors.paleBlue : colors.transparent,
|
||||
|
||||
color: selectedCount > 0 ? colors.darkGray : 'inherit',
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
{t('membersText')}
|
||||
{selectedCount > 0 && <Badge size="small" count={selectedCount} color={colors.skyBlue} />}
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default MembersFilterDropdown;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { CaretDownFilled } from '@ant-design/icons';
|
||||
import { Badge, Button, Card, Checkbox, Dropdown, List, Space } from 'antd';
|
||||
import { useState } from 'react';
|
||||
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const PriorityFilterDropdown = (props: { priorities: ITaskPriority[] }) => {
|
||||
const [selectedCount, setSelectedCount] = useState<number>(0);
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// handle selected filters count
|
||||
const handleSelectedFiltersCount = (checked: boolean) => {
|
||||
setSelectedCount(prev => (checked ? prev + 1 : prev - 1));
|
||||
};
|
||||
|
||||
// custom dropdown content
|
||||
const priorityDropdownContent = (
|
||||
<Card className="custom-card" style={{ width: 120 }} styles={{ body: { padding: 0 } }}>
|
||||
<List style={{ padding: 0 }}>
|
||||
{props.priorities?.map(item => (
|
||||
<List.Item
|
||||
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
key={item.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<Checkbox id={item.id} onChange={e => handleSelectedFiltersCount(e.target.checked)} />
|
||||
<Badge color={item.color_code} />
|
||||
{item.name}
|
||||
</Space>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="custom-dropdown"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => priorityDropdownContent}
|
||||
>
|
||||
<Button
|
||||
icon={<CaretDownFilled />}
|
||||
iconPosition="end"
|
||||
style={{
|
||||
backgroundColor: selectedCount > 0 ? colors.paleBlue : colors.transparent,
|
||||
|
||||
color: selectedCount > 0 ? colors.darkGray : 'inherit',
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
{t('priorityText')}
|
||||
{selectedCount > 0 && <Badge size="small" count={selectedCount} color={colors.skyBlue} />}
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriorityFilterDropdown;
|
||||
@@ -0,0 +1,54 @@
|
||||
import { SearchOutlined } from '@ant-design/icons';
|
||||
import { Button, Card, Dropdown, Flex, Input, InputRef, Space } from 'antd';
|
||||
import { useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const SearchDropdown = () => {
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
|
||||
const searchInputRef = useRef<InputRef>(null);
|
||||
|
||||
const handleSearchInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
};
|
||||
|
||||
// custom dropdown content
|
||||
const searchDropdownContent = (
|
||||
<Card className="custom-card" styles={{ body: { padding: 8, width: 360 } }}>
|
||||
<Flex vertical gap={8}>
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder={t('searchInputPlaceholder')}
|
||||
onChange={handleSearchInputChange}
|
||||
/>
|
||||
<Space>
|
||||
<Button type="primary">{t('searchButton')}</Button>
|
||||
<Button>{t('resetButton')}</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// function to focus search input
|
||||
const handleSearchDropdownOpen = (open: boolean) => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="custom-dropdown"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => searchDropdownContent}
|
||||
onOpenChange={handleSearchDropdownOpen}
|
||||
>
|
||||
<Button icon={<SearchOutlined />} />
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default SearchDropdown;
|
||||
@@ -0,0 +1,96 @@
|
||||
import { MoreOutlined } from '@ant-design/icons';
|
||||
import { Button, Card, Checkbox, Dropdown, List, Space } from 'antd';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
projectViewTaskListColumnsState,
|
||||
toggleColumnVisibility,
|
||||
} from '@features/projects/singleProject/taskListColumns/taskColumnsSlice';
|
||||
import { columnList } from '../taskListTable/columns/columnList';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
|
||||
const ShowFieldsFilterDropdown = () => {
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
const dispatch = useAppDispatch();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const customColumns = useAppSelector(state => state.taskReducer.customColumns);
|
||||
|
||||
const changableColumnList = [
|
||||
...columnList.filter(column => !['selector', 'task'].includes(column.key)),
|
||||
...(customColumns || []).map(col => ({
|
||||
key: col.key,
|
||||
columnHeader: col.custom_column_obj.columnHeader,
|
||||
isCustomColumn: col.custom_column,
|
||||
}))
|
||||
];
|
||||
|
||||
const columnsVisibility = useAppSelector(
|
||||
state => state.projectViewTaskListColumnsReducer.columnsVisibility
|
||||
);
|
||||
|
||||
const handleColumnToggle = (columnKey: string, isCustomColumn: boolean = false) => {
|
||||
if (isCustomColumn) {
|
||||
// dispatch(toggleCustomColumnVisibility(columnKey));
|
||||
} else {
|
||||
dispatch(toggleColumnVisibility(columnKey));
|
||||
}
|
||||
trackMixpanelEvent('task_list_column_visibility_changed', {
|
||||
column: columnKey,
|
||||
isCustomColumn,
|
||||
visible: !columnsVisibility[columnKey as keyof typeof columnsVisibility],
|
||||
});
|
||||
};
|
||||
|
||||
const showFieldsDropdownContent = (
|
||||
<Card
|
||||
className="custom-card"
|
||||
style={{
|
||||
height: 300,
|
||||
overflowY: 'auto',
|
||||
minWidth: 130,
|
||||
}}
|
||||
styles={{ body: { padding: 0 } }}
|
||||
>
|
||||
<List style={{ padding: 0 }}>
|
||||
{changableColumnList.map(col => (
|
||||
<List.Item
|
||||
key={col.key}
|
||||
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => handleColumnToggle(col.key, col.custom_column)}
|
||||
>
|
||||
<Space>
|
||||
<Checkbox
|
||||
checked={
|
||||
columnsVisibility[
|
||||
col.key as keyof projectViewTaskListColumnsState['columnsVisibility']
|
||||
]
|
||||
}
|
||||
/>
|
||||
{col.custom_column
|
||||
? col.columnHeader
|
||||
: t(col.key === 'phases' ? 'phasesText' : `${col.columnHeader}Text`)}
|
||||
</Space>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown overlay={showFieldsDropdownContent} trigger={['click']} placement="bottomRight">
|
||||
<Button icon={<MoreOutlined />}>{t('showFieldsText')}</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default ShowFieldsFilterDropdown;
|
||||
@@ -0,0 +1,110 @@
|
||||
import { CaretDownFilled, SortAscendingOutlined, SortDescendingOutlined } from '@ant-design/icons';
|
||||
import { Badge, Button, Card, Checkbox, Dropdown, List, Space } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { colors } from '../../../../../styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const SortFilterDropdown = () => {
|
||||
const [selectedCount, setSelectedCount] = useState<number>(0);
|
||||
const [sortState, setSortState] = useState<Record<string, 'ascending' | 'descending'>>({});
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
|
||||
// handle selected filters count
|
||||
const handleSelectedFiltersCount = (checked: boolean) => {
|
||||
setSelectedCount(prev => (checked ? prev + 1 : prev - 1));
|
||||
};
|
||||
|
||||
// fuction for handle sort
|
||||
const handleSort = (key: string) => {
|
||||
setSortState(prev => ({
|
||||
...prev,
|
||||
[key]: prev[key] === 'ascending' ? 'descending' : 'ascending',
|
||||
}));
|
||||
};
|
||||
|
||||
// sort dropdown items
|
||||
type SortFieldsType = {
|
||||
key: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const sortFieldsList: SortFieldsType[] = [
|
||||
{ key: 'task', label: t('taskText') },
|
||||
{ key: 'status', label: t('statusText') },
|
||||
{ key: 'priority', label: t('priorityText') },
|
||||
{ key: 'startDate', label: t('startDateText') },
|
||||
{ key: 'endDate', label: t('endDateText') },
|
||||
{ key: 'completedDate', label: t('completedDateText') },
|
||||
{ key: 'createdDate', label: t('createdDateText') },
|
||||
{ key: 'lastUpdated', label: t('lastUpdatedText') },
|
||||
];
|
||||
|
||||
// custom dropdown content
|
||||
const sortDropdownContent = (
|
||||
<Card className="custom-card" styles={{ body: { padding: 0 } }}>
|
||||
<List style={{ padding: 0 }}>
|
||||
{sortFieldsList.map(item => (
|
||||
<List.Item
|
||||
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
|
||||
key={item.key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<Checkbox
|
||||
id={item.key}
|
||||
onChange={e => handleSelectedFiltersCount(e.target.checked)}
|
||||
/>
|
||||
{item.label}
|
||||
</Space>
|
||||
<Button
|
||||
onClick={() => handleSort(item.key)}
|
||||
icon={
|
||||
sortState[item.key] === 'ascending' ? (
|
||||
<SortAscendingOutlined />
|
||||
) : (
|
||||
<SortDescendingOutlined />
|
||||
)
|
||||
}
|
||||
/>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="custom-dropdown"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => sortDropdownContent}
|
||||
>
|
||||
<Button
|
||||
icon={<CaretDownFilled />}
|
||||
iconPosition="end"
|
||||
style={{
|
||||
backgroundColor: selectedCount > 0 ? colors.paleBlue : colors.transparent,
|
||||
|
||||
color: selectedCount > 0 ? colors.darkGray : 'inherit',
|
||||
}}
|
||||
>
|
||||
<Space>
|
||||
<SortAscendingOutlined />
|
||||
{t('sortText')}
|
||||
{selectedCount > 0 && <Badge size="small" count={selectedCount} color={colors.skyBlue} />}
|
||||
</Space>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default SortFilterDropdown;
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Checkbox, Flex, Typography } from 'antd';
|
||||
import SearchDropdown from './SearchDropdown';
|
||||
import SortFilterDropdown from './SortFilterDropdown';
|
||||
import LabelsFilterDropdown from './LabelsFilterDropdown';
|
||||
import MembersFilterDropdown from './MembersFilterDropdown';
|
||||
import GroupByFilterDropdown from './GroupByFilterDropdown';
|
||||
import ShowFieldsFilterDropdown from './ShowFieldsFilterDropdown';
|
||||
import PriorityFilterDropdown from './PriorityFilterDropdown';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useEffect } from 'react';
|
||||
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
|
||||
interface TaskListFiltersProps {
|
||||
position: 'board' | 'list';
|
||||
}
|
||||
|
||||
const TaskListFilters: React.FC<TaskListFiltersProps> = ({ position }) => {
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// Selectors
|
||||
const priorities = useAppSelector(state => state.priorityReducer.priorities);
|
||||
const labels = useAppSelector(state => state.taskLabelsReducer.labels);
|
||||
|
||||
// Fetch initial data
|
||||
useEffect(() => {
|
||||
const fetchInitialData = async () => {
|
||||
if (!priorities.length) {
|
||||
await dispatch(fetchPriorities());
|
||||
}
|
||||
if (!labels.length) {
|
||||
await dispatch(fetchLabels());
|
||||
}
|
||||
};
|
||||
|
||||
fetchInitialData();
|
||||
}, [dispatch, priorities.length, labels.length]);
|
||||
|
||||
return (
|
||||
<Flex gap={8} align="center" justify="space-between">
|
||||
<Flex gap={8} wrap={'wrap'}>
|
||||
{/* search dropdown */}
|
||||
<SearchDropdown />
|
||||
{/* sort dropdown */}
|
||||
<SortFilterDropdown />
|
||||
{/* prioriy dropdown */}
|
||||
<PriorityFilterDropdown priorities={priorities} />
|
||||
{/* labels dropdown */}
|
||||
<LabelsFilterDropdown labels={labels} />
|
||||
{/* members dropdown */}
|
||||
<MembersFilterDropdown />
|
||||
{/* group by dropdown */}
|
||||
{<GroupByFilterDropdown position={position} />}
|
||||
</Flex>
|
||||
|
||||
{position === 'list' && (
|
||||
<Flex gap={12} wrap={'wrap'}>
|
||||
<Flex gap={4} align="center">
|
||||
<Checkbox />
|
||||
<Typography.Text>{t('showArchivedText')}</Typography.Text>
|
||||
</Flex>
|
||||
{/* show fields dropdown */}
|
||||
<ShowFieldsFilterDropdown />
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListFilters;
|
||||
@@ -0,0 +1,425 @@
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { columnList } from './columns/columnList';
|
||||
import AddTaskListRow from './taskListTableRows/AddTaskListRow';
|
||||
import { Checkbox, Flex, Tag, Tooltip } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useSelectedProject } from '@/hooks/useSelectedProject';
|
||||
import TaskCell from './taskListTableCells/TaskCell';
|
||||
import AddSubTaskListRow from './taskListTableRows/AddSubTaskListRow';
|
||||
import { colors } from '@/styles/colors';
|
||||
import TaskContextMenu from './contextMenu/TaskContextMenu';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { deselectAll } from '@features/projects/bulkActions/bulkActionSlice';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { HolderOutlined } from '@ant-design/icons';
|
||||
|
||||
const TaskListTable = ({
|
||||
taskList,
|
||||
tableId,
|
||||
}: {
|
||||
taskList: IProjectTask[] | null;
|
||||
tableId: string;
|
||||
}) => {
|
||||
// these states manage the necessary states
|
||||
const [hoverRow, setHoverRow] = useState<string | null>(null);
|
||||
const [selectedRows, setSelectedRows] = useState<string[]>([]);
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [expandedTasks, setExpandedTasks] = useState<string[]>([]);
|
||||
const [isSelectAll, setIsSelectAll] = useState(false);
|
||||
// context menu state
|
||||
const [contextMenuVisible, setContextMenuVisible] = useState(false);
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
// state to check scroll
|
||||
const [scrollingTables, setScrollingTables] = useState<{
|
||||
[key: string]: boolean;
|
||||
}>({});
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// get data theme data from redux
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// get the selected project details
|
||||
const selectedProject = useSelectedProject();
|
||||
|
||||
// get columns list details
|
||||
const columnsVisibility = useAppSelector( state => state.projectViewTaskListColumnsReducer.columnList );
|
||||
const visibleColumns = columnList.filter(
|
||||
column => columnsVisibility[column.key as keyof typeof columnsVisibility]
|
||||
);
|
||||
|
||||
// toggle subtasks visibility
|
||||
const toggleTaskExpansion = (taskId: string) => {
|
||||
setExpandedTasks(prev =>
|
||||
prev.includes(taskId) ? prev.filter(id => id !== taskId) : [...prev, taskId]
|
||||
);
|
||||
};
|
||||
|
||||
// toggle all task select when header checkbox click
|
||||
const toggleSelectAll = () => {
|
||||
if (isSelectAll) {
|
||||
setSelectedRows([]);
|
||||
dispatch(deselectAll());
|
||||
} else {
|
||||
const allTaskIds =
|
||||
taskList?.flatMap(task => [
|
||||
task.id,
|
||||
...(task.sub_tasks?.map(subtask => subtask.id) || []),
|
||||
]) || [];
|
||||
|
||||
// setSelectedRows(allTaskIds);
|
||||
// dispatch(selectTaskIds(allTaskIds));
|
||||
// console.log('selected tasks and subtasks (all):', allTaskIds);
|
||||
}
|
||||
setIsSelectAll(!isSelectAll);
|
||||
};
|
||||
|
||||
// toggle selected row
|
||||
const toggleRowSelection = (task: IProjectTask) => {
|
||||
setSelectedRows(prevSelectedRows =>
|
||||
prevSelectedRows.includes(task.id || '')
|
||||
? prevSelectedRows.filter(id => id !== task.id || '')
|
||||
: [...prevSelectedRows, task.id || '']
|
||||
);
|
||||
};
|
||||
|
||||
// this use effect for realtime update the selected rows
|
||||
useEffect(() => {
|
||||
console.log('Selected tasks and subtasks:', selectedRows);
|
||||
}, [selectedRows]);
|
||||
|
||||
// select one row this triggers only in handle the context menu ==> righ click mouse event
|
||||
const selectOneRow = (task: IProjectTask) => {
|
||||
setSelectedRows([task.id || '']);
|
||||
|
||||
// log the task object when selected
|
||||
if (!selectedRows.includes(task.id || '')) {
|
||||
console.log('Selected task:', task);
|
||||
}
|
||||
};
|
||||
|
||||
// handle custom task context menu
|
||||
const handleContextMenu = (e: React.MouseEvent, task: IProjectTask) => {
|
||||
e.preventDefault();
|
||||
setSelectedTaskId(task.id || '');
|
||||
selectOneRow(task);
|
||||
setContextMenuPosition({ x: e.clientX, y: e.clientY });
|
||||
setContextMenuVisible(true);
|
||||
};
|
||||
|
||||
// trigger the table scrolling
|
||||
useEffect(() => {
|
||||
const tableContainer = document.querySelector(`.tasklist-container-${tableId}`);
|
||||
const handleScroll = () => {
|
||||
if (tableContainer) {
|
||||
setScrollingTables(prev => ({
|
||||
...prev,
|
||||
[tableId]: tableContainer.scrollLeft > 0,
|
||||
}));
|
||||
}
|
||||
};
|
||||
tableContainer?.addEventListener('scroll', handleScroll);
|
||||
return () => tableContainer?.removeEventListener('scroll', handleScroll);
|
||||
}, [tableId]);
|
||||
|
||||
// layout styles for table and the columns
|
||||
const customBorderColor = themeMode === 'dark' && ' border-[#303030]';
|
||||
|
||||
const customHeaderColumnStyles = (key: string) =>
|
||||
`border px-2 text-left ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:h-[42px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#1d1d1d] border-[#303030]' : 'bg-[#fafafa]'}`;
|
||||
|
||||
const customBodyColumnStyles = (key: string) =>
|
||||
`border px-2 ${key === 'selector' && 'sticky left-0 z-10'} ${key === 'task' && `sticky left-[33px] z-10 after:content after:absolute after:top-0 after:-right-1 after:-z-10 after:min-h-[40px] after:w-1.5 after:bg-transparent ${scrollingTables[tableId] ? 'after:bg-gradient-to-r after:from-[rgba(0,0,0,0.12)] after:to-transparent' : ''}`} ${themeMode === 'dark' ? 'bg-[#141414] border-[#303030]' : 'bg-white'}`;
|
||||
|
||||
// function to render the column content based on column key
|
||||
const renderColumnContent = (
|
||||
columnKey: string,
|
||||
task: IProjectTask,
|
||||
isSubtask: boolean = false
|
||||
) => {
|
||||
switch (columnKey) {
|
||||
// task ID column
|
||||
case 'taskId':
|
||||
return (
|
||||
<Tooltip title={task.id} className="flex justify-center">
|
||||
<Tag>{task.task_key}</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
// task column
|
||||
case 'task':
|
||||
return (
|
||||
// custom task cell component
|
||||
<TaskCell
|
||||
task={task}
|
||||
isSubTask={isSubtask}
|
||||
expandedTasks={expandedTasks}
|
||||
setSelectedTaskId={setSelectedTaskId}
|
||||
toggleTaskExpansion={toggleTaskExpansion}
|
||||
/>
|
||||
);
|
||||
|
||||
// description column
|
||||
case 'description':
|
||||
return (
|
||||
<div style={{ width: 260 }}>
|
||||
{/* <Typography.Paragraph ellipsis={{ expandable: false }} style={{ marginBlockEnd: 0 }} >
|
||||
{task.description || ''}
|
||||
</Typography.Paragraph> */}
|
||||
</div>
|
||||
);
|
||||
|
||||
// progress column
|
||||
case 'progress': {
|
||||
return <div></div>;
|
||||
}
|
||||
|
||||
// members column
|
||||
case 'members':
|
||||
return <div></div>;
|
||||
|
||||
// labels column
|
||||
case 'labels':
|
||||
return <div></div>;
|
||||
|
||||
// phase column
|
||||
case 'phases':
|
||||
return <div></div>;
|
||||
|
||||
// status column
|
||||
case 'status':
|
||||
return <div></div>;
|
||||
|
||||
// priority column
|
||||
case 'priority':
|
||||
return <div></div>;
|
||||
|
||||
// // time tracking column
|
||||
// case 'timeTracking':
|
||||
// return (
|
||||
// <TimeTracker
|
||||
// taskId={task.id}
|
||||
// initialTime={task.timer_start_time || 0}
|
||||
// />
|
||||
// );
|
||||
|
||||
// estimation column
|
||||
case 'estimation':
|
||||
return <div></div>;
|
||||
|
||||
// start date column
|
||||
case 'startDate':
|
||||
return <div></div>;
|
||||
|
||||
// due date column
|
||||
case 'dueDate':
|
||||
return <div></div>;
|
||||
|
||||
// completed date column
|
||||
case 'completedDate':
|
||||
return <div></div>;
|
||||
|
||||
// created date column
|
||||
case 'createdDate':
|
||||
return <div></div>;
|
||||
|
||||
// last updated column
|
||||
case 'lastUpdated':
|
||||
return <div></div>;
|
||||
|
||||
// recorder column
|
||||
case 'reporter':
|
||||
return <div></div>;
|
||||
|
||||
// default case for unsupported columns
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`border-x border-b ${customBorderColor}`}>
|
||||
<div className={`tasklist-container-${tableId} min-h-0 max-w-full overflow-x-auto`}>
|
||||
<table className={`rounded-2 w-full min-w-max border-collapse`}>
|
||||
<thead className="h-[42px]">
|
||||
<tr>
|
||||
{/* this cell render the select all task checkbox */}
|
||||
<th
|
||||
key={'selector'}
|
||||
className={`${customHeaderColumnStyles('selector')}`}
|
||||
style={{ width: 56, fontWeight: 500 }}
|
||||
>
|
||||
<Flex justify="flex-end">
|
||||
<Checkbox checked={isSelectAll} onChange={toggleSelectAll} />
|
||||
</Flex>
|
||||
</th>
|
||||
{/* other header cells */}
|
||||
{visibleColumns.map(column => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`${customHeaderColumnStyles(column.key)}`}
|
||||
style={{ width: column.width, fontWeight: 500 }}
|
||||
>
|
||||
{column.key === 'phases'
|
||||
? column.columnHeader
|
||||
: t(`${column.columnHeader}Column`)}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{taskList?.map(task => (
|
||||
<React.Fragment key={task.id}>
|
||||
<tr
|
||||
key={task.id}
|
||||
onContextMenu={e => handleContextMenu(e, task)}
|
||||
className={`${taskList.length === 0 ? 'h-0' : 'h-[42px]'}`}
|
||||
>
|
||||
{/* this cell render the select the related task checkbox */}
|
||||
<td
|
||||
key={'selector'}
|
||||
className={customBodyColumnStyles('selector')}
|
||||
style={{
|
||||
width: 56,
|
||||
backgroundColor: selectedRows.includes(task.id || '')
|
||||
? themeMode === 'dark'
|
||||
? colors.skyBlue
|
||||
: '#dceeff'
|
||||
: hoverRow === task.id
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#f8f7f9'
|
||||
: themeMode === 'dark'
|
||||
? '#181818'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
<Flex gap={8} align="center">
|
||||
<HolderOutlined />
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(task.id || '')}
|
||||
onChange={() => toggleRowSelection(task)}
|
||||
/>
|
||||
</Flex>
|
||||
</td>
|
||||
{/* other cells */}
|
||||
{visibleColumns.map(column => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={customBodyColumnStyles(column.key)}
|
||||
style={{
|
||||
width: column.width,
|
||||
backgroundColor: selectedRows.includes(task.id || '')
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#dceeff'
|
||||
: hoverRow === task.id
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#f8f7f9'
|
||||
: themeMode === 'dark'
|
||||
? '#181818'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
{renderColumnContent(column.key, task)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{/* this is for sub tasks */}
|
||||
{expandedTasks.includes(task.id || '') &&
|
||||
task?.sub_tasks?.map(subtask => (
|
||||
<tr
|
||||
key={subtask.id}
|
||||
onContextMenu={e => handleContextMenu(e, subtask)}
|
||||
onMouseEnter={() => setHoverRow(subtask.id || '')}
|
||||
onMouseLeave={() => setHoverRow(null)}
|
||||
className={`${taskList.length === 0 ? 'h-0' : 'h-[42px]'}`}
|
||||
>
|
||||
{/* this cell render the select the related task checkbox */}
|
||||
<td
|
||||
key={'selector'}
|
||||
className={customBodyColumnStyles('selector')}
|
||||
style={{
|
||||
width: 20,
|
||||
backgroundColor: selectedRows.includes(subtask.id || '')
|
||||
? themeMode === 'dark'
|
||||
? colors.skyBlue
|
||||
: '#dceeff'
|
||||
: hoverRow === subtask.id
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#f8f7f9'
|
||||
: themeMode === 'dark'
|
||||
? '#181818'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
checked={selectedRows.includes(subtask.id || '')}
|
||||
onChange={() => toggleRowSelection(subtask)}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* other sub tasks cells */}
|
||||
{visibleColumns.map(column => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={customBodyColumnStyles(column.key)}
|
||||
style={{
|
||||
width: column.width,
|
||||
backgroundColor: selectedRows.includes(subtask.id || '')
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#dceeff'
|
||||
: hoverRow === subtask.id
|
||||
? themeMode === 'dark'
|
||||
? '#000'
|
||||
: '#f8f7f9'
|
||||
: themeMode === 'dark'
|
||||
? '#181818'
|
||||
: '#fff',
|
||||
}}
|
||||
>
|
||||
{renderColumnContent(column.key, subtask, true)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{expandedTasks.includes(task.id || '') && (
|
||||
<tr>
|
||||
<td colSpan={visibleColumns.length}>
|
||||
<AddSubTaskListRow />
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* add a main task to the table */}
|
||||
<AddTaskListRow />
|
||||
|
||||
{/* custom task context menu */}
|
||||
<TaskContextMenu
|
||||
visible={contextMenuVisible}
|
||||
position={contextMenuPosition}
|
||||
selectedTask={selectedRows[0]}
|
||||
onClose={() => setContextMenuVisible(false)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListTable;
|
||||
@@ -0,0 +1,216 @@
|
||||
import { Badge, Button, Collapse, ConfigProvider, Dropdown, Flex, Input, Typography } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { TaskType } from '../../../../../types/task.types';
|
||||
import { EditOutlined, EllipsisOutlined, RetweetOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import { colors } from '../../../../../styles/colors';
|
||||
import './taskListTableWrapper.css';
|
||||
import TaskListTable from './TaskListTable';
|
||||
import { MenuProps } from 'antd/lib';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
|
||||
type TaskListTableWrapperProps = {
|
||||
taskList: IProjectTask[];
|
||||
tableId: string;
|
||||
type: string;
|
||||
name: string;
|
||||
color: string;
|
||||
statusCategory?: string | null;
|
||||
priorityCategory?: string | null;
|
||||
onRename?: (name: string) => void;
|
||||
onStatusCategoryChange?: (category: string) => void;
|
||||
};
|
||||
|
||||
const TaskListTableWrapper = ({
|
||||
taskList,
|
||||
tableId,
|
||||
name,
|
||||
type,
|
||||
color,
|
||||
statusCategory = null,
|
||||
priorityCategory = null,
|
||||
onRename,
|
||||
onStatusCategoryChange,
|
||||
}: TaskListTableWrapperProps) => {
|
||||
const [tableName, setTableName] = useState<string>(name);
|
||||
const [isRenaming, setIsRenaming] = useState<boolean>(false);
|
||||
const [isExpanded, setIsExpanded] = useState<boolean>(true);
|
||||
const [currentCategory, setCurrentCategory] = useState<string | null>(statusCategory);
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
// function to handle toggle expand
|
||||
const handlToggleExpand = () => {
|
||||
setIsExpanded(!isExpanded);
|
||||
};
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// this is for get the color for every typed tables
|
||||
const getBgColorClassName = (type: string) => {
|
||||
switch (type) {
|
||||
case 'status':
|
||||
if (currentCategory === 'todo')
|
||||
return themeMode === 'dark' ? 'after:bg-[#3a3a3a]' : 'after:bg-[#d8d7d8]';
|
||||
else if (currentCategory === 'doing')
|
||||
return themeMode === 'dark' ? 'after:bg-[#3d506e]' : 'after:bg-[#c0d5f6]';
|
||||
else if (currentCategory === 'done')
|
||||
return themeMode === 'dark' ? 'after:bg-[#3b6149]' : 'after:bg-[#c2e4d0]';
|
||||
else return themeMode === 'dark' ? 'after:bg-[#3a3a3a]' : 'after:bg-[#d8d7d8]';
|
||||
|
||||
case 'priority':
|
||||
if (priorityCategory === 'low')
|
||||
return themeMode === 'dark' ? 'after:bg-[#3b6149]' : 'after:bg-[#c2e4d0]';
|
||||
else if (priorityCategory === 'medium')
|
||||
return themeMode === 'dark' ? 'after:bg-[#916c33]' : 'after:bg-[#f9e3b1]';
|
||||
else if (priorityCategory === 'high')
|
||||
return themeMode === 'dark' ? 'after:bg-[#8b3a3b]' : 'after:bg-[#f6bfc0]';
|
||||
else return themeMode === 'dark' ? 'after:bg-[#916c33]' : 'after:bg-[#f9e3b1]';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
// these codes only for status type tables
|
||||
// function to handle rename this functionality only available for status type tables
|
||||
const handleRename = () => {
|
||||
if (onRename) {
|
||||
onRename(tableName);
|
||||
}
|
||||
setIsRenaming(false);
|
||||
};
|
||||
|
||||
// function to handle category change
|
||||
const handleCategoryChange = (category: string) => {
|
||||
setCurrentCategory(category);
|
||||
if (onStatusCategoryChange) {
|
||||
onStatusCategoryChange(category);
|
||||
}
|
||||
};
|
||||
|
||||
// find the available status for the currently active project
|
||||
const statusList = useAppSelector(state => state.statusReducer.status);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'todo':
|
||||
return '#d8d7d8';
|
||||
case 'doing':
|
||||
return '#c0d5f6';
|
||||
case 'done':
|
||||
return '#c2e4d0';
|
||||
default:
|
||||
return '#d8d7d8';
|
||||
}
|
||||
};
|
||||
|
||||
// dropdown options
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
icon: <EditOutlined />,
|
||||
label: 'Rename',
|
||||
onClick: () => setIsRenaming(true),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
icon: <RetweetOutlined />,
|
||||
label: 'Change category',
|
||||
children: statusList?.map(status => ({
|
||||
key: status.id,
|
||||
label: (
|
||||
<Flex gap={8} onClick={() => handleCategoryChange(status.category)}>
|
||||
<Badge color={getStatusColor(status.category)} />
|
||||
{status.name}
|
||||
</Flex>
|
||||
),
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
wave={{ disabled: true }}
|
||||
theme={{
|
||||
components: {
|
||||
Collapse: {
|
||||
headerPadding: 0,
|
||||
contentPadding: 0,
|
||||
},
|
||||
|
||||
Select: {
|
||||
colorBorder: colors.transparent,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Flex vertical>
|
||||
<Flex style={{ transform: 'translateY(6px)' }}>
|
||||
<Button
|
||||
className="custom-collapse-button"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
border: 'none',
|
||||
borderBottomLeftRadius: isExpanded ? 0 : 4,
|
||||
borderBottomRightRadius: isExpanded ? 0 : 4,
|
||||
color: themeMode === 'dark' ? '#ffffffd9' : colors.darkGray,
|
||||
}}
|
||||
icon={<RightOutlined rotate={isExpanded ? 90 : 0} />}
|
||||
onClick={handlToggleExpand}
|
||||
>
|
||||
{isRenaming ? (
|
||||
<Input
|
||||
size="small"
|
||||
value={tableName}
|
||||
onChange={e => setTableName(e.target.value)}
|
||||
onBlur={handleRename}
|
||||
onPressEnter={handleRename}
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text
|
||||
style={{
|
||||
fontSize: 14,
|
||||
color: themeMode === 'dark' ? '#ffffffd9' : colors.darkGray,
|
||||
}}
|
||||
>
|
||||
{/* check the default values available in the table names ==> this check for localization */}
|
||||
{['todo', 'doing', 'done', 'low', 'medium', 'high'].includes(
|
||||
tableName.replace(/\s+/g, '').toLowerCase()
|
||||
)
|
||||
? t(`${tableName.replace(/\s+/g, '').toLowerCase()}SelectorText`)
|
||||
: tableName}{' '}
|
||||
({taskList.length})
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Button>
|
||||
{type === 'status' && !isRenaming && (
|
||||
<Dropdown menu={{ items }}>
|
||||
<Button icon={<EllipsisOutlined />} className="borderless-icon-btn" />
|
||||
</Dropdown>
|
||||
)}
|
||||
</Flex>
|
||||
<Collapse
|
||||
collapsible="header"
|
||||
className="border-l-[4px]"
|
||||
bordered={false}
|
||||
ghost={true}
|
||||
expandIcon={() => null}
|
||||
activeKey={isExpanded ? '1' : undefined}
|
||||
onChange={handlToggleExpand}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
className: `custom-collapse-content-box relative after:content after:absolute after:h-full after:w-1 ${getBgColorClassName(type)} after:z-10 after:top-0 after:left-0`,
|
||||
children: <TaskListTable taskList={taskList} tableId={tableId} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListTableWrapper;
|
||||
@@ -0,0 +1,90 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import PhaseHeader from '../../../../../../features/projects/singleProject/phase/PhaseHeader';
|
||||
|
||||
export type CustomTableColumnsType = {
|
||||
key: string;
|
||||
columnHeader: ReactNode | null;
|
||||
width: number;
|
||||
};
|
||||
|
||||
const phaseHeader = React.createElement(PhaseHeader);
|
||||
|
||||
export const columnList: CustomTableColumnsType[] = [
|
||||
{ key: 'taskId', columnHeader: 'key', width: 20 },
|
||||
{ key: 'task', columnHeader: 'task', width: 400 },
|
||||
{
|
||||
key: 'description',
|
||||
columnHeader: 'description',
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
key: 'progress',
|
||||
columnHeader: 'progress',
|
||||
width: 60,
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
columnHeader: 'members',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'labels',
|
||||
columnHeader: 'labels',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'phases',
|
||||
columnHeader: phaseHeader,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
columnHeader: 'status',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
columnHeader: 'priority',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
key: 'timeTracking',
|
||||
columnHeader: 'timeTracking',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'estimation',
|
||||
columnHeader: 'estimation',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'startDate',
|
||||
columnHeader: 'startDate',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'dueDate',
|
||||
columnHeader: 'dueDate',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'completedDate',
|
||||
columnHeader: 'completedDate',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'createdDate',
|
||||
columnHeader: 'createdDate',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'lastUpdated',
|
||||
columnHeader: 'lastUpdated',
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'reporter',
|
||||
columnHeader: 'reporter',
|
||||
width: 150,
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
DeleteOutlined,
|
||||
DoubleRightOutlined,
|
||||
InboxOutlined,
|
||||
RetweetOutlined,
|
||||
UserAddOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Badge, Dropdown, Flex, Typography } from 'antd';
|
||||
import { MenuProps } from 'antd/lib';
|
||||
import React from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
type TaskContextMenuProps = {
|
||||
visible: boolean;
|
||||
position: { x: number; y: number };
|
||||
selectedTask: string;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const TaskContextMenu = ({ visible, position, selectedTask, onClose }: TaskContextMenuProps) => {
|
||||
// find the available status for the currently active project
|
||||
const statusList = useAppSelector(state => state.statusReducer.status);
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'todo':
|
||||
return '#d8d7d8';
|
||||
case 'doing':
|
||||
return '#c0d5f6';
|
||||
case 'done':
|
||||
return '#c2e4d0';
|
||||
default:
|
||||
return '#d8d7d8';
|
||||
}
|
||||
};
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
icon: <UserAddOutlined />,
|
||||
label: ' Assign to me',
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
icon: <RetweetOutlined />,
|
||||
label: 'Move to',
|
||||
children: statusList?.map(status => ({
|
||||
key: status.id,
|
||||
label: (
|
||||
<Flex gap={8}>
|
||||
<Badge color={getStatusColor(status.category)} />
|
||||
{status.name}
|
||||
</Flex>
|
||||
),
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
icon: <InboxOutlined />,
|
||||
label: 'Archive',
|
||||
},
|
||||
{
|
||||
key: '4',
|
||||
icon: <DoubleRightOutlined />,
|
||||
label: 'Convert to Sub task',
|
||||
},
|
||||
{
|
||||
key: '5',
|
||||
icon: <DeleteOutlined />,
|
||||
label: ' Delete',
|
||||
},
|
||||
];
|
||||
|
||||
return visible ? (
|
||||
<Dropdown menu={{ items }} trigger={['contextMenu']} open={visible} onOpenChange={onClose}>
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: position.y,
|
||||
left: position.x,
|
||||
zIndex: 1000,
|
||||
width: 1,
|
||||
height: 1,
|
||||
}}
|
||||
></div>
|
||||
</Dropdown>
|
||||
) : null;
|
||||
};
|
||||
|
||||
export default TaskContextMenu;
|
||||
@@ -0,0 +1,121 @@
|
||||
// TaskNameCell.tsx
|
||||
import React from 'react';
|
||||
import { Flex, Typography, Button } from 'antd';
|
||||
import {
|
||||
DoubleRightOutlined,
|
||||
DownOutlined,
|
||||
RightOutlined,
|
||||
ExpandAltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
|
||||
type TaskCellProps = {
|
||||
task: IProjectTask;
|
||||
isSubTask?: boolean;
|
||||
expandedTasks: string[];
|
||||
setSelectedTaskId: (taskId: string) => void;
|
||||
toggleTaskExpansion: (taskId: string) => void;
|
||||
};
|
||||
|
||||
const TaskCell = ({
|
||||
task,
|
||||
isSubTask = false,
|
||||
expandedTasks,
|
||||
setSelectedTaskId,
|
||||
toggleTaskExpansion,
|
||||
}: TaskCellProps) => {
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// render the toggle arrow icon for tasks with subtasks
|
||||
const renderToggleButtonForHasSubTasks = (taskId: string, hasSubtasks: boolean) => {
|
||||
if (!hasSubtasks) return null;
|
||||
return (
|
||||
<button
|
||||
onClick={() => toggleTaskExpansion(taskId)}
|
||||
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
|
||||
>
|
||||
{expandedTasks.includes(taskId) ? <DownOutlined /> : <RightOutlined />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// show expand button on hover for tasks without subtasks
|
||||
const renderToggleButtonForNonSubtasks = (taskId: string, isSubTask: boolean) => {
|
||||
return !isSubTask ? (
|
||||
<button
|
||||
onClick={() => toggleTaskExpansion(taskId)}
|
||||
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54]"
|
||||
>
|
||||
{expandedTasks.includes(taskId) ? <DownOutlined /> : <RightOutlined />}
|
||||
</button>
|
||||
) : (
|
||||
<div className="h-4 w-4"></div>
|
||||
);
|
||||
};
|
||||
|
||||
// render the double arrow icon and count label for tasks with subtasks
|
||||
const renderSubtasksCountLabel = (taskId: string, isSubTask: boolean, subTasksCount: number) => {
|
||||
return (
|
||||
!isSubTask && (
|
||||
<Button
|
||||
onClick={() => toggleTaskExpansion(taskId)}
|
||||
size="small"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
paddingInline: 4,
|
||||
alignItems: 'center',
|
||||
justifyItems: 'center',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Typography.Text style={{ fontSize: 12, lineHeight: 1 }}>{subTasksCount}</Typography.Text>
|
||||
<DoubleRightOutlined style={{ fontSize: 10 }} />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="space-between">
|
||||
<Flex gap={8} align="center">
|
||||
{!!task?.sub_tasks?.length && task.id ? (
|
||||
renderToggleButtonForHasSubTasks(task.id, !!task?.sub_tasks?.length)
|
||||
) : (
|
||||
<div className="h-4 w-4"></div>
|
||||
)}
|
||||
|
||||
{isSubTask && <DoubleRightOutlined style={{ fontSize: 12 }} />}
|
||||
|
||||
<Typography.Text ellipsis={{ expanded: false }}>{task.name}</Typography.Text>
|
||||
|
||||
{renderSubtasksCountLabel(task.id || '', isSubTask, task?.sub_tasks?.length || 0)}
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ExpandAltOutlined />}
|
||||
onClick={() => {
|
||||
setSelectedTaskId(task.id || '');
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}}
|
||||
style={{
|
||||
backgroundColor: colors.transparent,
|
||||
padding: 0,
|
||||
height: 'fit-content',
|
||||
}}
|
||||
>
|
||||
{t('openButton')}
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskCell;
|
||||
@@ -0,0 +1,22 @@
|
||||
/* Set the stroke width to 9px for the progress circle */
|
||||
.task-progress.ant-progress-circle .ant-progress-circle-path {
|
||||
stroke-width: 9px !important; /* Adjust the stroke width */
|
||||
}
|
||||
|
||||
/* Adjust the inner check mark for better alignment and visibility */
|
||||
.task-progress.ant-progress-circle.ant-progress-status-success .ant-progress-inner .anticon-check {
|
||||
font-size: 8px; /* Adjust font size for the check mark */
|
||||
color: green; /* Optional: Set a color */
|
||||
transform: translate(-50%, -50%); /* Center align */
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
padding: 0;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
/* Adjust the text inside the progress circle */
|
||||
.task-progress.ant-progress-circle .ant-progress-text {
|
||||
font-size: 10px; /* Ensure the text size fits well */
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Progress, Tooltip } from 'antd';
|
||||
import React from 'react';
|
||||
import './TaskProgress.css';
|
||||
|
||||
type TaskProgressProps = {
|
||||
progress: number;
|
||||
numberOfSubTasks: number;
|
||||
};
|
||||
|
||||
const TaskProgress = ({ progress = 0, numberOfSubTasks = 0 }: TaskProgressProps) => {
|
||||
const totalTasks = numberOfSubTasks + 1;
|
||||
const completedTasks = 0;
|
||||
|
||||
const size = progress === 100 ? 21 : 26;
|
||||
|
||||
return (
|
||||
<Tooltip title={`${completedTasks} / ${totalTasks}`}>
|
||||
<Progress
|
||||
percent={progress}
|
||||
type="circle"
|
||||
size={size}
|
||||
style={{ cursor: 'default' }}
|
||||
className="task-progress"
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskProgress;
|
||||
@@ -0,0 +1,66 @@
|
||||
import React from 'react';
|
||||
import { Divider, Empty, Flex, Popover, Typography } from 'antd';
|
||||
import { PlayCircleFilled } from '@ant-design/icons';
|
||||
import { colors } from '../../../../../../styles/colors';
|
||||
import CustomAvatar from '../../../../../../components/CustomAvatar';
|
||||
import { mockTimeLogs } from './mockTimeLogs';
|
||||
|
||||
type TimeTrackerProps = {
|
||||
taskId: string | null | undefined;
|
||||
initialTime?: number;
|
||||
};
|
||||
|
||||
const TimeTracker = ({ taskId, initialTime = 0 }: TimeTrackerProps) => {
|
||||
const minutes = Math.floor(initialTime / 60);
|
||||
const seconds = initialTime % 60;
|
||||
const formattedTime = `${minutes}m ${seconds}s`;
|
||||
|
||||
const timeTrackingLogCard =
|
||||
initialTime > 0 ? (
|
||||
<Flex vertical style={{ width: 400, height: 300, overflowY: 'scroll' }}>
|
||||
{mockTimeLogs.map(log => (
|
||||
<React.Fragment key={log.logId}>
|
||||
<Flex gap={8} align="center">
|
||||
<CustomAvatar avatarName={log.username} />
|
||||
|
||||
<Flex vertical>
|
||||
<Typography>
|
||||
<Typography.Text strong>{log.username}</Typography.Text>
|
||||
<Typography.Text>{` logged ${log.duration} ${
|
||||
log.via ? `via ${log.via}` : ''
|
||||
}`}</Typography.Text>
|
||||
</Typography>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{log.date}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Divider style={{ marginBlock: 12 }} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Flex>
|
||||
) : (
|
||||
<Empty style={{ width: 400 }} />
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
<PlayCircleFilled style={{ color: colors.skyBlue, fontSize: 16 }} />
|
||||
<Popover
|
||||
title={
|
||||
<Typography.Text style={{ fontWeight: 500 }}>
|
||||
Time Tracking Log
|
||||
<Divider style={{ marginBlockStart: 8, marginBlockEnd: 12 }} />
|
||||
</Typography.Text>
|
||||
}
|
||||
content={timeTrackingLogCard}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Typography.Text style={{ cursor: 'pointer' }}>{formattedTime}</Typography.Text>
|
||||
</Popover>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeTracker;
|
||||
@@ -0,0 +1,42 @@
|
||||
type TimeLog = {
|
||||
logId: number;
|
||||
username: string;
|
||||
duration: string;
|
||||
date: string;
|
||||
via?: string;
|
||||
};
|
||||
|
||||
export const mockTimeLogs: TimeLog[] = [
|
||||
{
|
||||
logId: 1,
|
||||
username: 'Sachintha Prasad',
|
||||
duration: '1h 0m',
|
||||
date: 'Sep 22, 2023, 10:47:02 AM',
|
||||
},
|
||||
{
|
||||
logId: 2,
|
||||
username: 'Sachintha Prasad',
|
||||
duration: '8h 0m',
|
||||
date: 'Sep 22, 2023, 10:47:00 AM',
|
||||
},
|
||||
{
|
||||
logId: 3,
|
||||
username: 'Sachintha Prasad',
|
||||
duration: '6h 0m',
|
||||
date: 'Sep 22, 2023, 10:46:58 AM',
|
||||
},
|
||||
{
|
||||
logId: 4,
|
||||
username: 'Raveesha Dilanka',
|
||||
duration: '1m 4s',
|
||||
date: 'Sep 12, 2023, 8:32:49 AM - Sep 12, 2023, 8:33:53 AM',
|
||||
via: 'Timer',
|
||||
},
|
||||
{
|
||||
logId: 5,
|
||||
username: 'Raveesha Dilanka',
|
||||
duration: '0m 30s',
|
||||
date: 'Sep 12, 2023, 8:30:19 AM - Sep 12, 2023, 8:30:49 AM',
|
||||
via: 'Timer',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Input } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const AddSubTaskListRow = () => {
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
// get data theme data from redux
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const customBorderColor = themeMode === 'dark' && ' border-[#303030]';
|
||||
|
||||
return (
|
||||
<div className={`border-t ${customBorderColor}`}>
|
||||
{isEdit ? (
|
||||
<Input
|
||||
className="h-12 w-full rounded-none"
|
||||
style={{ borderColor: colors.skyBlue }}
|
||||
placeholder={t('addTaskInputPlaceholder')}
|
||||
onBlur={() => setIsEdit(false)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
onFocus={() => setIsEdit(true)}
|
||||
className="w-[300px] border-none"
|
||||
value={t('addSubTaskText')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddSubTaskListRow;
|
||||
@@ -0,0 +1,37 @@
|
||||
import { Input } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const AddTaskListRow = () => {
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
// get data theme data from redux
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const customBorderColor = themeMode === 'dark' && ' border-[#303030]';
|
||||
|
||||
return (
|
||||
<div className={`border-t ${customBorderColor}`}>
|
||||
{isEdit ? (
|
||||
<Input
|
||||
className="h-12 w-full rounded-none"
|
||||
style={{ borderColor: colors.skyBlue }}
|
||||
placeholder={t('addTaskInputPlaceholder')}
|
||||
onBlur={() => setIsEdit(false)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
onFocus={() => setIsEdit(true)}
|
||||
className="w-[300px] border-none"
|
||||
value={t('addTaskText')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddTaskListRow;
|
||||
@@ -0,0 +1,15 @@
|
||||
/* custom collapse styles for content box and the left border */
|
||||
.ant-collapse-header {
|
||||
margin-bottom: 6px !important;
|
||||
}
|
||||
|
||||
.custom-collapse-content-box .ant-collapse-content-box {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
:where(.css-dev-only-do-not-override-1w6wsvq).ant-collapse-ghost
|
||||
> .ant-collapse-item
|
||||
> .ant-collapse-content
|
||||
> .ant-collapse-content-box {
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
.mentions-light .mentions {
|
||||
background-color: #e9e2e2;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.mentions-dark .mentions {
|
||||
background-color: #2c2c2c;
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.tooltip-comment .mentions {
|
||||
background-color: transparent;
|
||||
font-weight: 500;
|
||||
padding: 0;
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
import { Button, ConfigProvider, Flex, Form, Mentions, Skeleton, Space, Tooltip, Typography } from 'antd';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useParams } from 'react-router-dom';
|
||||
|
||||
import CustomAvatar from '@components/CustomAvatar';
|
||||
import { colors } from '@/styles/colors';
|
||||
import {
|
||||
IMentionMemberSelectOption,
|
||||
IMentionMemberViewModel,
|
||||
} from '@/types/project/projectComments.types';
|
||||
import { projectsApiService } from '@/api/projects/projects.api.service';
|
||||
import { projectCommentsApiService } from '@/api/projects/comments/project-comments.api.service';
|
||||
import { IProjectUpdateCommentViewModel } from '@/types/project/project.types';
|
||||
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
|
||||
import { getUserSession } from '@/utils/session-helper';
|
||||
import './project-view-updates.css';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
|
||||
const MAX_COMMENT_LENGTH = 2000;
|
||||
|
||||
const ProjectViewUpdates = () => {
|
||||
const { projectId } = useParams();
|
||||
const [characterLength, setCharacterLength] = useState<number>(0);
|
||||
const [isCommentBoxExpand, setIsCommentBoxExpand] = useState<boolean>(false);
|
||||
const [members, setMembers] = useState<IMentionMemberViewModel[]>([]);
|
||||
const [selectedMembers, setSelectedMembers] = useState<{ id: string; name: string }[]>([]);
|
||||
const [comments, setComments] = useState<IProjectUpdateCommentViewModel[]>([]);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isLoadingComments, setIsLoadingComments] = useState<boolean>(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState<boolean>(false);
|
||||
const [commentValue, setCommentValue] = useState<string>('');
|
||||
const theme = useAppSelector(state => state.themeReducer.mode);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const { t } = useTranslation('project-view-updates');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const getMembers = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await projectCommentsApiService.getMentionMembers(projectId, 1, 15, null, null, null);
|
||||
if (res.done) {
|
||||
setMembers(res.body as IMentionMemberViewModel[]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch members:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const getComments = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setIsLoadingComments(true);
|
||||
const res = await projectCommentsApiService.getByProjectId(projectId);
|
||||
if (res.done) {
|
||||
setComments(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch comments:', error);
|
||||
} finally {
|
||||
setIsLoadingComments(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
const handleAddComment = async () => {
|
||||
if (!projectId || characterLength === 0) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
|
||||
if (!commentValue) {
|
||||
console.error('Comment content is empty');
|
||||
return;
|
||||
}
|
||||
|
||||
const body = {
|
||||
project_id: projectId,
|
||||
team_id: getUserSession()?.team_id,
|
||||
content: commentValue.trim(),
|
||||
mentions: selectedMembers
|
||||
};
|
||||
|
||||
const res = await projectCommentsApiService.createProjectComment(body);
|
||||
if (res.done) {
|
||||
await getComments();
|
||||
handleCancel();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to add comment:', error);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setCommentValue('');
|
||||
|
||||
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void getMembers();
|
||||
void getComments();
|
||||
}, [getMembers, getComments,refreshTimestamp]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
form.resetFields(['comment']);
|
||||
setCharacterLength(0);
|
||||
setIsCommentBoxExpand(false);
|
||||
setSelectedMembers([]);
|
||||
}, [form]);
|
||||
|
||||
const mentionsOptions =
|
||||
members?.map(member => ({
|
||||
value: member.id,
|
||||
label: member.name,
|
||||
})) ?? [];
|
||||
|
||||
const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => {
|
||||
if (!member?.value || !member?.label) return;
|
||||
setSelectedMembers(prev =>
|
||||
prev.some(mention => mention.id === member.value)
|
||||
? prev
|
||||
: [...prev, { id: member.value, name: member.label }]
|
||||
);
|
||||
|
||||
setCommentValue(prev => {
|
||||
const parts = prev.split('@');
|
||||
const lastPart = parts[parts.length - 1];
|
||||
const mentionText = member.label;
|
||||
// Keep only the part before the @ and add the new mention
|
||||
return prev.slice(0, prev.length - lastPart.length) + mentionText;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCommentChange = useCallback((value: string) => {
|
||||
// Only update the value without trying to replace mentions
|
||||
setCommentValue(value);
|
||||
setCharacterLength(value.trim().length);
|
||||
}, []);
|
||||
|
||||
const handleDeleteComment = useCallback(
|
||||
async (commentId: string | undefined) => {
|
||||
if (!commentId) return;
|
||||
try {
|
||||
const res = await projectCommentsApiService.deleteComment(commentId);
|
||||
if (res.done) {
|
||||
void getComments();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete comment:', error);
|
||||
}
|
||||
},
|
||||
[getComments]
|
||||
);
|
||||
|
||||
return (
|
||||
<Flex gap={24} vertical>
|
||||
<Flex vertical gap={16}>
|
||||
{
|
||||
isLoadingComments ? (
|
||||
<Skeleton active />
|
||||
):
|
||||
comments.map(comment => (
|
||||
<Flex key={comment.id} gap={8}>
|
||||
<CustomAvatar avatarName={comment.created_by || ''} />
|
||||
<Flex vertical flex={1}>
|
||||
<Space>
|
||||
<Typography.Text strong style={{ fontSize: 13, color: colors.lightGray }}>
|
||||
{comment.created_by || ''}
|
||||
</Typography.Text>
|
||||
<Tooltip title={comment.created_at}>
|
||||
<Typography.Text style={{ fontSize: 13, color: colors.deepLightGray }}>
|
||||
{calculateTimeDifference(comment.created_at || '')}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</Space>
|
||||
<Typography.Paragraph
|
||||
style={{ margin: '8px 0' }}
|
||||
ellipsis={{ rows: 3, expandable: true }}
|
||||
>
|
||||
<div className={`mentions-${theme === 'dark' ? 'dark' : 'light'}`} dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(comment.content || '') }} />
|
||||
</Typography.Paragraph>
|
||||
<ConfigProvider
|
||||
wave={{ disabled: true }}
|
||||
|
||||
theme={{
|
||||
components: {
|
||||
Button: {
|
||||
defaultColor: colors.lightGray,
|
||||
defaultHoverColor: colors.darkGray,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
shape="circle"
|
||||
type="text"
|
||||
size='small'
|
||||
onClick={() => handleDeleteComment(comment.id)}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
</Flex>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
|
||||
<Form onFinish={handleAddComment}>
|
||||
<Form.Item>
|
||||
<Mentions
|
||||
value={commentValue}
|
||||
placeholder={t('inputPlaceholder')}
|
||||
loading={isLoading}
|
||||
options={mentionsOptions}
|
||||
autoSize
|
||||
maxLength={MAX_COMMENT_LENGTH}
|
||||
onSelect={option => memberSelectHandler(option as IMentionMemberSelectOption)}
|
||||
onClick={() => setIsCommentBoxExpand(true)}
|
||||
onChange={handleCommentChange}
|
||||
prefix="@"
|
||||
split=""
|
||||
style={{
|
||||
minHeight: isCommentBoxExpand ? 180 : 60,
|
||||
paddingBlockEnd: 24,
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
right: 12,
|
||||
color: colors.lightGray,
|
||||
}}
|
||||
>{`${characterLength}/${MAX_COMMENT_LENGTH}`}</span>
|
||||
</Form.Item>
|
||||
|
||||
{isCommentBoxExpand && (
|
||||
<Form.Item>
|
||||
<Flex gap={8} justify="flex-end">
|
||||
<Button onClick={handleCancel} disabled={isSubmitting}>
|
||||
{t('cancelButton')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={isSubmitting}
|
||||
disabled={characterLength === 0}
|
||||
htmlType="submit"
|
||||
>
|
||||
{t('addButton')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
)}
|
||||
</Form>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewUpdates;
|
||||
@@ -0,0 +1,7 @@
|
||||
import React from 'react';
|
||||
|
||||
const ProjectViewWorkload = () => {
|
||||
return <div>ProjectViewWorkload</div>;
|
||||
};
|
||||
|
||||
export default ProjectViewWorkload;
|
||||
@@ -0,0 +1,160 @@
|
||||
import { Button, Flex } from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { nanoid } from '@reduxjs/toolkit';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { addBoardSectionCard, fetchBoardTaskGroups, IGroupBy } from '@features/board/board-slice';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
|
||||
import { createStatus, fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
|
||||
|
||||
const BoardCreateSectionCard = () => {
|
||||
const { t } = useTranslation('kanban-board');
|
||||
|
||||
const themeMode = useAppSelector((state) => state.themeReducer.mode);
|
||||
const { projectId } = useAppSelector((state) => state.projectReducer);
|
||||
const groupBy = useAppSelector((state) => state.boardReducer.groupBy);
|
||||
const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const getUniqueSectionName = (baseName: string): string => {
|
||||
// Check if the base name already exists
|
||||
const existingNames = existingStatuses.map(status => status.name?.toLowerCase());
|
||||
|
||||
if (!existingNames.includes(baseName.toLowerCase())) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
// If the base name exists, add a number suffix
|
||||
let counter = 1;
|
||||
let newName = `${baseName.trim()} (${counter})`;
|
||||
|
||||
while (existingNames.includes(newName.toLowerCase())) {
|
||||
counter++;
|
||||
newName = `${baseName.trim()} (${counter})`;
|
||||
}
|
||||
|
||||
return newName;
|
||||
};
|
||||
|
||||
const handleAddSection = async () => {
|
||||
const sectionId = nanoid();
|
||||
const baseNameSection = 'Untitled section';
|
||||
const sectionName = getUniqueSectionName(baseNameSection);
|
||||
|
||||
if (groupBy === IGroupBy.STATUS && projectId) {
|
||||
// Find the "To do" category
|
||||
const todoCategory = statusCategories.find(category =>
|
||||
category.name?.toLowerCase() === 'to do' ||
|
||||
category.name?.toLowerCase() === 'todo'
|
||||
);
|
||||
|
||||
if (todoCategory && todoCategory.id) {
|
||||
// Create a new status
|
||||
const body = {
|
||||
name: sectionName,
|
||||
project_id: projectId,
|
||||
category_id: todoCategory.id,
|
||||
};
|
||||
|
||||
try {
|
||||
// Create the status
|
||||
const response = await dispatch(createStatus({ body, currentProjectId: projectId })).unwrap();
|
||||
|
||||
if (response.done && response.body) {
|
||||
dispatch(
|
||||
addBoardSectionCard({
|
||||
id: response.body.id as string,
|
||||
name: sectionName,
|
||||
colorCode: (response.body.color_code || todoCategory.color_code || '#d8d7d8') + ALPHA_CHANNEL,
|
||||
colorCodeDark: '#989898',
|
||||
})
|
||||
);
|
||||
|
||||
// Refresh the board to show the new section
|
||||
dispatch(fetchBoardTaskGroups(projectId));
|
||||
// Refresh statuses
|
||||
dispatch(fetchStatuses(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create status:', error);
|
||||
}
|
||||
} else {
|
||||
// Fallback if "To do" category not found
|
||||
dispatch(
|
||||
addBoardSectionCard({
|
||||
id: sectionId,
|
||||
name: sectionName,
|
||||
colorCode: '#d8d7d8',
|
||||
colorCodeDark: '#989898',
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (groupBy === IGroupBy.PHASE && projectId) {
|
||||
const body = {
|
||||
name: sectionName,
|
||||
project_id: projectId,
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await phasesApiService.addPhaseOption(projectId);
|
||||
if (response.done && response.body) {
|
||||
dispatch(fetchBoardTaskGroups(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create phase:', error);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
vertical
|
||||
gap={16}
|
||||
style={{
|
||||
minWidth: 375,
|
||||
padding: 8,
|
||||
borderRadius: 12,
|
||||
}}
|
||||
className="h-[600px] max-h-[600px] overflow-y-scroll"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
height: 640,
|
||||
background: themeWiseColor(
|
||||
'linear-gradient( 180deg, #fafafa, rgba(245, 243, 243, 0))',
|
||||
'linear-gradient( 180deg, #2a2b2d, rgba(42, 43, 45, 0))',
|
||||
themeMode
|
||||
),
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
style={{
|
||||
height: '38px',
|
||||
width: '100%',
|
||||
borderRadius: 6,
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddSection}
|
||||
>
|
||||
{t('addSectionButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardCreateSectionCard;
|
||||
@@ -0,0 +1,377 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Dropdown,
|
||||
Flex,
|
||||
Input,
|
||||
InputRef,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ExclamationCircleFilled,
|
||||
LoadingOutlined,
|
||||
MoreOutlined,
|
||||
PlusOutlined,
|
||||
RetweetOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { MenuProps } from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import ChangeCategoryDropdown from '@/components/board/changeCategoryDropdown/ChangeCategoryDropdown';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
deleteSection,
|
||||
IGroupBy,
|
||||
setBoardGroupName,
|
||||
setEditableSection,
|
||||
} from '@features/board/board-slice';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
|
||||
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
|
||||
import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events';
|
||||
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import { updateTaskGroupColor } from '@/features/tasks/tasks.slice';
|
||||
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||
import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types';
|
||||
import { update } from 'lodash';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { toggleDrawer } from '@/features/projects/status/StatusSlice';
|
||||
import { deleteStatusToggleDrawer, seletedStatusCategory } from '@/features/projects/status/DeleteStatusSlice';
|
||||
|
||||
interface BoardSectionCardHeaderProps {
|
||||
groupId: string;
|
||||
name: string;
|
||||
tasksCount: number;
|
||||
isLoading: boolean;
|
||||
setName: (newName: string) => void;
|
||||
colorCode: string;
|
||||
onHoverChange: (hovered: boolean) => void;
|
||||
setShowNewCard: (x: boolean) => void;
|
||||
categoryId: string | null;
|
||||
}
|
||||
|
||||
const BoardSectionCardHeader: React.FC<BoardSectionCardHeaderProps> = ({
|
||||
groupId,
|
||||
name,
|
||||
tasksCount,
|
||||
isLoading,
|
||||
setName,
|
||||
colorCode,
|
||||
onHoverChange,
|
||||
setShowNewCard,
|
||||
categoryId = null,
|
||||
}) => {
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||
const isProjectManager = useIsProjectManager();
|
||||
const [isEditable, setIsEditable] = useState(false);
|
||||
const [editName, setEdit] = useState(name);
|
||||
const [isEllipsisActive, setIsEllipsisActive] = useState(false);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const { editableSectionId, groupBy } = useAppSelector(state => state.boardReducer);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { statusCategories, status } = useAppSelector(state => state.taskStatusReducer);
|
||||
|
||||
const { t } = useTranslation('kanban-board');
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditable && inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
inputRef.current.select();
|
||||
}
|
||||
}, [isEditable]);
|
||||
|
||||
useEffect(() => {
|
||||
if (editableSectionId === groupId && (isProjectManager || isOwnerOrAdmin)) {
|
||||
setIsEditable(true);
|
||||
dispatch(setEditableSection(null));
|
||||
}
|
||||
}, [editableSectionId, groupId, dispatch]);
|
||||
|
||||
const getUniqueSectionName = (baseName: string): string => {
|
||||
// Check if the base name already exists
|
||||
const existingNames = status.map(status => status.name?.toLowerCase());
|
||||
|
||||
if (!existingNames.includes(baseName.toLowerCase())) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
// If the base name exists, add a number suffix
|
||||
let counter = 1;
|
||||
let newName = `${baseName.trim()} (${counter})`;
|
||||
|
||||
while (existingNames.includes(newName.toLowerCase())) {
|
||||
counter++;
|
||||
newName = `${baseName.trim()} (${counter})`;
|
||||
}
|
||||
|
||||
return newName;
|
||||
};
|
||||
|
||||
const updateStatus = async (category = categoryId) => {
|
||||
if (!category || !projectId || !groupId) return;
|
||||
const sectionName = getUniqueSectionName(name);
|
||||
const body: ITaskStatusUpdateModel = {
|
||||
name: sectionName,
|
||||
project_id: projectId,
|
||||
category_id: category,
|
||||
};
|
||||
const res = await statusApiService.updateStatus(groupId, body, projectId);
|
||||
if (res.done) {
|
||||
dispatch(
|
||||
setBoardGroupName({
|
||||
groupId,
|
||||
name: sectionName ?? '',
|
||||
colorCode: res.body.color_code ?? '',
|
||||
colorCodeDark: res.body.color_code_dark ?? '',
|
||||
categoryId: category,
|
||||
})
|
||||
);
|
||||
dispatch(fetchStatuses(projectId));
|
||||
setName(sectionName);
|
||||
} else {
|
||||
setName(editName);
|
||||
logger.error('Error updating status', res.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const taskName = e.target.value;
|
||||
setName(taskName);
|
||||
};
|
||||
|
||||
const handleBlur = async () => {
|
||||
if (name === 'Untitled section') {
|
||||
dispatch(deleteSection({ sectionId: groupId }));
|
||||
}
|
||||
setIsEditable(false);
|
||||
|
||||
if (!projectId || !groupId) return;
|
||||
|
||||
if (groupBy === IGroupBy.STATUS) {
|
||||
await updateStatus();
|
||||
}
|
||||
|
||||
if (groupBy === IGroupBy.PHASE) {
|
||||
const body = {
|
||||
id: groupId,
|
||||
name: name,
|
||||
};
|
||||
|
||||
const res = await phasesApiService.updateNameOfPhase(groupId, body as ITaskPhase, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' });
|
||||
// dispatch(fetchPhasesByProjectId(projectId));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handlePressEnter = () => {
|
||||
setShowNewCard(true);
|
||||
setIsEditable(false);
|
||||
handleBlur();
|
||||
};
|
||||
|
||||
const handleDeleteSection = async () => {
|
||||
if (!projectId || !groupId) return;
|
||||
|
||||
try {
|
||||
if (groupBy === IGroupBy.STATUS) {
|
||||
const replacingStatusId = '';
|
||||
const res = await statusApiService.deleteStatus(groupId, projectId, replacingStatusId);
|
||||
if (res.message === 'At least one status should exists under each category.') return
|
||||
if (res.done) {
|
||||
dispatch(deleteSection({ sectionId: groupId }));
|
||||
} else {
|
||||
dispatch(seletedStatusCategory({ id: groupId, name: name, category_id: categoryId ?? '', message: res.message ?? '' }));
|
||||
dispatch(deleteStatusToggleDrawer());
|
||||
}
|
||||
} else if (groupBy === IGroupBy.PHASE) {
|
||||
const res = await phasesApiService.deletePhaseOption(groupId, projectId);
|
||||
if (res.done) {
|
||||
dispatch(deleteSection({ sectionId: groupId }));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting section', error);
|
||||
}
|
||||
};
|
||||
|
||||
const items: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
width: '100%',
|
||||
gap: '8px',
|
||||
}}
|
||||
onClick={() => setIsEditable(true)}
|
||||
>
|
||||
<EditOutlined /> <span>{t('rename')}</span>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
groupBy === IGroupBy.STATUS && {
|
||||
key: '2',
|
||||
icon: <RetweetOutlined />,
|
||||
label: 'Change category',
|
||||
children: statusCategories?.map(status => ({
|
||||
key: status.id,
|
||||
label: (
|
||||
<Flex
|
||||
gap={8}
|
||||
onClick={() => status.id && updateStatus(status.id)}
|
||||
style={categoryId === status.id ? { fontWeight: 700 } : {}}
|
||||
>
|
||||
<Badge color={status.color_code} />
|
||||
{status.name}
|
||||
</Flex>
|
||||
),
|
||||
})),
|
||||
},
|
||||
groupBy !== IGroupBy.PRIORITY && {
|
||||
key: '3',
|
||||
label: (
|
||||
<Popconfirm
|
||||
title={t('deleteConfirmationTitle')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText={t('deleteConfirmationOk')}
|
||||
cancelText={t('deleteConfirmationCancel')}
|
||||
onConfirm={handleDeleteSection}
|
||||
>
|
||||
<Flex gap={8} align="center" style={{ width: '100%' }}>
|
||||
<DeleteOutlined />
|
||||
{t('delete')}
|
||||
</Flex>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
].filter(Boolean) as MenuProps['items'];
|
||||
|
||||
return (
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
fontWeight: 600,
|
||||
padding: '8px',
|
||||
backgroundColor: colorCode,
|
||||
borderRadius: 6,
|
||||
}}
|
||||
onMouseEnter={() => onHoverChange(true)}
|
||||
onMouseLeave={() => onHoverChange(false)}
|
||||
>
|
||||
<Flex
|
||||
gap={8}
|
||||
align="center"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => {
|
||||
if ((isProjectManager || isOwnerOrAdmin) && name !== 'Unmapped') setIsEditable(true);
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{
|
||||
minWidth: 26,
|
||||
height: 26,
|
||||
borderRadius: 120,
|
||||
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
||||
}}
|
||||
>
|
||||
{tasksCount}
|
||||
</Flex>
|
||||
|
||||
{isLoading && <LoadingOutlined style={{ color: colors.darkGray }} />}
|
||||
{isEditable ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={name}
|
||||
variant="borderless"
|
||||
style={{
|
||||
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
||||
}}
|
||||
onChange={handleChange}
|
||||
onBlur={handleBlur}
|
||||
onPressEnter={handlePressEnter}
|
||||
/>
|
||||
) : (
|
||||
<Tooltip title={isEllipsisActive ? name : null}>
|
||||
<Typography.Text
|
||||
ellipsis={{
|
||||
tooltip: false,
|
||||
onEllipsis: ellipsed => setIsEllipsisActive(ellipsed),
|
||||
}}
|
||||
style={{
|
||||
minWidth: 200,
|
||||
textTransform: 'capitalize',
|
||||
color: themeMode === 'dark' ? '#383838' : '',
|
||||
display: 'inline-block',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<div style={{ display: 'flex' }}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
shape="circle"
|
||||
style={{ color: themeMode === 'dark' ? '#383838' : '' }}
|
||||
onClick={() => setShowNewCard(true)}
|
||||
>
|
||||
<PlusOutlined />
|
||||
</Button>
|
||||
|
||||
{(isOwnerOrAdmin || isProjectManager) && name !== 'Unmapped' && (
|
||||
<Dropdown
|
||||
overlayClassName="todo-threedot-dropdown"
|
||||
trigger={['click']}
|
||||
menu={{ items }}
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<Button type="text" size="small" shape="circle">
|
||||
<MoreOutlined
|
||||
style={{
|
||||
rotate: '90deg',
|
||||
fontSize: '25px',
|
||||
color: themeMode === 'dark' ? '#383838' : '',
|
||||
}}
|
||||
/>
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardSectionCardHeader;
|
||||
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Button, Flex } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import BoardSectionCardHeader from './board-section-card-header';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import BoardViewTaskCard from '../board-task-card/board-view-task-card';
|
||||
import BoardViewCreateTaskCard from '../board-task-card/board-view-create-task-card';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
||||
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
interface IBoardSectionCardProps {
|
||||
taskGroup: ITaskListGroup;
|
||||
}
|
||||
|
||||
const BoardSectionCard = ({ taskGroup }: IBoardSectionCardProps) => {
|
||||
const { t } = useTranslation('kanban-board');
|
||||
const scrollContainerRef = useRef<any>(null);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { team_id: teamId, id: reporterId } = useAppSelector(state => state.userReducer);
|
||||
const { socket } = useSocket();
|
||||
|
||||
const [name, setName] = useState<string>(taskGroup.name);
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const [isHover, setIsHover] = useState<boolean>(false);
|
||||
const [showNewCardTop, setShowNewCardTop] = useState<boolean>(false);
|
||||
const [showNewCardBottom, setShowNewCardBottom] = useState<boolean>(false);
|
||||
const [creatingTempTask, setCreatingTempTask] = useState<boolean>(false);
|
||||
|
||||
const { setNodeRef: setDroppableRef } = useDroppable({
|
||||
id: taskGroup.id,
|
||||
data: {
|
||||
type: 'section',
|
||||
section: taskGroup,
|
||||
},
|
||||
});
|
||||
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef: setSortableRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({
|
||||
id: taskGroup.id,
|
||||
data: {
|
||||
type: 'section',
|
||||
section: taskGroup,
|
||||
},
|
||||
});
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
const setRefs = (el: HTMLElement | null) => {
|
||||
setSortableRef(el);
|
||||
setDroppableRef(el);
|
||||
};
|
||||
|
||||
const getInstantTask = async ({
|
||||
task_id,
|
||||
group_id,
|
||||
task,
|
||||
}: {
|
||||
task_id: string;
|
||||
group_id: string;
|
||||
task: IProjectTask;
|
||||
}) => {
|
||||
try {
|
||||
} catch (error) {
|
||||
logger.error('Error creating instant task', error);
|
||||
}
|
||||
};
|
||||
|
||||
const createTempTask = async () => {
|
||||
if (creatingTempTask || !projectId) return;
|
||||
setCreatingTempTask(true);
|
||||
|
||||
const body: ITaskCreateRequest = {
|
||||
name: DEFAULT_TASK_NAME,
|
||||
project_id: projectId,
|
||||
team_id: teamId,
|
||||
reporter_id: reporterId,
|
||||
status_id: taskGroup.id,
|
||||
};
|
||||
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
};
|
||||
|
||||
const handleAddTaskToBottom = () => {
|
||||
// createTempTask();
|
||||
setShowNewCardBottom(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (showNewCardBottom && scrollContainerRef.current) {
|
||||
const timeout = setTimeout(() => {
|
||||
scrollContainerRef.current.scrollTop = scrollContainerRef.current.scrollHeight;
|
||||
}, 300);
|
||||
|
||||
return () => clearTimeout(timeout);
|
||||
}
|
||||
}, [taskGroup.tasks, showNewCardBottom]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
vertical
|
||||
gap={16}
|
||||
ref={setRefs}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
style={{
|
||||
...style,
|
||||
minWidth: 375,
|
||||
outline: isHover
|
||||
? `1px solid ${themeWiseColor('#edeae9', '#ffffff12', themeMode)}`
|
||||
: 'none',
|
||||
padding: 8,
|
||||
borderRadius: 12,
|
||||
}}
|
||||
className="h-[600px] max-h-[600px] overflow-y-scroll board-section"
|
||||
data-section-id={taskGroup.id}
|
||||
data-droppable="true"
|
||||
data-over="false"
|
||||
>
|
||||
<BoardSectionCardHeader
|
||||
groupId={taskGroup.id}
|
||||
key={taskGroup.id}
|
||||
categoryId={taskGroup.category_id ?? null}
|
||||
name={name}
|
||||
tasksCount={taskGroup?.tasks.length}
|
||||
isLoading={isLoading}
|
||||
setName={setName}
|
||||
colorCode={themeWiseColor(taskGroup?.color_code, taskGroup?.color_code_dark, themeMode)}
|
||||
onHoverChange={setIsHover}
|
||||
setShowNewCard={setShowNewCardTop}
|
||||
/>
|
||||
|
||||
<Flex
|
||||
vertical
|
||||
gap={16}
|
||||
ref={scrollContainerRef}
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
height: taskGroup?.tasks.length <= 0 ? 600 : 'auto',
|
||||
maxHeight: taskGroup?.tasks.length <= 0 ? 600 : 'auto',
|
||||
overflowY: 'scroll',
|
||||
padding: taskGroup?.tasks.length <= 0 ? 8 : 6,
|
||||
background:
|
||||
taskGroup?.tasks.length <= 0 && !showNewCardTop && !showNewCardBottom
|
||||
? themeWiseColor(
|
||||
'linear-gradient( 180deg, #fafafa, rgba(245, 243, 243, 0))',
|
||||
'linear-gradient( 180deg, #2a2b2d, rgba(42, 43, 45, 0))',
|
||||
themeMode
|
||||
)
|
||||
: 'transparent',
|
||||
}}
|
||||
>
|
||||
<SortableContext
|
||||
items={taskGroup.tasks.map(task => task.id ?? '')}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<Flex vertical gap={16} align="center">
|
||||
{showNewCardTop && (
|
||||
<BoardViewCreateTaskCard
|
||||
position="top"
|
||||
sectionId={taskGroup.id}
|
||||
setShowNewCard={setShowNewCardTop}
|
||||
/>
|
||||
)}
|
||||
|
||||
{taskGroup.tasks.map((task: any) => (
|
||||
<BoardViewTaskCard key={task.id} sectionId={taskGroup.id} task={task} />
|
||||
))}
|
||||
|
||||
{showNewCardBottom && (
|
||||
<BoardViewCreateTaskCard
|
||||
position="bottom"
|
||||
sectionId={taskGroup.id}
|
||||
setShowNewCard={setShowNewCardBottom}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
</SortableContext>
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
style={{
|
||||
height: '38px',
|
||||
width: '100%',
|
||||
borderRadius: 6,
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddTaskToBottom}
|
||||
>
|
||||
{t('addTask')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardSectionCard;
|
||||
@@ -0,0 +1,116 @@
|
||||
import { Flex } from 'antd';
|
||||
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import BoardSectionCard from './board-section-card/board-section-card';
|
||||
import BoardCreateSectionCard from './board-section-card/board-create-section-card';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { useEffect } from 'react';
|
||||
import { setTaskAssignee, setTaskEndDate } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { fetchTaskAssignees } from '@/features/taskAttributes/taskMemberSlice';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { updateTaskAssignees, updateTaskEndDate } from '@/features/board/board-slice';
|
||||
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
||||
|
||||
const BoardSectionCardContainer = ({
|
||||
datasource,
|
||||
group,
|
||||
}: {
|
||||
datasource: ITaskListGroup[];
|
||||
group: 'status' | 'priority' | 'phases' | 'members';
|
||||
}) => {
|
||||
const { socket } = useSocket();
|
||||
const dispatch = useAppDispatch();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const { taskGroups } = useAppSelector(state => state.boardReducer);
|
||||
const { loadingAssignees } = useAppSelector(state => state.taskReducer);
|
||||
const isOwnerorAdmin = useAuthService().isOwnerOrAdmin();
|
||||
const isProjectManager = useIsProjectManager();
|
||||
|
||||
// Socket handler for assignee updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleAssigneesUpdate = (data: ITaskAssigneesUpdateResponse) => {
|
||||
if (!data) return;
|
||||
|
||||
const updatedAssignees = data.assignees.map(assignee => ({
|
||||
...assignee,
|
||||
selected: true,
|
||||
}));
|
||||
|
||||
// Find the group that contains the task or its subtasks
|
||||
const groupId = taskGroups.find(group =>
|
||||
group.tasks.some(
|
||||
task =>
|
||||
task.id === data.id ||
|
||||
(task.sub_tasks && task.sub_tasks.some(subtask => subtask.id === data.id))
|
||||
)
|
||||
)?.id;
|
||||
|
||||
if (groupId) {
|
||||
dispatch(
|
||||
updateTaskAssignees({
|
||||
groupId,
|
||||
taskId: data.id,
|
||||
assignees: updatedAssignees,
|
||||
names: data.names,
|
||||
})
|
||||
);
|
||||
|
||||
dispatch(setTaskAssignee(data));
|
||||
|
||||
if (currentSession?.team_id && !loadingAssignees) {
|
||||
dispatch(fetchTaskAssignees(currentSession.team_id));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
|
||||
return () => {
|
||||
socket.off(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), handleAssigneesUpdate);
|
||||
};
|
||||
}, [socket, currentSession?.team_id, loadingAssignees, taskGroups, dispatch]);
|
||||
|
||||
// Socket handler for due date updates
|
||||
useEffect(() => {
|
||||
if (!socket) return;
|
||||
|
||||
const handleEndDateChange = (task: {
|
||||
id: string;
|
||||
parent_task: string | null;
|
||||
end_date: string;
|
||||
}) => {
|
||||
dispatch(updateTaskEndDate({ task }));
|
||||
dispatch(setTaskEndDate(task));
|
||||
};
|
||||
|
||||
socket.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
|
||||
|
||||
return () => {
|
||||
socket.off(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleEndDateChange);
|
||||
};
|
||||
}, [socket, dispatch]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap={16}
|
||||
align="flex-start"
|
||||
className="max-w-screen max-h-[620px] min-h-[620px] overflow-x-scroll p-[1px]"
|
||||
>
|
||||
<SortableContext
|
||||
items={datasource?.map((section: any) => section.id)}
|
||||
strategy={horizontalListSortingStrategy}
|
||||
>
|
||||
{datasource?.map((data: any) => <BoardSectionCard key={data.id} taskGroup={data} />)}
|
||||
</SortableContext>
|
||||
|
||||
{(group !== 'priority' && (isOwnerorAdmin || isProjectManager)) && <BoardCreateSectionCard />}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardSectionCardContainer;
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Flex, Input, InputRef } from 'antd';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
addSubtask,
|
||||
GROUP_BY_PHASE_VALUE,
|
||||
GROUP_BY_PRIORITY_VALUE,
|
||||
GROUP_BY_STATUS_VALUE,
|
||||
updateSubtask,
|
||||
updateTaskProgress,
|
||||
} from '@features/board/board-slice';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { getCurrentGroup } from '@/features/tasks/tasks.slice';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
type BoardCreateSubtaskCardProps = {
|
||||
sectionId: string;
|
||||
parentTaskId: string;
|
||||
setShowNewSubtaskCard: (x: boolean) => void;
|
||||
};
|
||||
|
||||
const BoardCreateSubtaskCard = ({
|
||||
sectionId,
|
||||
parentTaskId,
|
||||
setShowNewSubtaskCard,
|
||||
}: BoardCreateSubtaskCardProps) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const [creatingTask, setCreatingTask] = useState<boolean>(false);
|
||||
const [newSubtaskName, setNewSubtaskName] = useState<string>('');
|
||||
const [isEnterKeyPressed, setIsEnterKeyPressed] = useState<boolean>(false);
|
||||
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const { t } = useTranslation('kanban-board');
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { projectId } = useParams();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
const createRequestBody = (): ITaskCreateRequest | null => {
|
||||
if (!projectId || !currentSession) return null;
|
||||
const body: ITaskCreateRequest = {
|
||||
project_id: projectId,
|
||||
name: newSubtaskName,
|
||||
reporter_id: currentSession.id,
|
||||
team_id: currentSession.team_id,
|
||||
};
|
||||
|
||||
const groupBy = getCurrentGroup();
|
||||
if (groupBy.value === GROUP_BY_STATUS_VALUE) {
|
||||
body.status_id = sectionId || undefined;
|
||||
} else if (groupBy.value === GROUP_BY_PRIORITY_VALUE) {
|
||||
body.priority_id = sectionId || undefined;
|
||||
} else if (groupBy.value === GROUP_BY_PHASE_VALUE) {
|
||||
body.phase_id = sectionId || undefined;
|
||||
}
|
||||
|
||||
if (parentTaskId) {
|
||||
body.parent_task_id = parentTaskId;
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
const handleAddSubtask = () => {
|
||||
if (creatingTask || !projectId || !currentSession || newSubtaskName.trim() === '' || !connected)
|
||||
return;
|
||||
|
||||
try {
|
||||
setCreatingTask(true);
|
||||
const body = createRequestBody();
|
||||
if (!body) return;
|
||||
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => {
|
||||
if (!task) return;
|
||||
|
||||
dispatch(updateSubtask({ sectionId, subtask: task, mode: 'add' }));
|
||||
setCreatingTask(false);
|
||||
// Clear the input field after successful task creation
|
||||
setNewSubtaskName('');
|
||||
// Focus back to the input field for adding another subtask
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 0);
|
||||
if (task.parent_task_id) {
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
|
||||
socket?.once(SocketEvents.GET_TASK_PROGRESS.toString(), (data: {
|
||||
id: string;
|
||||
complete_ratio: number;
|
||||
completed_count: number;
|
||||
total_tasks_count: number;
|
||||
parent_task: string;
|
||||
}) => {
|
||||
if (!data.parent_task) data.parent_task = task.parent_task_id || '';
|
||||
dispatch(updateTaskProgress(data));
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error adding task:', error);
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
setIsEnterKeyPressed(true);
|
||||
handleAddSubtask();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
if (!isEnterKeyPressed && newSubtaskName.length > 0) {
|
||||
handleAddSubtask();
|
||||
}
|
||||
setIsEnterKeyPressed(false);
|
||||
};
|
||||
|
||||
const handleCancelNewCard = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (cardRef.current && !cardRef.current.contains(e.relatedTarget)) {
|
||||
setNewSubtaskName('');
|
||||
setShowNewSubtaskCard(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={cardRef}
|
||||
vertical
|
||||
gap={12}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: 12,
|
||||
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline`}
|
||||
onBlur={handleCancelNewCard}
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
value={newSubtaskName}
|
||||
onChange={e => setNewSubtaskName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder={t('newSubtaskNamePlaceholder')}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardCreateSubtaskCard;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { useState } from 'react';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { Col, Flex, Typography, List } from 'antd';
|
||||
import CustomAvatarGroup from '@/components/board/custom-avatar-group';
|
||||
import CustomDueDatePicker from '@/components/board/custom-due-date-picker';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
||||
|
||||
interface IBoardSubTaskCardProps {
|
||||
subtask: IProjectTask;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
const BoardSubTaskCard = ({ subtask, sectionId }: IBoardSubTaskCardProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const [subtaskDueDate, setSubtaskDueDate] = useState<Dayjs | null>(
|
||||
subtask?.end_date ? dayjs(subtask?.end_date) : null
|
||||
);
|
||||
|
||||
const handleCardClick = (e: React.MouseEvent, id: string) => {
|
||||
// Prevent the event from propagating to parent elements
|
||||
e.stopPropagation();
|
||||
|
||||
// Add a small delay to ensure it's a click and not the start of a drag
|
||||
const clickTimeout = setTimeout(() => {
|
||||
dispatch(setSelectedTaskId(id));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(clickTimeout);
|
||||
};
|
||||
|
||||
return (
|
||||
<List.Item
|
||||
key={subtask.id}
|
||||
className="group"
|
||||
style={{
|
||||
width: '100%',
|
||||
}}
|
||||
onClick={e => handleCardClick(e, subtask.id || '')}
|
||||
>
|
||||
<Col span={10}>
|
||||
<Typography.Text
|
||||
style={{ fontWeight: 500, fontSize: 14 }}
|
||||
delete={subtask.status === 'done'}
|
||||
ellipsis={{ expanded: false }}
|
||||
>
|
||||
{subtask.name}
|
||||
</Typography.Text>
|
||||
</Col>
|
||||
|
||||
<Flex gap={8} justify="end" style={{ width: '100%' }}>
|
||||
<CustomAvatarGroup task={subtask} sectionId={sectionId} />
|
||||
|
||||
<CustomDueDatePicker task={subtask} onDateChange={setSubtaskDueDate} />
|
||||
</Flex>
|
||||
</List.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardSubTaskCard;
|
||||
@@ -0,0 +1,248 @@
|
||||
import { Button, Flex, Input, InputRef } from 'antd';
|
||||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { Dayjs } from 'dayjs';
|
||||
import { nanoid } from '@reduxjs/toolkit';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
addTaskCardToTheBottom,
|
||||
addTaskCardToTheTop,
|
||||
getCurrentGroupBoard,
|
||||
GROUP_BY_STATUS_VALUE,
|
||||
GROUP_BY_PRIORITY_VALUE,
|
||||
GROUP_BY_PHASE_VALUE,
|
||||
} from '@features/board/board-slice';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import CustomDueDatePicker from '@/components/board/custom-due-date-picker';
|
||||
import AddMembersDropdown from '@/components/add-members-dropdown-v2/add-members-dropdown';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
|
||||
|
||||
type BoardViewCreateTaskCardProps = {
|
||||
position: 'top' | 'bottom';
|
||||
sectionId: string;
|
||||
setShowNewCard: (x: boolean) => void;
|
||||
};
|
||||
|
||||
const BoardViewCreateTaskCard = ({
|
||||
position,
|
||||
sectionId,
|
||||
setShowNewCard,
|
||||
}: BoardViewCreateTaskCardProps) => {
|
||||
const { t } = useTranslation('kanban-board');
|
||||
const dispatch = useAppDispatch();
|
||||
const { socket } = useSocket();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
const [newTaskName, setNewTaskName] = useState<string>('');
|
||||
const [dueDate, setDueDate] = useState<Dayjs | null>(null);
|
||||
const [creatingTask, setCreatingTask] = useState<boolean>(false);
|
||||
|
||||
const cardRef = useRef<HTMLDivElement>(null);
|
||||
const inputRef = useRef<InputRef>(null);
|
||||
|
||||
const focusInput = () => {
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
// Focus when component mounts or when showNewCard becomes true
|
||||
useEffect(() => {
|
||||
focusInput();
|
||||
}, []);
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
|
||||
const createRequestBody = (): ITaskCreateRequest | null => {
|
||||
if (!projectId || !currentSession) return null;
|
||||
|
||||
const body: ITaskCreateRequest = {
|
||||
project_id: projectId,
|
||||
name: newTaskName.trim(),
|
||||
reporter_id: currentSession.id,
|
||||
team_id: currentSession.team_id,
|
||||
};
|
||||
|
||||
// Set end date if provided
|
||||
if (dueDate) {
|
||||
body.end_date = dueDate.toISOString();
|
||||
}
|
||||
|
||||
// Set the appropriate group ID based on the current grouping
|
||||
const groupBy = getCurrentGroupBoard();
|
||||
if (groupBy.value === GROUP_BY_STATUS_VALUE) {
|
||||
body.status_id = sectionId;
|
||||
} else if (groupBy.value === GROUP_BY_PRIORITY_VALUE) {
|
||||
body.priority_id = sectionId;
|
||||
} else if (groupBy.value === GROUP_BY_PHASE_VALUE) {
|
||||
body.phase_id = sectionId;
|
||||
}
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setNewTaskName('');
|
||||
setDueDate(null);
|
||||
setCreatingTask(false);
|
||||
setShowNewCard(true);
|
||||
focusInput();
|
||||
};
|
||||
|
||||
const handleAddTaskToTheTop = async () => {
|
||||
if (creatingTask || !projectId || !currentSession || newTaskName.trim() === '') return;
|
||||
|
||||
try {
|
||||
setCreatingTask(true);
|
||||
const body = createRequestBody();
|
||||
if (!body) return;
|
||||
|
||||
// Create a unique event handler for this specific task creation
|
||||
const eventHandler = (task: IProjectTask) => {
|
||||
// Set creating task to false
|
||||
setCreatingTask(false);
|
||||
|
||||
// Add the task to the state at the top of the section
|
||||
dispatch(
|
||||
addTaskCardToTheTop({
|
||||
sectionId: sectionId,
|
||||
task: {
|
||||
...task,
|
||||
id: task.id || nanoid(),
|
||||
name: task.name || newTaskName.trim(),
|
||||
end_date: task.end_date || dueDate,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Remove the event listener to prevent memory leaks
|
||||
socket?.off(SocketEvents.QUICK_TASK.toString(), eventHandler);
|
||||
|
||||
// Reset the form
|
||||
resetForm();
|
||||
};
|
||||
|
||||
// Register the event handler before emitting
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), eventHandler);
|
||||
|
||||
// Emit the event
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
} catch (error) {
|
||||
console.error('Error adding task:', error);
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddTaskToTheBottom = async () => {
|
||||
if (creatingTask || !projectId || !currentSession || newTaskName.trim() === '') return;
|
||||
|
||||
try {
|
||||
setCreatingTask(true);
|
||||
const body = createRequestBody();
|
||||
if (!body) return;
|
||||
|
||||
// Create a unique event handler for this specific task creation
|
||||
const eventHandler = (task: IProjectTask) => {
|
||||
// Set creating task to false
|
||||
setCreatingTask(false);
|
||||
|
||||
// Add the task to the state at the bottom of the section
|
||||
dispatch(
|
||||
addTaskCardToTheBottom({
|
||||
sectionId: sectionId,
|
||||
task: {
|
||||
...task,
|
||||
id: task.id || nanoid(),
|
||||
name: task.name || newTaskName.trim(),
|
||||
end_date: task.end_date || dueDate,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
// Remove the event listener to prevent memory leaks
|
||||
socket?.off(SocketEvents.QUICK_TASK.toString(), eventHandler);
|
||||
|
||||
// Reset the form
|
||||
resetForm();
|
||||
};
|
||||
|
||||
// Register the event handler before emitting
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), eventHandler);
|
||||
|
||||
// Emit the event
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
} catch (error) {
|
||||
console.error('Error adding task:', error);
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancelNewCard = (e: React.FocusEvent<HTMLDivElement>) => {
|
||||
if (cardRef.current && !cardRef.current.contains(e.relatedTarget)) {
|
||||
// Only reset the form without creating a task
|
||||
setNewTaskName('');
|
||||
setShowNewCard(false);
|
||||
setDueDate(null);
|
||||
setCreatingTask(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
ref={cardRef}
|
||||
vertical
|
||||
gap={12}
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: 12,
|
||||
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
|
||||
borderRadius: 6,
|
||||
cursor: 'pointer',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline`}
|
||||
onBlur={handleCancelNewCard}
|
||||
>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={newTaskName}
|
||||
onChange={e => setNewTaskName(e.target.value)}
|
||||
onPressEnter={position === 'bottom' ? handleAddTaskToTheBottom : handleAddTaskToTheTop}
|
||||
placeholder={t('newTaskNamePlaceholder')}
|
||||
style={{
|
||||
width: '100%',
|
||||
borderRadius: 6,
|
||||
padding: 8,
|
||||
}}
|
||||
disabled={creatingTask}
|
||||
/>
|
||||
{newTaskName.trim() && (
|
||||
<Flex gap={8} justify="flex-end">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => setShowNewCard(false)}
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={position === 'bottom' ? handleAddTaskToTheBottom : handleAddTaskToTheTop}
|
||||
loading={creatingTask}
|
||||
>
|
||||
{t('addTask')}
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardViewCreateTaskCard;
|
||||
@@ -0,0 +1,413 @@
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Tooltip,
|
||||
Tag,
|
||||
Progress,
|
||||
Typography,
|
||||
Dropdown,
|
||||
MenuProps,
|
||||
Button,
|
||||
Flex,
|
||||
List,
|
||||
Divider,
|
||||
Popconfirm,
|
||||
Skeleton,
|
||||
} from 'antd';
|
||||
import {
|
||||
DoubleRightOutlined,
|
||||
PauseOutlined,
|
||||
UserAddOutlined,
|
||||
InboxOutlined,
|
||||
DeleteOutlined,
|
||||
MinusOutlined,
|
||||
ForkOutlined,
|
||||
CaretRightFilled,
|
||||
CaretDownFilled,
|
||||
ExclamationCircleFilled,
|
||||
PlusOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import BoardSubTaskCard from '../board-sub-task-card/board-sub-task-card';
|
||||
import CustomAvatarGroup from '@/components/board/custom-avatar-group';
|
||||
import CustomDueDatePicker from '@/components/board/custom-due-date-picker';
|
||||
import { colors } from '@/styles/colors';
|
||||
import {
|
||||
deleteBoardTask,
|
||||
fetchBoardSubTasks,
|
||||
updateBoardTaskAssignee,
|
||||
} from '@features/board/board-slice';
|
||||
import BoardCreateSubtaskCard from '../board-sub-task-card/board-create-sub-task-card';
|
||||
import { setShowTaskDrawer, setSelectedTaskId } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { IBulkAssignRequest } from '@/types/tasks/bulk-action-bar.types';
|
||||
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import {
|
||||
evt_project_task_list_context_menu_archive,
|
||||
evt_project_task_list_context_menu_assign_me,
|
||||
evt_project_task_list_context_menu_delete,
|
||||
} from '@/shared/worklenz-analytics-events';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
interface IBoardViewTaskCardProps {
|
||||
task: IProjectTask;
|
||||
sectionId: string;
|
||||
}
|
||||
|
||||
const BoardViewTaskCard = ({ task, sectionId }: IBoardViewTaskCardProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('kanban-board');
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
const [isSubTaskShow, setIsSubTaskShow] = useState(false);
|
||||
const [showNewSubtaskCard, setShowNewSubtaskCard] = useState(false);
|
||||
const [dueDate, setDueDate] = useState<Dayjs | null>(
|
||||
task?.end_date ? dayjs(task?.end_date) : null
|
||||
);
|
||||
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
|
||||
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
|
||||
id: task.id || '',
|
||||
data: {
|
||||
type: 'task',
|
||||
task,
|
||||
sectionId,
|
||||
},
|
||||
});
|
||||
|
||||
const style = useMemo(() => ({
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
}), [transform, transition, isDragging]);
|
||||
|
||||
const handleCardClick = useCallback((e: React.MouseEvent, id: string) => {
|
||||
// Prevent the event from propagating to parent elements
|
||||
e.stopPropagation();
|
||||
|
||||
// Don't handle click if we're dragging
|
||||
if (isDragging) return;
|
||||
|
||||
// Add a small delay to ensure it's a click and not the start of a drag
|
||||
const clickTimeout = setTimeout(() => {
|
||||
dispatch(setSelectedTaskId(id));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}, 50);
|
||||
|
||||
return () => clearTimeout(clickTimeout);
|
||||
}, [dispatch, isDragging]);
|
||||
|
||||
const handleAssignToMe = useCallback(async () => {
|
||||
if (!projectId || !task.id || updatingAssignToMe) return;
|
||||
|
||||
try {
|
||||
setUpdatingAssignToMe(true);
|
||||
const body: IBulkAssignRequest = {
|
||||
tasks: [task.id],
|
||||
project_id: projectId,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.assignToMe(body);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_context_menu_assign_me);
|
||||
dispatch(
|
||||
updateBoardTaskAssignee({
|
||||
body: res.body,
|
||||
sectionId,
|
||||
taskId: task.id,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error assigning task to me:', error);
|
||||
} finally {
|
||||
setUpdatingAssignToMe(false);
|
||||
}
|
||||
}, [projectId, task.id, updatingAssignToMe, dispatch, trackMixpanelEvent, sectionId]);
|
||||
|
||||
const handleArchive = useCallback(async () => {
|
||||
if (!projectId || !task.id) return;
|
||||
|
||||
try {
|
||||
const res = await taskListBulkActionsApiService.archiveTasks(
|
||||
{
|
||||
tasks: [task.id],
|
||||
project_id: projectId,
|
||||
},
|
||||
false
|
||||
);
|
||||
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_context_menu_archive);
|
||||
dispatch(deleteBoardTask({ sectionId, taskId: task.id }));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error archiving task:', error);
|
||||
}
|
||||
}, [projectId, task.id, dispatch, trackMixpanelEvent, sectionId]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!projectId || !task.id) return;
|
||||
|
||||
try {
|
||||
const res = await taskListBulkActionsApiService.deleteTasks({ tasks: [task.id] }, projectId);
|
||||
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_context_menu_delete);
|
||||
dispatch(deleteBoardTask({ sectionId, taskId: task.id }));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting task:', error);
|
||||
}
|
||||
}, [projectId, task.id, dispatch, trackMixpanelEvent, sectionId]);
|
||||
|
||||
const handleSubTaskExpand = useCallback(() => {
|
||||
if (task && task.id && projectId) {
|
||||
if (task.show_sub_tasks) {
|
||||
// If subtasks are already loaded, just toggle visibility
|
||||
setIsSubTaskShow(prev => !prev);
|
||||
} else {
|
||||
// If subtasks need to be fetched, show the section first with loading state
|
||||
setIsSubTaskShow(true);
|
||||
dispatch(fetchBoardSubTasks({ taskId: task.id, projectId }));
|
||||
}
|
||||
}
|
||||
}, [task, projectId, dispatch]);
|
||||
|
||||
const handleAddSubtaskClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setShowNewSubtaskCard(true);
|
||||
}, []);
|
||||
|
||||
const handleSubtaskButtonClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
handleSubTaskExpand();
|
||||
}, [handleSubTaskExpand]);
|
||||
|
||||
const items: MenuProps['items'] = useMemo(() => [
|
||||
{
|
||||
label: (
|
||||
<span>
|
||||
<UserAddOutlined />
|
||||
|
||||
<Typography.Text>{t('assignToMe')}</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
key: '1',
|
||||
onClick: handleAssignToMe,
|
||||
disabled: updatingAssignToMe,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<span>
|
||||
<InboxOutlined />
|
||||
|
||||
<Typography.Text>{t('archive')}</Typography.Text>
|
||||
</span>
|
||||
),
|
||||
key: '2',
|
||||
onClick: handleArchive,
|
||||
},
|
||||
{
|
||||
label: (
|
||||
<Popconfirm
|
||||
title={t('deleteConfirmationTitle')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText={t('deleteConfirmationOk')}
|
||||
cancelText={t('deleteConfirmationCancel')}
|
||||
onConfirm={handleDelete}
|
||||
>
|
||||
<DeleteOutlined />
|
||||
|
||||
{t('delete')}
|
||||
</Popconfirm>
|
||||
),
|
||||
key: '3',
|
||||
},
|
||||
], [t, handleAssignToMe, handleArchive, handleDelete, updatingAssignToMe]);
|
||||
|
||||
const priorityIcon = useMemo(() => {
|
||||
if (task.priority_value === 0) {
|
||||
return (
|
||||
<MinusOutlined
|
||||
style={{
|
||||
color: '#52c41a',
|
||||
marginRight: '0.25rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (task.priority_value === 1) {
|
||||
return (
|
||||
<PauseOutlined
|
||||
style={{
|
||||
color: '#faad14',
|
||||
transform: 'rotate(90deg)',
|
||||
marginRight: '0.25rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<DoubleRightOutlined
|
||||
style={{
|
||||
color: '#f5222d',
|
||||
transform: 'rotate(-90deg)',
|
||||
marginRight: '0.25rem',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}, [task.priority_value]);
|
||||
|
||||
const renderLabels = useMemo(() => {
|
||||
if (!task?.labels?.length) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{task.labels.slice(0, 2).map((label: any) => (
|
||||
<Tag key={label.id} style={{ marginRight: '4px' }} color={label?.color_code}>
|
||||
<span style={{ color: themeMode === 'dark' ? '#383838' : '' }}>
|
||||
{label.name}
|
||||
</span>
|
||||
</Tag>
|
||||
))}
|
||||
{task.labels.length > 2 && <Tag>+ {task.labels.length - 2}</Tag>}
|
||||
</>
|
||||
);
|
||||
}, [task.labels, themeMode]);
|
||||
|
||||
return (
|
||||
<Dropdown menu={{ items }} trigger={['contextMenu']}>
|
||||
<Flex
|
||||
ref={setNodeRef}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
vertical
|
||||
gap={12}
|
||||
style={{
|
||||
...style,
|
||||
width: '100%',
|
||||
padding: 12,
|
||||
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
|
||||
borderRadius: 6,
|
||||
cursor: 'grab',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
className={`group outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline board-task-card`}
|
||||
onClick={e => handleCardClick(e, task.id || '')}
|
||||
data-id={task.id}
|
||||
data-dragging={isDragging ? "true" : "false"}
|
||||
>
|
||||
{/* Labels and Progress */}
|
||||
<Flex align="center" justify="space-between">
|
||||
<Flex>
|
||||
{renderLabels}
|
||||
</Flex>
|
||||
|
||||
<Tooltip title={` ${task?.completed_count} / ${task?.total_tasks_count}`}>
|
||||
<Progress type="circle" percent={task?.complete_ratio } size={26} strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7} />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
{/* Action Icons */}
|
||||
<Flex gap={4}>
|
||||
{priorityIcon}
|
||||
<Typography.Text
|
||||
style={{ fontWeight: 500 }}
|
||||
ellipsis={{ tooltip: task.name }}
|
||||
>
|
||||
{task.name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
|
||||
<Flex vertical gap={8}>
|
||||
<Flex
|
||||
align="center"
|
||||
justify="space-between"
|
||||
style={{
|
||||
marginBlock: 8,
|
||||
}}
|
||||
>
|
||||
{task && <CustomAvatarGroup task={task} sectionId={sectionId} />}
|
||||
|
||||
<Flex gap={4} align="center">
|
||||
<CustomDueDatePicker task={task} onDateChange={setDueDate} />
|
||||
|
||||
{/* Subtask Section */}
|
||||
<Button
|
||||
onClick={handleSubtaskButtonClick}
|
||||
size="small"
|
||||
style={{
|
||||
padding: 0,
|
||||
}}
|
||||
type="text"
|
||||
>
|
||||
<Tag
|
||||
bordered={false}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
margin: 0,
|
||||
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
|
||||
}}
|
||||
>
|
||||
<ForkOutlined rotate={90} />
|
||||
<span>{task.sub_tasks_count}</span>
|
||||
{isSubTaskShow ? <CaretDownFilled /> : <CaretRightFilled />}
|
||||
</Tag>
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
|
||||
{isSubTaskShow && (
|
||||
<Flex vertical>
|
||||
<Divider style={{ marginBlock: 0 }} />
|
||||
<List>
|
||||
{task.sub_tasks_loading && (
|
||||
<List.Item>
|
||||
<Skeleton active paragraph={{ rows: 2 }} title={false} style={{ marginTop: 8 }} />
|
||||
</List.Item>
|
||||
)}
|
||||
|
||||
{!task.sub_tasks_loading && task?.sub_tasks &&
|
||||
task?.sub_tasks.map((subtask: any) => (
|
||||
<BoardSubTaskCard key={subtask.id} subtask={subtask} sectionId={sectionId} />
|
||||
))}
|
||||
|
||||
{showNewSubtaskCard && (
|
||||
<BoardCreateSubtaskCard
|
||||
sectionId={sectionId}
|
||||
parentTaskId={task.id || ''}
|
||||
setShowNewSubtaskCard={setShowNewSubtaskCard}
|
||||
/>
|
||||
)}
|
||||
</List>
|
||||
<Button
|
||||
type="text"
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 6,
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddSubtaskClick}
|
||||
>
|
||||
{t('addSubtask', 'Add Subtask')}
|
||||
</Button>
|
||||
</Flex>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoardViewTaskCard;
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import TaskListFilters from '../taskList/task-list-filters/task-list-filters';
|
||||
import { Flex, Skeleton } from 'antd';
|
||||
import BoardSectionCardContainer from './board-section/board-section-container';
|
||||
import {
|
||||
fetchBoardTaskGroups,
|
||||
reorderTaskGroups,
|
||||
moveTaskBetweenGroups,
|
||||
IGroupBy,
|
||||
updateTaskProgress,
|
||||
} from '@features/board/board-slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
DndContext,
|
||||
DragEndEvent,
|
||||
DragOverEvent,
|
||||
DragStartEvent,
|
||||
closestCorners,
|
||||
DragOverlay,
|
||||
MouseSensor,
|
||||
TouchSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import BoardViewTaskCard from './board-section/board-task-card/board-view-task-card';
|
||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import alertService from '@/services/alerts/alertService';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { evt_project_board_visit, evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics-events';
|
||||
import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request';
|
||||
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { tasksApiService } from '@/api/tasks/tasks.api.service';
|
||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||
|
||||
const ProjectViewBoard = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { projectView } = useTabSearchParam();
|
||||
const { socket } = useSocket();
|
||||
const authService = useAuthService();
|
||||
const currentSession = authService.getCurrentSession();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const [ currentTaskIndex, setCurrentTaskIndex] = useState(-1);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer);
|
||||
const { statusCategories, loading: loadingStatusCategories } = useAppSelector(
|
||||
state => state.taskStatusReducer
|
||||
);
|
||||
const [activeItem, setActiveItem] = useState<any>(null);
|
||||
|
||||
// Store the original source group ID when drag starts
|
||||
const originalSourceGroupIdRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (projectId && groupBy && projectView === 'kanban') {
|
||||
if (!loadingGroups) {
|
||||
dispatch(fetchBoardTaskGroups(projectId));
|
||||
}
|
||||
}
|
||||
}, [dispatch, projectId, groupBy, projectView, search, archived]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(MouseSensor, {
|
||||
// Require the mouse to move by 10 pixels before activating
|
||||
activationConstraint: {
|
||||
distance: 10,
|
||||
},
|
||||
}),
|
||||
useSensor(TouchSensor, {
|
||||
// Press delay of 250ms, with tolerance of 5px of movement
|
||||
activationConstraint: {
|
||||
delay: 250,
|
||||
tolerance: 5,
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
const handleTaskProgress = (data: {
|
||||
id: string;
|
||||
status: string;
|
||||
complete_ratio: number;
|
||||
completed_count: number;
|
||||
total_tasks_count: number;
|
||||
parent_task: string;
|
||||
}) => {
|
||||
dispatch(updateTaskProgress(data));
|
||||
};
|
||||
|
||||
const handleDragStart = (event: DragStartEvent) => {
|
||||
const { active } = event;
|
||||
setActiveItem(active.data.current);
|
||||
setCurrentTaskIndex(active.data.current?.sortable.index);
|
||||
// Store the original source group ID when drag starts
|
||||
if (active.data.current?.type === 'task') {
|
||||
originalSourceGroupIdRef.current = active.data.current.sectionId;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (event: DragOverEvent) => {
|
||||
const { active, over } = event;
|
||||
if (!over) return;
|
||||
|
||||
const activeId = active.id;
|
||||
const overId = over.id;
|
||||
|
||||
if (activeId === overId) return;
|
||||
|
||||
const isActiveTask = active.data.current?.type === 'task';
|
||||
const isOverTask = over.data.current?.type === 'task';
|
||||
const isOverSection = over.data.current?.type === 'section';
|
||||
|
||||
// Handle task movement between sections
|
||||
if (isActiveTask && (isOverTask || isOverSection)) {
|
||||
// If we're over a task, we want to insert at that position
|
||||
// If we're over a section, we want to append to the end
|
||||
const activeTaskId = active.data.current?.task.id;
|
||||
|
||||
// Use the original source group ID from ref instead of the potentially modified one
|
||||
const sourceGroupId = originalSourceGroupIdRef.current || active.data.current?.sectionId;
|
||||
|
||||
// Fix: Ensure we correctly identify the target group ID
|
||||
let targetGroupId;
|
||||
if (isOverTask) {
|
||||
// If over a task, get its section ID
|
||||
targetGroupId = over.data.current?.sectionId;
|
||||
} else if (isOverSection) {
|
||||
// If over a section directly
|
||||
targetGroupId = over.id;
|
||||
} else {
|
||||
// Fallback
|
||||
targetGroupId = over.id;
|
||||
}
|
||||
|
||||
// Find the target index
|
||||
let targetIndex = -1;
|
||||
if (isOverTask) {
|
||||
const overTaskId = over.data.current?.task.id;
|
||||
const targetGroup = taskGroups.find(group => group.id === targetGroupId);
|
||||
if (targetGroup) {
|
||||
targetIndex = targetGroup.tasks.findIndex(task => task.id === overTaskId);
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch the action to move the task
|
||||
dispatch(
|
||||
moveTaskBetweenGroups({
|
||||
taskId: activeTaskId,
|
||||
sourceGroupId,
|
||||
targetGroupId,
|
||||
targetIndex,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragEnd = async (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (!over || !projectId) {
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||
return;
|
||||
}
|
||||
|
||||
const isActiveTask = active.data.current?.type === 'task';
|
||||
const isActiveSection = active.data.current?.type === 'section';
|
||||
|
||||
// Handle task dragging between columns
|
||||
if (isActiveTask) {
|
||||
const task = active.data.current?.task;
|
||||
|
||||
// Use the original source group ID from ref instead of the potentially modified one
|
||||
const sourceGroupId = originalSourceGroupIdRef.current || active.data.current?.sectionId;
|
||||
|
||||
// Fix: Ensure we correctly identify the target group ID
|
||||
let targetGroupId;
|
||||
if (over.data.current?.type === 'task') {
|
||||
// If dropping on a task, get its section ID
|
||||
targetGroupId = over.data.current?.sectionId;
|
||||
} else if (over.data.current?.type === 'section') {
|
||||
// If dropping directly on a section
|
||||
targetGroupId = over.id;
|
||||
} else {
|
||||
// Fallback to the over ID if type is not specified
|
||||
targetGroupId = over.id;
|
||||
}
|
||||
|
||||
// Find source and target groups
|
||||
const sourceGroup = taskGroups.find(group => group.id === sourceGroupId);
|
||||
const targetGroup = taskGroups.find(group => group.id === targetGroupId);
|
||||
|
||||
if (!sourceGroup || !targetGroup || !task) {
|
||||
logger.error('Could not find source or target group, or task is undefined');
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||
return;
|
||||
}
|
||||
if (targetGroupId !== sourceGroupId) {
|
||||
const canContinue = await checkTaskDependencyStatus(task.id, targetGroupId);
|
||||
if (!canContinue) {
|
||||
alertService.error(
|
||||
'Task is not completed',
|
||||
'Please complete the task dependencies before proceeding'
|
||||
);
|
||||
dispatch(
|
||||
moveTaskBetweenGroups({
|
||||
taskId: task.id,
|
||||
sourceGroupId: targetGroupId, // Current group (where it was moved optimistically)
|
||||
targetGroupId: sourceGroupId, // Move it back to the original source group
|
||||
targetIndex: currentTaskIndex !== -1 ? currentTaskIndex : 0, // Original position or append to end
|
||||
})
|
||||
);
|
||||
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Find indices
|
||||
let fromIndex = sourceGroup.tasks.findIndex(t => t.id === task.id);
|
||||
|
||||
// Handle case where task is not found in source group (might have been moved already in UI)
|
||||
if (fromIndex === -1) {
|
||||
logger.info('Task not found in source group. Using task sort_order from task object.');
|
||||
|
||||
// Use the sort_order from the task object itself
|
||||
const fromSortOrder = task.sort_order;
|
||||
|
||||
// Calculate target index and position
|
||||
let toIndex = -1;
|
||||
if (over.data.current?.type === 'task') {
|
||||
const overTaskId = over.data.current?.task.id;
|
||||
toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
|
||||
} else {
|
||||
// If dropping on a section, append to the end
|
||||
toIndex = targetGroup.tasks.length;
|
||||
}
|
||||
|
||||
// Calculate toPos similar to Angular implementation
|
||||
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
|
||||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
||||
-1;
|
||||
|
||||
// Prepare socket event payload
|
||||
const body = {
|
||||
project_id: projectId,
|
||||
from_index: fromSortOrder,
|
||||
to_index: toPos,
|
||||
to_last_index: !toPos,
|
||||
from_group: sourceGroupId,
|
||||
to_group: targetGroupId,
|
||||
group_by: groupBy || 'status',
|
||||
task,
|
||||
team_id: currentSession?.team_id
|
||||
};
|
||||
|
||||
logger.error('Emitting socket event with payload (task not found in source):', body);
|
||||
|
||||
// Emit socket event
|
||||
if (socket) {
|
||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
|
||||
|
||||
// Set up listener for task progress update
|
||||
socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => {
|
||||
if (task.is_sub_task) {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
|
||||
} else {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track analytics event
|
||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||
return;
|
||||
}
|
||||
|
||||
// Calculate target index and position
|
||||
let toIndex = -1;
|
||||
if (over.data.current?.type === 'task') {
|
||||
const overTaskId = over.data.current?.task.id;
|
||||
toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId);
|
||||
} else {
|
||||
// If dropping on a section, append to the end
|
||||
toIndex = targetGroup.tasks.length;
|
||||
}
|
||||
|
||||
// Calculate toPos similar to Angular implementation
|
||||
const toPos = targetGroup.tasks[toIndex]?.sort_order ||
|
||||
targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order ||
|
||||
-1;
|
||||
|
||||
// Prepare socket event payload
|
||||
const body = {
|
||||
project_id: projectId,
|
||||
from_index: sourceGroup.tasks[fromIndex].sort_order,
|
||||
to_index: toPos,
|
||||
to_last_index: !toPos,
|
||||
from_group: sourceGroupId, // Use the direct IDs instead of group objects
|
||||
to_group: targetGroupId, // Use the direct IDs instead of group objects
|
||||
group_by: groupBy || 'status', // Use the current groupBy value
|
||||
task,
|
||||
team_id: currentSession?.team_id
|
||||
};
|
||||
|
||||
// Emit socket event
|
||||
if (socket) {
|
||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body);
|
||||
|
||||
// Set up listener for task progress update
|
||||
socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => {
|
||||
if (task.is_sub_task) {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
|
||||
} else {
|
||||
socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Track analytics event
|
||||
trackMixpanelEvent(evt_project_task_list_drag_and_move);
|
||||
}
|
||||
// Handle column reordering
|
||||
else if (isActiveSection) {
|
||||
// Don't allow reordering if groupBy is phases
|
||||
if (groupBy === IGroupBy.PHASE) {
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const sectionId = active.id;
|
||||
const fromIndex = taskGroups.findIndex(group => group.id === sectionId);
|
||||
const toIndex = taskGroups.findIndex(group => group.id === over.id);
|
||||
|
||||
if (fromIndex !== -1 && toIndex !== -1) {
|
||||
// Create a new array with the reordered groups
|
||||
const reorderedGroups = [...taskGroups];
|
||||
const [movedGroup] = reorderedGroups.splice(fromIndex, 1);
|
||||
reorderedGroups.splice(toIndex, 0, movedGroup);
|
||||
|
||||
// Dispatch action to reorder columns with the new array
|
||||
dispatch(reorderTaskGroups(reorderedGroups));
|
||||
|
||||
// Prepare column order for API
|
||||
const columnOrder = reorderedGroups.map(group => group.id);
|
||||
|
||||
// Call API to update status order
|
||||
try {
|
||||
// Use the correct API endpoint based on the Angular code
|
||||
const requestBody: ITaskStatusCreateRequest = {
|
||||
status_order: columnOrder
|
||||
};
|
||||
|
||||
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
|
||||
if (!response.done) {
|
||||
const revertedGroups = [...reorderedGroups];
|
||||
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
|
||||
revertedGroups.splice(fromIndex, 0, movedBackGroup);
|
||||
dispatch(reorderTaskGroups(revertedGroups));
|
||||
alertService.error('Failed to update column order', 'Please try again');
|
||||
}
|
||||
} catch (error) {
|
||||
// Revert the change if API call fails
|
||||
const revertedGroups = [...reorderedGroups];
|
||||
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
|
||||
revertedGroups.splice(fromIndex, 0, movedBackGroup);
|
||||
dispatch(reorderTaskGroups(revertedGroups));
|
||||
alertService.error('Failed to update column order', 'Please try again');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setActiveItem(null);
|
||||
originalSourceGroupIdRef.current = null; // Reset the ref
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (socket) {
|
||||
socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
||||
}
|
||||
|
||||
return () => {
|
||||
socket?.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress);
|
||||
};
|
||||
}, [socket]);
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_project_board_visit);
|
||||
if (!statusCategories.length && projectId) {
|
||||
dispatch(fetchStatusesCategories());
|
||||
}
|
||||
}, [dispatch, projectId]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={16}>
|
||||
<TaskListFilters position={'board'} />
|
||||
|
||||
<Skeleton active loading={loadingGroups} className='mt-4 p-4'>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<BoardSectionCardContainer
|
||||
datasource={taskGroups}
|
||||
group={groupBy as 'status' | 'priority' | 'phases'}
|
||||
/>
|
||||
<DragOverlay>
|
||||
{activeItem?.type === 'task' && (
|
||||
<BoardViewTaskCard task={activeItem.task} sectionId={activeItem.sectionId} />
|
||||
)}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
</Skeleton>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewBoard;
|
||||
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Popconfirm,
|
||||
Segmented,
|
||||
Table,
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import {
|
||||
AppstoreOutlined,
|
||||
BarsOutlined,
|
||||
CloudDownloadOutlined,
|
||||
DeleteOutlined,
|
||||
ExclamationCircleFilled,
|
||||
ExclamationCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { durationDateFormat } from '@utils/durationDateFormat';
|
||||
import { DEFAULT_PAGE_SIZE, IconsMap } from '@/shared/constants';
|
||||
import {
|
||||
IProjectAttachmentsViewModel,
|
||||
ITaskAttachmentViewModel,
|
||||
} from '@/types/tasks/task-attachment-view-model';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { attachmentsApiService } from '@/api/attachments/attachments.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { evt_project_files_visit } from '@/shared/worklenz-analytics-events';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
|
||||
const ProjectViewFiles = () => {
|
||||
const { t } = useTranslation('project-view-files');
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const { projectId, refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
const [attachments, setAttachments] = useState<IProjectAttachmentsViewModel>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
|
||||
const [paginationConfig, setPaginationConfig] = useState({
|
||||
total: 0,
|
||||
pageIndex: 1,
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE,
|
||||
});
|
||||
|
||||
|
||||
const fetchAttachments = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await attachmentsApiService.getProjectAttachments(
|
||||
projectId,
|
||||
paginationConfig.pageIndex,
|
||||
paginationConfig.defaultPageSize
|
||||
);
|
||||
if (response.done) {
|
||||
setAttachments(response.body || {});
|
||||
setPaginationConfig(prev => ({ ...prev, total: response.body?.total || 0 }));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching project attachments', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAttachments();
|
||||
}, [refreshTimestamp]);
|
||||
|
||||
const getFileTypeIcon = (type: string | undefined) => {
|
||||
if (!type) return IconsMap['search'];
|
||||
return IconsMap[type as string] || IconsMap['search'];
|
||||
};
|
||||
|
||||
const downloadAttachment = async (id: string | undefined, filename: string | undefined) => {
|
||||
if (!id || !filename) return;
|
||||
try {
|
||||
setDownloading(true);
|
||||
|
||||
const response = await attachmentsApiService.downloadAttachment(id, filename);
|
||||
|
||||
if (response.done) {
|
||||
const link = document.createElement('a');
|
||||
link.href = response.body || '';
|
||||
link.download = filename;
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error downloading attachment', error);
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteAttachment = async (id: string | undefined) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const response = await attachmentsApiService.deleteAttachment(id);
|
||||
if (response.done) {
|
||||
fetchAttachments();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting attachment', error);
|
||||
}
|
||||
};
|
||||
|
||||
const openAttachment = (url: string | undefined) => {
|
||||
if (!url) return;
|
||||
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.target = '_blank';
|
||||
a.style.display = 'none';
|
||||
a.click();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_project_files_visit);
|
||||
fetchAttachments();
|
||||
}, [paginationConfig.pageIndex, projectId]);
|
||||
|
||||
const columns: TableProps<ITaskAttachmentViewModel>['columns'] = [
|
||||
{
|
||||
key: 'fileName',
|
||||
title: t('nameColumn'),
|
||||
render: (record: ITaskAttachmentViewModel) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
align="center"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => openAttachment(record.url)}
|
||||
>
|
||||
<img
|
||||
src={`/file-types/${getFileTypeIcon(record.type)}`}
|
||||
alt={t('fileIconAlt')}
|
||||
style={{ width: '100%', maxWidth: 25 }}
|
||||
/>
|
||||
<Typography.Text>
|
||||
[{record.task_key}] {record.name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attachedTask',
|
||||
title: t('attachedTaskColumn'),
|
||||
render: (record: ITaskAttachmentViewModel) => (
|
||||
<Typography.Text style={{ cursor: 'pointer' }} onClick={() => openAttachment(record.url)}>
|
||||
{record.task_name}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'size',
|
||||
title: t('sizeColumn'),
|
||||
render: (record: ITaskAttachmentViewModel) => (
|
||||
<Typography.Text style={{ cursor: 'pointer' }} onClick={() => openAttachment(record.url)}>
|
||||
{record.size}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'uploadedBy',
|
||||
title: t('uploadedByColumn'),
|
||||
render: (record: ITaskAttachmentViewModel) => (
|
||||
<Typography.Text style={{ cursor: 'pointer' }} onClick={() => openAttachment(record.url)}>
|
||||
{record.uploader_name}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'uploadedAt',
|
||||
title: t('uploadedAtColumn'),
|
||||
render: (record: ITaskAttachmentViewModel) => (
|
||||
<Typography.Text style={{ cursor: 'pointer' }} onClick={() => openAttachment(record.url)}>
|
||||
<Tooltip title={record.created_at}>{durationDateFormat(record.created_at)}</Tooltip>
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actionBtns',
|
||||
width: 80,
|
||||
render: (record: ITaskAttachmentViewModel) => (
|
||||
<Flex gap={8} style={{ padding: 0 }}>
|
||||
<Popconfirm
|
||||
title={t('deleteConfirmationTitle')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText={t('deleteConfirmationOk')}
|
||||
cancelText={t('deleteConfirmationCancel')}
|
||||
onConfirm={() => deleteAttachment(record.id)}
|
||||
>
|
||||
<Tooltip title="Delete">
|
||||
<Button shape="default" icon={<DeleteOutlined />} size="small" />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
|
||||
<Tooltip title="Download">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
onClick={() => downloadAttachment(record.id, record.name)}
|
||||
loading={downloading}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{ width: '100%' }}
|
||||
title={
|
||||
<Flex justify="space-between">
|
||||
<Typography.Text
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 4,
|
||||
alignItems: 'center',
|
||||
color: colors.lightGray,
|
||||
fontSize: 13,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
<ExclamationCircleOutlined />
|
||||
{t('titleDescriptionText')}
|
||||
</Typography.Text>
|
||||
|
||||
<Tooltip title={t('segmentedTooltip')}>
|
||||
<Segmented
|
||||
options={[
|
||||
{ value: 'listView', icon: <BarsOutlined /> },
|
||||
{ value: 'thumbnailView', icon: <AppstoreOutlined /> },
|
||||
]}
|
||||
defaultValue={'listView'}
|
||||
disabled={true}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Table<ITaskAttachmentViewModel>
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={attachments.data}
|
||||
columns={columns}
|
||||
rowKey={record => record.id || ''}
|
||||
loading={loading}
|
||||
pagination={{
|
||||
showSizeChanger: paginationConfig.showSizeChanger,
|
||||
defaultPageSize: paginationConfig.defaultPageSize,
|
||||
total: paginationConfig.total,
|
||||
current: paginationConfig.pageIndex,
|
||||
onChange: (page, pageSize) =>
|
||||
setPaginationConfig(prev => ({
|
||||
...prev,
|
||||
pageIndex: page,
|
||||
defaultPageSize: pageSize,
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewFiles;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Card, Flex, Typography } from 'antd';
|
||||
import TaskByMembersTable from './tables/tasks-by-members';
|
||||
|
||||
import MemberStats from '../member-stats/member-stats';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
const InsightsMembers = ({ t }: { t: TFunction }) => {
|
||||
return (
|
||||
<Flex vertical gap={24}>
|
||||
<MemberStats />
|
||||
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('members.tasksByMembers')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<TaskByMembersTable />
|
||||
</Card>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightsMembers;
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Flex, Tooltip, Typography } from 'antd';
|
||||
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { IInsightTasks } from '@/types/project/projectInsights.types';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
|
||||
interface AssignedTasksListTableProps {
|
||||
memberId: string;
|
||||
projectId: string;
|
||||
archived: boolean;
|
||||
}
|
||||
|
||||
const columnsList = [
|
||||
{ key: 'name', columnHeader: 'Name', width: 280 },
|
||||
{ key: 'status', columnHeader: 'Status', width: 100 },
|
||||
{ key: 'dueDate', columnHeader: 'Due Date', width: 150 },
|
||||
{ key: 'overdue', columnHeader: 'Days Overdue', width: 150 },
|
||||
{ key: 'completedDate', columnHeader: 'Completed Date', width: 150 },
|
||||
{ key: 'totalAllocation', columnHeader: 'Total Allocation', width: 150 },
|
||||
{ key: 'overLoggedTime', columnHeader: 'Over Logged Time', width: 150 },
|
||||
];
|
||||
|
||||
const AssignedTasksListTable: React.FC<AssignedTasksListTableProps> = ({
|
||||
memberId,
|
||||
projectId,
|
||||
archived,
|
||||
}) => {
|
||||
const [memberTasks, setMemberTasks] = useState<IInsightTasks[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
useEffect(() => {
|
||||
const getTasksByMemberId = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getMemberTasks({
|
||||
member_id: memberId,
|
||||
project_id: projectId,
|
||||
archived,
|
||||
});
|
||||
if (res.done) {
|
||||
setMemberTasks(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching member tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
getTasksByMemberId();
|
||||
}, [memberId, projectId, archived]);
|
||||
|
||||
const renderColumnContent = (key: string, task: IInsightTasks) => {
|
||||
switch (key) {
|
||||
case 'name':
|
||||
return (
|
||||
<Tooltip title={task.name}>
|
||||
<Typography.Text>{task.name}</Typography.Text>
|
||||
</Tooltip>
|
||||
);
|
||||
case 'status':
|
||||
return (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: task.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{task.status}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
);
|
||||
case 'dueDate':
|
||||
return task.end_date ? simpleDateFormat(task.end_date) : 'N/A';
|
||||
case 'overdue':
|
||||
return task.days_overdue ?? 'N/A';
|
||||
case 'completedDate':
|
||||
return task.completed_at ? simpleDateFormat(task.completed_at) : 'N/A';
|
||||
case 'totalAllocation':
|
||||
return task.total_minutes ?? 'N/A';
|
||||
case 'overLoggedTime':
|
||||
return task.overlogged_time ?? 'N/A';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-0 max-w-full overflow-x-auto py-2 pl-12 pr-4"
|
||||
style={{ backgroundColor: themeWiseColor('#f0f2f5', '#000', themeMode) }}
|
||||
>
|
||||
<table className="w-full min-w-max border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
{columnsList.map(column => (
|
||||
<th
|
||||
key={column.key}
|
||||
className="p-2 text-left"
|
||||
style={{ width: column.width, fontWeight: 500 }}
|
||||
>
|
||||
{column.columnHeader}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{memberTasks.map(task => (
|
||||
<tr key={task.id} className="h-[42px] border-t">
|
||||
{columnsList.map(column => (
|
||||
<td key={column.key} className="p-2" style={{ width: column.width }}>
|
||||
{renderColumnContent(column.key, task)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssignedTasksListTable;
|
||||
@@ -0,0 +1,161 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Flex, Progress } from 'antd';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { DownOutlined, ExclamationCircleOutlined, RightOutlined } from '@ant-design/icons';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { projectsApiService } from '@/api/projects/projects.api.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IProjectOverviewStats } from '@/types/project/projectsViewModel.types';
|
||||
import { ITeamMemberOverviewGetResponse } from '@/types/project/project-insights.types';
|
||||
import React from 'react';
|
||||
import AssignedTasksListTable from './assigned-tasks-list';
|
||||
|
||||
const TaskByMembersTable = () => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [expandedRows, setExpandedRows] = useState<string[]>([]);
|
||||
const [memberList, setMemberList] = useState<ITeamMemberOverviewGetResponse[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const { t } = useTranslation('project-view-insights');
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const getProjectOverviewMembers = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
const res = await projectsApiService.getOverViewMembersById(projectId);
|
||||
if (res.done) {
|
||||
setMemberList(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching member tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
setLoading(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getProjectOverviewMembers();
|
||||
}, [projectId,refreshTimestamp]);
|
||||
|
||||
// toggle members row expansions
|
||||
const toggleRowExpansion = (memberId: string) => {
|
||||
setExpandedRows(prev =>
|
||||
prev.includes(memberId) ? prev.filter(id => id !== memberId) : [...prev, memberId]
|
||||
);
|
||||
};
|
||||
|
||||
// columns list
|
||||
const columnsList = [
|
||||
{ key: 'name', columnHeader: t('members.name'), width: 200 },
|
||||
{ key: 'taskCount', columnHeader: t('members.taskCount'), width: 100 },
|
||||
{ key: 'contribution', columnHeader: t('members.contribution'), width: 120 },
|
||||
{ key: 'completed', columnHeader: t('members.completed'), width: 100 },
|
||||
{ key: 'incomplete', columnHeader: t('members.incomplete'), width: 100 },
|
||||
{ key: 'overdue', columnHeader: t('members.overdue'), width: 100 },
|
||||
{ key: 'progress', columnHeader: t('members.progress'), width: 150 },
|
||||
];
|
||||
|
||||
// render content, based on column type
|
||||
const renderColumnContent = (key: string, member: ITeamMemberOverviewGetResponse) => {
|
||||
switch (key) {
|
||||
case 'name':
|
||||
return (
|
||||
<Flex gap={8} align="center">
|
||||
{member?.task_count && (
|
||||
<button onClick={() => toggleRowExpansion(member.id)}>
|
||||
{expandedRows.includes(member.id) ? <DownOutlined /> : <RightOutlined />}
|
||||
</button>
|
||||
)}
|
||||
{member.overdue_task_count ? (
|
||||
<ExclamationCircleOutlined style={{ color: colors.vibrantOrange }} />
|
||||
) : (
|
||||
<div style={{ width: 14, height: 14 }}></div>
|
||||
)}
|
||||
{member.name}
|
||||
</Flex>
|
||||
);
|
||||
case 'taskCount':
|
||||
return member.task_count;
|
||||
case 'contribution':
|
||||
return `${member.contribution}%`;
|
||||
case 'completed':
|
||||
return member.done_task_count;
|
||||
case 'incomplete':
|
||||
return member.pending_task_count;
|
||||
case 'overdue':
|
||||
return member.overdue_task_count;
|
||||
case 'progress':
|
||||
return (
|
||||
<Progress
|
||||
percent={Math.floor(((member.done_task_count ?? 0) / (member.task_count ?? 1)) * 100)}
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="memberList-container min-h-0 max-w-full overflow-x-auto">
|
||||
<table className="w-full min-w-max border-collapse rounded">
|
||||
<thead
|
||||
style={{
|
||||
height: 42,
|
||||
backgroundColor: themeWiseColor('#f8f7f9', '#1d1d1d', themeMode),
|
||||
}}
|
||||
>
|
||||
<tr>
|
||||
{columnsList.map(column => (
|
||||
<th
|
||||
key={column.key}
|
||||
className={`p-2`}
|
||||
style={{ width: column.width, fontWeight: 500 }}
|
||||
>
|
||||
{column.columnHeader}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{memberList?.map(member => (
|
||||
<React.Fragment key={member.id}>
|
||||
<tr key={member.id} className="h-[42px] cursor-pointer">
|
||||
{columnsList.map(column => (
|
||||
<td
|
||||
key={column.key}
|
||||
className={`border-t p-2 text-center`}
|
||||
style={{
|
||||
width: column.width,
|
||||
}}
|
||||
>
|
||||
{renderColumnContent(column.key, member)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
|
||||
{expandedRows.includes(member.id) && (
|
||||
<tr>
|
||||
<td colSpan={columnsList.length}>
|
||||
<AssignedTasksListTable
|
||||
memberId={member.id}
|
||||
projectId={projectId}
|
||||
archived={includeArchivedTasks}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskByMembersTable;
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Bar } from 'react-chartjs-2';
|
||||
import { Chart, ArcElement, Tooltip, CategoryScale, LinearScale, BarElement } from 'chart.js';
|
||||
import { ChartOptions } from 'chart.js';
|
||||
import { Flex } from 'antd';
|
||||
import { ITaskPriorityCounts } from '@/types/project/project-insights.types';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { Spin } from 'antd/lib';
|
||||
|
||||
Chart.register(ArcElement, Tooltip, CategoryScale, LinearScale, BarElement);
|
||||
|
||||
const PriorityOverview = () => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [stats, setStats] = useState<ITaskPriorityCounts[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getTaskPriorityCounts = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getPriorityOverview(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setStats(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching task priority counts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getTaskPriorityCounts();
|
||||
}, [projectId, includeArchivedTasks, refreshTimestamp]);
|
||||
|
||||
const options: ChartOptions<'bar'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
scales: {
|
||||
x: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Priority',
|
||||
align: 'end',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(200, 200, 200, 0.5)',
|
||||
},
|
||||
},
|
||||
y: {
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Task Count',
|
||||
align: 'end',
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(200, 200, 200, 0.5)',
|
||||
},
|
||||
beginAtZero: true,
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: false,
|
||||
position: 'top' as const,
|
||||
},
|
||||
datalabels: {
|
||||
display: false,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const data = {
|
||||
labels: stats.map(stat => stat.name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Tasks',
|
||||
data: stats.map(stat => stat.data),
|
||||
backgroundColor: stats.map(stat => stat.color),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockPriorityData = {
|
||||
labels: ['Low', 'Medium', 'High'],
|
||||
datasets: [
|
||||
{
|
||||
label: 'Tasks',
|
||||
data: [6, 12, 2],
|
||||
backgroundColor: ['#75c997', '#fbc84c', '#f37070'],
|
||||
hoverBackgroundColor: ['#46d980', '#ffc227', '#ff4141'],
|
||||
},
|
||||
],
|
||||
};
|
||||
if (loading) {
|
||||
return (
|
||||
<Flex justify="center" align="center" style={{ height: 350 }}>
|
||||
<Spin size="large" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex justify="center">
|
||||
{loading && <Spin />}
|
||||
<Bar options={options} data={data} className="h-[350px] w-full md:max-w-[580px]" />
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriorityOverview;
|
||||
@@ -0,0 +1,107 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Doughnut } from 'react-chartjs-2';
|
||||
import { Chart, ArcElement } from 'chart.js';
|
||||
import { Badge, Flex, Tooltip, Typography, Spin } from 'antd';
|
||||
import { ChartOptions } from 'chart.js';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { ITaskStatusCounts } from '@/types/project/project-insights.types';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
Chart.register(ArcElement);
|
||||
|
||||
const StatusOverview = () => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [stats, setStats] = useState<ITaskStatusCounts[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getTaskStatusCounts = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getTaskStatusCounts(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setStats(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching task status counts:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getTaskStatusCounts();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
const options: ChartOptions<'doughnut'> = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
datalabels: {
|
||||
display: false,
|
||||
},
|
||||
legend: {
|
||||
display: false,
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: context => {
|
||||
const value = context.raw as number;
|
||||
return `${context.label}: ${value} task${value !== 1 ? 's' : ''}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const data = {
|
||||
labels: stats.map(status => status.name),
|
||||
datasets: [
|
||||
{
|
||||
label: 'Tasks',
|
||||
data: stats.map(status => status.y),
|
||||
backgroundColor: stats.map(status => status.color),
|
||||
hoverBackgroundColor: stats.map(status => status.color + '90'),
|
||||
borderWidth: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Flex justify="center" align="center" style={{ height: 350 }}>
|
||||
<Spin size="large" />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Flex gap={24} wrap="wrap-reverse" justify="center">
|
||||
{loading && <Spin />}
|
||||
<div style={{ position: 'relative', height: 350, width: '100%', maxWidth: 350 }}>
|
||||
<Doughnut options={options} data={data} />
|
||||
</div>
|
||||
|
||||
<Flex gap={12} style={{ marginBlockStart: 12 }} wrap="wrap" className="flex-row xl:flex-col">
|
||||
{stats.map(status => (
|
||||
<Flex key={status.name} gap={8} align="center">
|
||||
<Badge color={status.color} />
|
||||
<Typography.Text>
|
||||
{status.name}
|
||||
<span style={{ marginLeft: 4 }}>({status.y})</span>
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
))}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusOverview;
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Button, Card, Flex, Typography } from 'antd';
|
||||
|
||||
import StatusOverview from './graphs/status-overview';
|
||||
import PriorityOverview from './graphs/priority-overview';
|
||||
import LastUpdatedTasks from './tables/last-updated-tasks';
|
||||
import ProjectDeadline from './tables/project-deadline';
|
||||
import ProjectStats from '../project-stats/project-stats';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
const InsightsOverview = ({ t }: { t: TFunction }) => {
|
||||
return (
|
||||
<Flex vertical gap={24}>
|
||||
<ProjectStats t={t} />
|
||||
|
||||
<Flex gap={24} className="grid md:grid-cols-2">
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('overview.statusOverview')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<StatusOverview />
|
||||
</Card>
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('overview.priorityOverview')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<PriorityOverview />
|
||||
</Card>
|
||||
</Flex>
|
||||
|
||||
<Flex gap={24} className="grid lg:grid-cols-2">
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('overview.lastUpdatedTasks')}
|
||||
</Typography.Text>
|
||||
}
|
||||
extra={<Button type="link">{t('common.seeAll')}</Button>}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<LastUpdatedTasks />
|
||||
</Card>
|
||||
|
||||
<ProjectDeadline />
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightsOverview;
|
||||
@@ -0,0 +1,128 @@
|
||||
import { Flex, Table, Tooltip, Typography } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { TableProps } from 'antd/lib';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { IInsightTasks } from '@/types/project/projectInsights.types';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
|
||||
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
|
||||
|
||||
const LastUpdatedTasks = () => {
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [data, setData] = useState<IInsightTasks[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getLastUpdatedTasks = async () => {
|
||||
if (!projectId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getLastUpdatedTasks(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setData(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('getLastUpdatedTasks', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getLastUpdatedTasks();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: record.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{record.status}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dueDate',
|
||||
title: 'Due Date',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>
|
||||
{record.end_date ? simpleDateFormat(record.end_date) : 'N/A'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lastUpdated',
|
||||
title: 'Last Updated',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Tooltip title={record.updated_at ? formatDateTimeWithLocale(record.updated_at) : 'N/A'}>
|
||||
<Typography.Text>
|
||||
{record.updated_at ? calculateTimeDifference(record.updated_at) : 'N/A'}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const dataSource = data.map(record => ({
|
||||
...record,
|
||||
key: record.id,
|
||||
}));
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={dataSource}
|
||||
columns={columns}
|
||||
rowKey={record => record.id}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 20,
|
||||
}}
|
||||
loading={loading}
|
||||
onRow={() => {
|
||||
return {
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default LastUpdatedTasks;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Card, Flex, Skeleton, Table, Typography } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { TableProps } from 'antd/lib';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { IDeadlineTaskStats, IInsightTasks } from '@/types/project/project-insights.types';
|
||||
import ProjectStatsCard from '@/components/projects/project-stats-card';
|
||||
import warningIcon from '@assets/icons/insightsIcons/warning.png';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const ProjectDeadline = () => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [data, setData] = useState<IDeadlineTaskStats | null>(null);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const getProjectDeadline = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await projectInsightsApiService.getProjectDeadlineStats(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setData(res.body);
|
||||
}
|
||||
} catch {
|
||||
logger.error('Error fetching project deadline stats', { projectId, includeArchivedTasks });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getProjectDeadline();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: record.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{record.status}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dueDate',
|
||||
title: 'Due Date',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>
|
||||
{record.end_date ? simpleDateFormat(record.end_date) : 'N/A'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
Project Deadline <span style={{ color: colors.lightGray }}>{data?.project_end_date}</span>
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<Flex vertical gap={24}>
|
||||
<Flex gap={12} style={{ width: '100%' }}>
|
||||
<Skeleton active loading={loading}>
|
||||
<ProjectStatsCard
|
||||
icon={warningIcon}
|
||||
title="Overdue tasks (hours)"
|
||||
tooltip={'Tasks that has time logged past the end date of the project'}
|
||||
children={data?.deadline_logged_hours_string || 'N/A'}
|
||||
/>
|
||||
<ProjectStatsCard
|
||||
icon={warningIcon}
|
||||
title="Overdue tasks"
|
||||
tooltip={'Tasks that are past the end date of the project'}
|
||||
children={data?.deadline_tasks_count || 'N/A'}
|
||||
/>
|
||||
</Skeleton>
|
||||
</Flex>
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={data?.tasks}
|
||||
columns={columns}
|
||||
rowKey={record => record.taskId}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 20,
|
||||
}}
|
||||
onRow={record => {
|
||||
return {
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDeadline;
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Button, Card, Flex, Tooltip, Typography } from 'antd';
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import { colors } from '@/styles/colors';
|
||||
import OverdueTasksTable from './tables/overdue-tasks-table';
|
||||
import OverLoggedTasksTable from './tables/over-logged-tasks-table';
|
||||
import TaskCompletedEarlyTable from './tables/task-completed-early-table';
|
||||
import TaskCompletedLateTable from './tables/task-completed-late-table';
|
||||
import ProjectStats from '../project-stats/project-stats';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const InsightsTasks = ({ t }: { t: TFunction }) => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={24}>
|
||||
<ProjectStats t={t} />
|
||||
|
||||
<Flex gap={24} className="grid lg:grid-cols-2">
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('tasks.overdueTasks')}
|
||||
<Tooltip title={t('tasks.overdueTasksTooltip')}>
|
||||
<ExclamationCircleOutlined
|
||||
style={{
|
||||
color: colors.skyBlue,
|
||||
fontSize: 13,
|
||||
marginInlineStart: 4,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
}
|
||||
extra={<Button type="link">{t('common.seeAll')}</Button>}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<OverdueTasksTable projectId={projectId} includeArchivedTasks={includeArchivedTasks} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('tasks.overLoggedTasks')}
|
||||
<Tooltip title={t('tasks.overLoggedTasksTooltip')}>
|
||||
<ExclamationCircleOutlined
|
||||
style={{
|
||||
color: colors.skyBlue,
|
||||
fontSize: 13,
|
||||
marginInlineStart: 4,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
}
|
||||
extra={<Button type="link">{t('common.seeAll')}</Button>}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<OverLoggedTasksTable projectId={projectId} includeArchivedTasks={includeArchivedTasks} />
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('tasks.tasksCompletedEarly')}
|
||||
</Typography.Text>
|
||||
}
|
||||
extra={<Button type="link">{t('common.seeAll')}</Button>}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<TaskCompletedEarlyTable
|
||||
projectId={projectId}
|
||||
includeArchivedTasks={includeArchivedTasks}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
title={
|
||||
<Typography.Text style={{ fontSize: 16, fontWeight: 500 }}>
|
||||
{t('tasks.tasksCompletedLate')}
|
||||
</Typography.Text>
|
||||
}
|
||||
extra={<Button type="link">{t('common.seeAll')}</Button>}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
<TaskCompletedLateTable
|
||||
projectId={projectId}
|
||||
includeArchivedTasks={includeArchivedTasks}
|
||||
/>
|
||||
</Card>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default InsightsTasks;
|
||||
@@ -0,0 +1,137 @@
|
||||
import { Avatar, Button, Flex, Table, Typography } from 'antd';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { TableProps } from 'antd/lib';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { IInsightTasks } from '@/types/project/projectInsights.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
const OverLoggedTasksTable = () => {
|
||||
const { includeArchivedTasks, projectId } = useAppSelector(state => state.projectInsightsReducer);
|
||||
|
||||
const [overLoggedTaskList, setOverLoggedTaskList] = useState<IInsightTasks[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getOverLoggedTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await projectInsightsApiService.getOverloggedTasks(
|
||||
projectId,
|
||||
includeArchivedTasks
|
||||
);
|
||||
if (res.done) {
|
||||
setOverLoggedTaskList(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching over logged tasks', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getOverLoggedTasks();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: record.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{record.status_name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'members',
|
||||
title: 'Members',
|
||||
render: (record: IInsightTasks) =>
|
||||
record.status_name ? (
|
||||
<Avatar.Group>
|
||||
{/* {record.names.map((member) => (
|
||||
<CustomAvatar avatarName={member.memberName} size={26} />
|
||||
))} */}
|
||||
</Avatar.Group>
|
||||
) : (
|
||||
<Button
|
||||
disabled
|
||||
type="dashed"
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={
|
||||
<PlusOutlined
|
||||
style={{
|
||||
fontSize: 12,
|
||||
width: 22,
|
||||
height: 22,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'overLoggedTime',
|
||||
title: 'Over Logged Time',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>{record.overlogged_time}</Typography.Text>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={overLoggedTaskList}
|
||||
columns={columns}
|
||||
rowKey={record => record.taskId}
|
||||
pagination={{
|
||||
showSizeChanger: false,
|
||||
defaultPageSize: 10,
|
||||
}}
|
||||
loading={loading}
|
||||
onRow={record => {
|
||||
return {
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverLoggedTasksTable;
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Flex, Table, Typography } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { TableProps } from 'antd/lib';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { IInsightTasks } from '@/types/project/projectInsights.types';
|
||||
import { projectInsightsApiService } from '@/api/projects/insights/project-insights.api.service';
|
||||
|
||||
const OverdueTasksTable = ({
|
||||
projectId,
|
||||
includeArchivedTasks,
|
||||
}: {
|
||||
projectId: string;
|
||||
includeArchivedTasks: boolean;
|
||||
}) => {
|
||||
const [overdueTaskList, setOverdueTaskList] = useState<IInsightTasks[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { refreshTimestamp } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
|
||||
const getOverdueTasks = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await projectInsightsApiService.getOverdueTasks(projectId, includeArchivedTasks);
|
||||
if (res.done) {
|
||||
setOverdueTaskList(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching overdue tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getOverdueTasks();
|
||||
}, [projectId, includeArchivedTasks,refreshTimestamp]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
title: 'Status',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Flex
|
||||
gap={4}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 6,
|
||||
backgroundColor: record.status_color,
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{record.status_name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'dueDate',
|
||||
title: 'End Date',
|
||||
render: (record: IInsightTasks) => (
|
||||
<Typography.Text>
|
||||
{record.end_date ? simpleDateFormat(record.end_date) : 'N/A'}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'daysOverdue',
|
||||
title: 'Days overdue',
|
||||
render: (record: IInsightTasks) => <Typography.Text>{record.days_overdue}</Typography.Text>,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={overdueTaskList}
|
||||
columns={columns}
|
||||
rowKey={record => record.taskId}
|
||||
pagination={{
|
||||
showSizeChanger: false,
|
||||
defaultPageSize: 10,
|
||||
}}
|
||||
loading={loading}
|
||||
onRow={record => {
|
||||
return {
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default OverdueTasksTable;
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user