This commit is contained in:
chamikaJ
2025-04-17 18:28:54 +05:30
parent f583291d8a
commit 8825b0410a
2837 changed files with 241385 additions and 127578 deletions

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

View 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;
}
}

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

View File

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

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

View File

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

View File

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

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

View File

@@ -0,0 +1,3 @@
.admin-center-sidebar-button {
border-radius: 0.5rem !important;
}

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

View File

@@ -0,0 +1,7 @@
.team-table-row .row-buttons {
visibility: hidden;
}
.team-table-row:hover .row-buttons {
visibility: visible;
}

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

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

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

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

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

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

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

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

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

View File

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

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

View File

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

View 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;
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,7 @@
import React from 'react';
const ProjectViewWorkload = () => {
return <div>ProjectViewWorkload</div>;
};
export default ProjectViewWorkload;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 />
&nbsp;
<Typography.Text>{t('assignToMe')}</Typography.Text>
</span>
),
key: '1',
onClick: handleAssignToMe,
disabled: updatingAssignToMe,
},
{
label: (
<span>
<InboxOutlined />
&nbsp;
<Typography.Text>{t('archive')}</Typography.Text>
</span>
),
key: '2',
onClick: handleArchive,
},
{
label: (
<Popconfirm
title={t('deleteConfirmationTitle')}
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText={t('deleteConfirmationOk')}
cancelText={t('deleteConfirmationCancel')}
onConfirm={handleDelete}
>
<DeleteOutlined />
&nbsp;
{t('delete')}
</Popconfirm>
),
key: '3',
},
], [t, handleAssignToMe, handleArchive, handleDelete, updatingAssignToMe]);
const priorityIcon = useMemo(() => {
if (task.priority_value === 0) {
return (
<MinusOutlined
style={{
color: '#52c41a',
marginRight: '0.25rem',
}}
/>
);
} else if (task.priority_value === 1) {
return (
<PauseOutlined
style={{
color: '#faad14',
transform: 'rotate(90deg)',
marginRight: '0.25rem',
}}
/>
);
} else {
return (
<DoubleRightOutlined
style={{
color: '#f5222d',
transform: 'rotate(-90deg)',
marginRight: '0.25rem',
}}
/>
);
}
}, [task.priority_value]);
const 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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