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,27 @@
import { Flex, Typography } from 'antd';
import logo from '../assets/images/logo.png';
import logoDark from '@/assets/images/logo-dark-mode.png';
import { useAppSelector } from '@/hooks/useAppSelector';
type AuthPageHeaderProp = {
description: string;
};
// this page header used in only in auth pages
const AuthPageHeader = ({ description }: AuthPageHeaderProp) => {
const themeMode = useAppSelector(state => state.themeReducer.mode);
return (
<Flex vertical align="center" gap={8} style={{ marginBottom: 24 }}>
<img
src={themeMode === 'dark' ? logoDark : logo}
alt="worklenz logo"
style={{ width: '100%', maxWidth: 220 }}
/>
<Typography.Text style={{ color: '#8c8c8c', maxWidth: 400, textAlign: 'center' }}>
{description}
</Typography.Text>
</Flex>
);
};
export default AuthPageHeader;

View File

@@ -0,0 +1,25 @@
import Tooltip from 'antd/es/tooltip';
import Avatar from 'antd/es/avatar';
import { AvatarNamesMap } from '../shared/constants';
const CustomAvatar = ({ avatarName, size = 32 }: { avatarName: string; size?: number }) => {
const avatarCharacter = avatarName[0].toUpperCase();
return (
<Tooltip title={avatarName}>
<Avatar
style={{
backgroundColor: AvatarNamesMap[avatarCharacter],
verticalAlign: 'middle',
width: size,
height: size,
}}
>
{avatarCharacter}
</Avatar>
</Tooltip>
);
};
export default CustomAvatar;

View File

@@ -0,0 +1,37 @@
import { SearchOutlined } from '@ant-design/icons';
import { Input } from 'antd';
type CustomSearchbarProps = {
placeholderText: string;
searchQuery: string;
setSearchQuery: (searchText: string) => void;
};
const CustomSearchbar = ({
placeholderText,
searchQuery,
setSearchQuery,
}: CustomSearchbarProps) => {
return (
<div style={{ position: 'relative', width: 240 }}>
<Input
placeholder={placeholderText}
value={searchQuery}
onChange={e => setSearchQuery(e.currentTarget.value)}
style={{ padding: '4px 24px 4px 11px' }}
/>
<span
style={{
position: 'absolute',
top: '50%',
right: 6,
transform: 'translateY(-50%)',
}}
>
<SearchOutlined style={{ fontSize: 14 }} />
</span>
</div>
);
};
export default CustomSearchbar;

View File

@@ -0,0 +1,19 @@
import { Flex, Tooltip, Typography } from 'antd';
import { colors } from '../styles/colors';
import { ExclamationCircleOutlined } from '@ant-design/icons';
// this custom table title used when the typography font weigh 500 needed
const CustomTableTitle = ({ title, tooltip }: { title: string; tooltip?: string | null }) => {
return (
<Flex gap={8} align="center">
<Typography.Text style={{ fontWeight: 500, textAlign: 'center' }}>{title}</Typography.Text>
{tooltip && (
<Tooltip title={tooltip} trigger={'hover'}>
<ExclamationCircleOutlined style={{ color: colors.skyBlue }} />
</Tooltip>
)}
</Flex>
);
};
export default CustomTableTitle;

View File

@@ -0,0 +1,30 @@
import { Empty, Typography } from 'antd';
import React from 'react';
type EmptyListPlaceholderProps = {
imageSrc?: string;
imageHeight?: number;
text: string;
};
const EmptyListPlaceholder = ({
imageSrc = 'https://app.worklenz.com/assets/images/empty-box.webp',
imageHeight = 60,
text,
}: EmptyListPlaceholderProps) => {
return (
<Empty
image={imageSrc}
imageStyle={{ height: imageHeight }}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBlockStart: 24,
}}
description={<Typography.Text type="secondary">{text}</Typography.Text>}
/>
);
};
export default EmptyListPlaceholder;

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { Button, Result } from 'antd';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import logger from '@/utils/errorLogger';
interface Props {
children: React.ReactNode;
}
interface State {
hasError: boolean;
error?: Error;
}
class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
logger.error('Error caught by ErrorBoundary:', {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack
});
console.error('Error caught by ErrorBoundary:', error);
}
render() {
if (this.state.hasError) {
return <ErrorFallback error={this.state.error} />;
}
return this.props.children;
}
}
const ErrorFallback: React.FC<{ error?: Error }> = ({ error }) => {
const { t } = useTranslation();
const navigate = useNavigate();
const handleRetry = () => {
window.location.reload();
};
const handleGoHome = () => {
navigate('/worklenz/home');
window.location.reload();
};
return (
<Result
status="error"
title={t('error.somethingWentWrong', 'Something went wrong')}
extra={[
<Button key="retry" type="primary" onClick={handleRetry}>
{t('error.retry', 'Try Again')}
</Button>,
<Button key="home" onClick={handleGoHome}>
{t('error.goHome', 'Go Home')}
</Button>,
]}
/>
);
};
export default ErrorBoundary;

View File

@@ -0,0 +1,62 @@
import React, { useState } from 'react';
import { getJSONFromLocalStorage, saveJSONToLocalStorage } from '../utils/localStorageFunctions';
import { Button, ConfigProvider, Tooltip } from 'antd';
import { PushpinFilled, PushpinOutlined } from '@ant-design/icons';
import { colors } from '../styles/colors';
import { navRoutes, NavRoutesType } from '../features/navbar/navRoutes';
// this component pin the given path to navbar
const PinRouteToNavbarButton = ({ name, path }: NavRoutesType) => {
const navRoutesList: NavRoutesType[] = getJSONFromLocalStorage('navRoutes') || navRoutes;
const [isPinned, setIsPinned] = useState(
// this function check the current name is available in local storage's navRoutes list if it's available then isPinned state will be true
navRoutesList.filter(item => item.name === name).length && true
);
// this function handle pin to the navbar
const handlePinToNavbar = (name: string, path: string) => {
let newNavRoutesList;
const route: NavRoutesType = { name, path };
if (isPinned) {
newNavRoutesList = navRoutesList.filter(item => item.name !== route.name);
} else {
newNavRoutesList = [...navRoutesList, route];
}
setIsPinned(prev => !prev);
saveJSONToLocalStorage('navRoutes', newNavRoutesList);
};
return (
<ConfigProvider wave={{ disabled: true }}>
<Tooltip title={'Click to pin this into the main menu'} trigger={'hover'}>
<Button
className="borderless-icon-btn"
onClick={() => handlePinToNavbar(name, path)}
icon={
isPinned ? (
<PushpinFilled
style={{
fontSize: 18,
color: colors.skyBlue,
}}
/>
) : (
<PushpinOutlined
style={{
fontSize: 18,
color: colors.skyBlue,
}}
/>
)
}
/>
</Tooltip>
</ConfigProvider>
);
};
export default PinRouteToNavbarButton;

View File

@@ -0,0 +1,28 @@
import { FloatButton, Space, Tooltip } from 'antd';
import { FormatPainterOutlined } from '@ant-design/icons';
import LanguageSelector from '../features/i18n/language-selector';
import ThemeSelector from '../features/theme/ThemeSelector';
const PreferenceSelector = () => {
return (
<Tooltip title="Preferences" placement="leftBottom">
<div>
<FloatButton.Group trigger="click" icon={<FormatPainterOutlined />}>
<Space
direction="vertical"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}}
>
<ThemeSelector />
</Space>
</FloatButton.Group>
</div>
</Tooltip>
);
};
export default PreferenceSelector;

View File

@@ -0,0 +1,50 @@
import { useEffect } from 'react';
// Add TypeScript declarations for Tawk_API
declare global {
interface Window {
Tawk_API?: any;
Tawk_LoadStart?: Date;
}
}
interface TawkToProps {
propertyId: string;
widgetId: string;
}
const TawkTo: React.FC<TawkToProps> = ({ propertyId, widgetId }) => {
useEffect(() => {
// Initialize tawk.to chat
const s1 = document.createElement('script');
s1.async = true;
s1.src = `https://embed.tawk.to/${propertyId}/${widgetId}`;
s1.setAttribute('crossorigin', '*');
const s0 = document.getElementsByTagName('script')[0];
s0.parentNode?.insertBefore(s1, s0);
return () => {
// Clean up when the component unmounts
// Remove the script tag
const tawkScript = document.querySelector(`script[src*="tawk.to/${propertyId}"]`);
if (tawkScript && tawkScript.parentNode) {
tawkScript.parentNode.removeChild(tawkScript);
}
// Remove the tawk.to iframe
const tawkIframe = document.getElementById('tawk-iframe');
if (tawkIframe) {
tawkIframe.remove();
}
// Reset Tawk globals
delete window.Tawk_API;
delete window.Tawk_LoadStart;
};
}, [propertyId, widgetId]);
return null;
};
export default TawkTo;

View File

@@ -0,0 +1,19 @@
@media (max-width: 1000px) {
.step-content,
.step-form,
.create-first-task-form,
.setup-action-buttons,
.invite-members-form {
width: 400px !important;
}
}
@media (max-width: 500px) {
.step-content,
.step-form,
.create-first-task-form,
.setup-action-buttons,
.invite-members-form {
width: 200px !important;
}
}

View File

@@ -0,0 +1,182 @@
import React, { useEffect, useRef } from 'react';
import { Form, Input, Button, List, Alert, message, InputRef } from 'antd';
import { CloseCircleOutlined, MailOutlined, PlusOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { Typography } from 'antd';
import { setTeamMembers, setTasks } from '@/features/account-setup/account-setup.slice';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from '@/app/store';
import { validateEmail } from '@/utils/validateEmail';
import { sanitizeInput } from '@/utils/sanitizeInput';
import { Rule } from 'antd/es/form';
const { Title } = Typography;
interface Email {
id: number;
value: string;
}
interface MembersStepProps {
isDarkMode: boolean;
styles: any;
}
const MembersStep: React.FC<MembersStepProps> = ({ isDarkMode, styles }) => {
const { t } = useTranslation('account-setup');
const { teamMembers, organizationName } = useSelector(
(state: RootState) => state.accountSetupReducer
);
const inputRefs = useRef<(InputRef | null)[]>([]);
const dispatch = useDispatch();
const [form] = Form.useForm();
const addEmail = () => {
if (teamMembers.length == 5) return;
const newId = teamMembers.length > 0 ? Math.max(...teamMembers.map(t => t.id)) + 1 : 0;
dispatch(setTeamMembers([...teamMembers, { id: newId, value: '' }]));
setTimeout(() => {
inputRefs.current[newId]?.focus();
}, 0);
};
const removeEmail = (id: number) => {
if (teamMembers.length > 1) {
dispatch(setTeamMembers(teamMembers.filter(teamMember => teamMember.id !== id)));
}
};
const updateEmail = (id: number, value: string) => {
const sanitizedValue = sanitizeInput(value);
dispatch(
setTeamMembers(
teamMembers.map(teamMember =>
teamMember.id === id ? { ...teamMember, value: sanitizedValue } : teamMember
)
)
);
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
const input = e.currentTarget as HTMLInputElement;
if (!input.value.trim()) return;
e.preventDefault();
addEmail();
};
// Function to set ref that doesn't return anything (void)
const setInputRef = (index: number) => (el: InputRef | null) => {
inputRefs.current[index] = el;
};
useEffect(() => {
setTimeout(() => {
inputRefs.current[teamMembers.length - 1]?.focus();
// Set initial form values
const initialValues: Record<string, string> = {};
teamMembers.forEach(teamMember => {
initialValues[`email-${teamMember.id}`] = teamMember.value;
});
form.setFieldsValue(initialValues);
}, 200);
}, []);
const formRules = {
email: [
{
validator: async (_: any, value: string) => {
if (!value) return;
if (!validateEmail(value)) {
throw new Error(t('invalidEmail'));
}
},
},
],
};
return (
<Form
form={form}
className="invite-members-form"
style={{
minHeight: '300px',
width: '600px',
paddingBottom: '1rem',
marginBottom: '3rem',
marginTop: '3rem',
display: 'flex',
flexDirection: 'column',
}}
>
<Form.Item>
<Title level={2} style={{ marginBottom: '1rem' }}>
{t('step3Title')} "<mark>{organizationName}</mark>".
</Title>
</Form.Item>
<Form.Item
layout="vertical"
rules={[{ required: true }]}
label={
<span className="font-medium">
{t('step3InputLabel')}&nbsp; <MailOutlined /> {t('maxMembers')}
</span>
}
>
<List
dataSource={teamMembers}
bordered={false}
itemLayout="vertical"
renderItem={(teamMember, index) => (
<List.Item key={teamMember.id}>
<div className="invite-members-form" style={{ display: 'flex', width: '600px' }}>
<Form.Item
rules={formRules.email as Rule[]}
className="w-full"
validateTrigger={['onChange', 'onBlur']}
name={`email-${teamMember.id}`}
>
<Input
placeholder={t('emailPlaceholder')}
value={teamMember.value}
onChange={e => updateEmail(teamMember.id, e.target.value)}
onPressEnter={handleKeyPress}
ref={setInputRef(index)}
status={teamMember.value && !validateEmail(teamMember.value) ? 'error' : ''}
id={`member-${index}`}
/>
</Form.Item>
<Button
className="custom-close-button"
style={{ marginLeft: '48px' }}
type="text"
icon={<CloseCircleOutlined />}
disabled={teamMembers.length === 1}
onClick={() => removeEmail(teamMember.id)}
/>
</div>
</List.Item>
)}
/>
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={addEmail}
style={{ marginTop: '16px' }}
disabled={teamMembers.length == 5}
>
{t('tasksStepAddAnother')}
</Button>
<div
style={{
marginTop: '24px',
display: 'flex',
justifyContent: 'space-between',
}}
></div>
</Form.Item>
</Form>
);
};
export default MembersStep;

View File

@@ -0,0 +1,64 @@
import React, { useEffect, useRef } from 'react';
import { Form, Input, InputRef, Typography } from 'antd';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { setOrganizationName } from '@/features/account-setup/account-setup.slice';
import { RootState } from '@/app/store';
import { sanitizeInput } from '@/utils/sanitizeInput';
import './admin-center-common.css';
const { Title } = Typography;
interface Props {
onEnter: () => void;
styles: any;
organizationNamePlaceholder: string;
}
export const OrganizationStep: React.FC<Props> = ({
onEnter,
styles,
organizationNamePlaceholder,
}) => {
const { t } = useTranslation('account-setup');
const dispatch = useDispatch();
const { organizationName } = useSelector((state: RootState) => state.accountSetupReducer);
const inputRef = useRef<InputRef>(null);
useEffect(() => {
setTimeout(() => inputRef.current?.focus(), 300);
}, []);
const onPressEnter = () => {
if (!organizationName.trim()) return;
onEnter();
};
const handleOrgNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const sanitizedValue = sanitizeInput(e.target.value);
dispatch(setOrganizationName(sanitizedValue));
};
return (
<Form className="step-form" style={styles.form}>
<Form.Item>
<Title level={2} style={{ marginBottom: '1rem' }}>
{t('organizationStepTitle')}
</Title>
</Form.Item>
<Form.Item
layout="vertical"
rules={[{ required: true }]}
label={<span style={styles.label}>{t('organizationStepLabel')}</span>}
>
<Input
placeholder={organizationNamePlaceholder}
value={organizationName}
onChange={handleOrgNameChange}
onPressEnter={onPressEnter}
ref={inputRef}
/>
</Form.Item>
</Form>
);
};

View File

@@ -0,0 +1,155 @@
import React, { startTransition, useEffect, useRef, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { Button, Drawer, Form, Input, InputRef, Select, Typography } from 'antd';
import TemplateDrawer from '../common/template-drawer/template-drawer';
import { RootState } from '@/app/store';
import { setProjectName, setTemplateId } from '@/features/account-setup/account-setup.slice';
import { sanitizeInput } from '@/utils/sanitizeInput';
import { projectTemplatesApiService } from '@/api/project-templates/project-templates.api.service';
import logger from '@/utils/errorLogger';
import { IAccountSetupRequest } from '@/types/project-templates/project-templates.types';
import { evt_account_setup_template_complete } from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import { createPortal } from 'react-dom';
const { Title } = Typography;
interface Props {
onEnter: () => void;
styles: any;
isDarkMode: boolean;
}
export const ProjectStep: React.FC<Props> = ({ onEnter, styles, isDarkMode = false }) => {
const { t } = useTranslation('account-setup');
const dispatch = useDispatch();
const navigate = useNavigate();
const { trackMixpanelEvent } = useMixpanelTracking();
const inputRef = useRef<InputRef>(null);
useEffect(() => {
setTimeout(() => inputRef.current?.focus(), 200);
}, []);
const { projectName, templateId, organizationName } = useSelector(
(state: RootState) => state.accountSetupReducer
);
const [open, setOpen] = useState(false);
const [creatingFromTemplate, setCreatingFromTemplate] = useState(false);
const handleTemplateSelected = (templateId: string) => {
if (!templateId) return;
dispatch(setTemplateId(templateId));
};
const toggleTemplateSelector = (isOpen: boolean) => {
startTransition(() => setOpen(isOpen));
};
const createFromTemplate = async () => {
setCreatingFromTemplate(true);
if (!templateId) return;
try {
const model: IAccountSetupRequest = {
team_name: organizationName,
project_name: null,
template_id: templateId || null,
tasks: [],
team_members: [],
};
const res = await projectTemplatesApiService.setupAccount(model);
if (res.done && res.body.id) {
toggleTemplateSelector(false);
trackMixpanelEvent(evt_account_setup_template_complete);
navigate(`/worklenz/projects/${res.body.id}?tab=tasks-list&pinned_tab=tasks-list`);
}
} catch (error) {
logger.error('createFromTemplate', error);
}
};
const onPressEnter = () => {
if (!projectName.trim()) return;
onEnter();
};
const handleProjectNameChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const sanitizedValue = sanitizeInput(e.target.value);
dispatch(setProjectName(sanitizedValue));
};
return (
<div>
<Form className="step-form" style={styles.form}>
<Form.Item>
<Title level={2} style={{ marginBottom: '1rem' }}>
{t('projectStepTitle')}
</Title>
</Form.Item>
<Form.Item
layout="vertical"
rules={[{ required: true }]}
label={<span style={styles.label}>{t('projectStepLabel')}</span>}
>
<Input
placeholder={t('projectStepPlaceholder')}
value={projectName}
onChange={handleProjectNameChange}
onPressEnter={onPressEnter}
ref={inputRef}
/>
</Form.Item>
</Form>
<div style={{ position: 'relative' }}>
<Title level={4} className={isDarkMode ? 'vert-text-dark' : 'vert-text'}>
{t('or')}
</Title>
<div className={isDarkMode ? 'vert-line-dark' : 'vert-line'} />
</div>
<div className="flex justify-center">
<Button onClick={() => toggleTemplateSelector(true)} type="primary">
{t('templateButton')}
</Button>
</div>
{createPortal(
<Drawer
title={t('templateDrawerTitle')}
width={1000}
onClose={() => toggleTemplateSelector(false)}
open={open}
footer={
<div style={styles.drawerFooter}>
<Button style={{ marginRight: '8px' }} onClick={() => toggleTemplateSelector(false)}>
{t('cancel')}
</Button>
<Button
type="primary"
onClick={() => createFromTemplate()}
loading={creatingFromTemplate}
>
{t('create')}
</Button>
</div>
}
>
<TemplateDrawer
showBothTabs={false}
templateSelected={handleTemplateSelected}
selectedTemplateType={() => {}}
/>
</Drawer>,
document.body,
'template-drawer'
)}
</div>
);
};

View File

@@ -0,0 +1,132 @@
import React, { useEffect, useRef } from 'react';
import { Form, Input, Button, Typography, List, InputRef } from 'antd';
import { PlusOutlined, DeleteOutlined, CloseCircleOutlined } from '@ant-design/icons';
import { useDispatch, useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { RootState } from '@/app/store';
import { setTasks } from '@/features/account-setup/account-setup.slice';
import { sanitizeInput } from '@/utils/sanitizeInput';
const { Title } = Typography;
interface Props {
onEnter: () => void;
styles: any;
isDarkMode: boolean;
}
export const TasksStep: React.FC<Props> = ({ onEnter, styles, isDarkMode }) => {
const { t } = useTranslation('account-setup');
const dispatch = useDispatch();
const { tasks, projectName } = useSelector((state: RootState) => state.accountSetupReducer);
const inputRefs = useRef<(InputRef | null)[]>([]);
const addTask = () => {
if (tasks.length == 5) return;
const newId = tasks.length > 0 ? Math.max(...tasks.map(t => t.id)) + 1 : 0;
dispatch(setTasks([...tasks, { id: newId, value: '' }]));
setTimeout(() => {
inputRefs.current[newId]?.focus();
}, 0);
};
const removeTask = (id: number) => {
if (tasks.length > 1) {
dispatch(setTasks(tasks.filter(task => task.id !== id)));
}
};
const updateTask = (id: number, value: string) => {
const sanitizedValue = sanitizeInput(value);
dispatch(setTasks(tasks.map(task => (task.id === id ? { ...task, value: sanitizedValue } : task))));
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
const input = e.currentTarget as HTMLInputElement;
if (!input.value.trim()) return;
e.preventDefault();
addTask();
};
useEffect(() => {
setTimeout(() => inputRefs.current[0]?.focus(), 200);
}, []);
// Function to set ref that doesn't return anything (void)
const setInputRef = (index: number) => (el: InputRef | null) => {
inputRefs.current[index] = el;
};
return (
<Form
className="create-first-task-form"
style={{
minHeight: '300px',
width: '600px',
paddingBottom: '1rem',
marginBottom: '3rem',
marginTop: '3rem',
display: 'flex',
flexDirection: 'column',
}}
>
<Form.Item>
<Title level={2} style={{ marginBottom: '1rem' }}>
{t('tasksStepTitle')}
</Title>
</Form.Item>
<Form.Item
layout="vertical"
rules={[{ required: true }]}
label={
<span className="font-medium">
{t('tasksStepLabel')} "<mark>{projectName}</mark>". {t('maxTasks')}
</span>
}
>
<List
dataSource={tasks}
bordered={false}
renderItem={(task, index) => (
<List.Item key={task.id}>
<div style={{ display: 'flex', width: '600px' }}>
<Input
placeholder="Your Task"
value={task.value}
onChange={e => updateTask(task.id, e.target.value)}
onPressEnter={handleKeyPress}
ref={setInputRef(index)}
/>
<Button
className="custom-close-button"
style={{ marginLeft: '48px' }}
type="text"
icon={<CloseCircleOutlined />}
disabled={tasks.length === 1}
onClick={() => removeTask(task.id)}
/>
</div>
</List.Item>
)}
/>
<Button
type="dashed"
icon={<PlusOutlined />}
onClick={addTask}
disabled={tasks.length == 5}
style={{ marginTop: '16px' }}
>
{t('tasksStepAddAnother')}
</Button>
<div
style={{
marginTop: '24px',
display: 'flex',
justifyContent: 'space-between',
}}
></div>
</Form.Item>
</Form>
);
};

View File

@@ -0,0 +1,13 @@
[data-theme="dark"] {
--border-color: 1px solid black;
}
[data-theme="default"] {
--border-color: 1px solid #dee2e6;
}
.custom-dropdown-menu .ant-dropdown-menu {
box-shadow: none !important;
border-top: var(--border-color) !important;
border-bottom: var(--border-color) !important;
}

View File

@@ -0,0 +1,137 @@
import React, { useState } from 'react';
import { Avatar, Button, Checkbox, Dropdown, Input, Menu, Typography } from 'antd';
import { UserAddOutlined, UsergroupAddOutlined } from '@ant-design/icons';
import './add-members-dropdown.css';
import { useAppSelector } from '@/hooks/useAppSelector';
import { AvatarNamesMap } from '@/shared/constants';
const AddMembersDropdown: React.FC = () => {
const [checkedMembers, setCheckedMembers] = useState<string[]>([]);
const handleCheck = (member: string) => {
setCheckedMembers(prevChecked =>
prevChecked.includes(member)
? prevChecked.filter(m => m !== member)
: [...prevChecked, member]
);
};
const themeMode = useAppSelector(state => state.themeReducer.mode);
const inviteItems = [
{
key: '1',
label: (
<Checkbox
checked={checkedMembers.includes('Invite Member 1')}
onClick={e => e.stopPropagation()}
onChange={() => handleCheck('Invite Member 1')}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Avatar
style={{
backgroundColor: AvatarNamesMap['R'],
width: '28px',
height: '28px',
marginRight: '0.5rem',
}}
>
R
</Avatar>
<div
style={{
lineHeight: '15px',
display: 'flex',
flexDirection: 'column',
}}
>
<Typography.Text>Raveesha Dilanka</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: '80%' }}>
raveeshadilanka1999@gmail.com
</Typography.Text>
</div>
</div>
</Checkbox>
),
},
];
// Define menu items with header and footer
const menu = (
<div>
{/* Header */}
<div
style={{
backgroundColor: themeMode === 'dark' ? 'black' : 'white',
padding: '8px 16px',
fontWeight: 'bold',
}}
>
<Input placeholder="Search by name" />
</div>
{/* Invite Items */}
<Menu
items={inviteItems}
style={{
maxHeight: '300px',
overflowY: 'auto',
}}
/>
<Button
style={{
width: '100%',
backgroundColor: themeMode === 'dark' ? 'black' : 'white',
}}
type="link"
>
<UsergroupAddOutlined /> Invite a new member by email
</Button>
{/* Footer */}
<div
style={{
padding: '8px',
textAlign: 'right',
backgroundColor: themeMode === 'dark' ? 'black' : 'white',
borderTop: '1px solid rgba(0, 0, 0, 0.15)',
color: '#1890ff',
}}
>
<Button
type="primary"
size="small"
onClick={() => {
console.log('Selected Members:', checkedMembers);
}}
>
Ok
</Button>
</div>
</div>
);
return (
<Dropdown
menu={{ items: inviteItems }}
trigger={['click']}
dropdownRender={() => menu}
overlayClassName="custom-dropdown-menu"
overlayStyle={{
width: '300px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
}}
>
<UserAddOutlined />
</Dropdown>
);
};
export default AddMembersDropdown;

View File

@@ -0,0 +1,5 @@
.custom-dropdown-menu .ant-dropdown-menu {
box-shadow: none !important;
border-top: 1px solid #dee2e6 !important;
border-bottom: 1px solid #dee2e6 !important;
}

View File

@@ -0,0 +1,132 @@
import React, { useState } from 'react';
import { Avatar, Button, Checkbox, Dropdown, Input, Menu, Typography } from 'antd';
import { PlusOutlined, UsergroupAddOutlined } from '@ant-design/icons';
import './add-members-dropdown.css';
import { AvatarNamesMap } from '../../shared/constants';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
const AddMembersDropdown: React.FC = () => {
const [checkedMembers, setCheckedMembers] = useState<string[]>([]);
const dispatch = useAppDispatch();
const teamMembers = useAppSelector(state => state.teamMembersReducer.teamMembers);
const handleCheck = (member: string) => {
setCheckedMembers(prevChecked =>
prevChecked.includes(member)
? prevChecked.filter(m => m !== member)
: [...prevChecked, member]
);
};
const inviteItems = [
{
key: '1',
label: (
<Checkbox
checked={checkedMembers.includes('Invite Member 1')}
onClick={e => e.stopPropagation()} // Prevent dropdown from closing
onChange={() => handleCheck('Invite Member 1')}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<Avatar
style={{
backgroundColor: AvatarNamesMap['R'],
width: '28px',
height: '28px',
marginRight: '0.5rem',
}}
>
R
</Avatar>
<div
style={{
lineHeight: '15px',
display: 'flex',
flexDirection: 'column',
}}
>
<Typography.Text>Raveesha Dilanka</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: '80%' }}>
raveeshadilanka1999@gmail.com
</Typography.Text>
</div>
</div>
</Checkbox>
),
},
];
// Define menu items with header and footer
const menu = (
<div>
{/* Header */}
<div
style={{
backgroundColor: 'white',
padding: '8px 16px',
fontWeight: 'bold',
}}
>
<Input placeholder="Search by name" />
</div>
{/* Invite Items */}
<Menu
items={inviteItems}
style={{
maxHeight: '300px',
overflowY: 'auto',
}}
/>
<Button style={{ width: '100%', backgroundColor: 'white' }} type="link">
<UsergroupAddOutlined /> Invite a new member by email
</Button>
{/* Footer */}
<div
style={{
padding: '8px',
textAlign: 'right',
backgroundColor: 'white',
borderTop: '1px solid rgba(0, 0, 0, 0.15)',
color: '#1890ff',
}}
>
<Button
type="primary"
size="small"
onClick={() => {
console.log('Selected Members:', checkedMembers);
}}
>
Ok
</Button>
</div>
</div>
);
return (
<Dropdown
menu={{ items: inviteItems }}
trigger={['click']}
dropdownRender={() => menu}
overlayClassName="custom-dropdown-menu"
overlayStyle={{
width: '300px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
}}
>
<PlusOutlined />
</Dropdown>
);
};
export default AddMembersDropdown;

View File

@@ -0,0 +1,95 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import { fetchStorageInfo } from '@/features/admin-center/admin-center.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { SUBSCRIPTION_STATUS } from '@/shared/constants';
import { IBillingAccountStorage } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { Card, Progress, Typography } from 'antd/es';
import { useEffect, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
interface IAccountStorageProps {
themeMode: string;
}
const AccountStorage = ({ themeMode }: IAccountStorageProps) => {
const { t } = useTranslation('admin-center/current-bill');
const dispatch = useAppDispatch();
const [subscriptionType, setSubscriptionType] = useState<string>(SUBSCRIPTION_STATUS.TRIALING);
const { loadingBillingInfo, billingInfo, storageInfo } = useAppSelector(state => state.adminCenterReducer);
const formatBytes = useMemo(
() =>
(bytes = 0, decimals = 2) => {
if (!+bytes) return '0 MB';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
const formattedValue = parseFloat((bytes / Math.pow(k, i)).toFixed(dm));
return `${formattedValue} ${subscriptionType !== SUBSCRIPTION_STATUS.FREE ? sizes[i] : 'MB'}`;
},
[subscriptionType]
);
useEffect(() => {
dispatch(fetchStorageInfo());
}, []);
useEffect(() => {
setSubscriptionType(billingInfo?.status ?? SUBSCRIPTION_STATUS.TRIALING);
}, [billingInfo?.status]);
const textColor = themeMode === 'dark' ? '#ffffffd9' : '#000000d9';
return (
<Card
style={{
height: '100%',
}}
loading={loadingBillingInfo}
title={
<span
style={{
color: textColor,
fontWeight: 500,
fontSize: '16px',
}}
>
{t('accountStorage')}
</span>
}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<div style={{ padding: '0 16px' }}>
<Progress
percent={billingInfo?.usedPercentage ?? 0}
type="circle"
format={percent => <span style={{ fontSize: '13px' }}>{percent}% Used</span>}
/>
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
gap: '8px',
}}
>
<Typography.Text>
{t('used')} <strong>{formatBytes(storageInfo?.used ?? 0, 1)}</strong>
</Typography.Text>
<Typography.Text>
{t('remaining')} <strong>{formatBytes(storageInfo?.remaining ?? 0, 1)}</strong>
</Typography.Text>
</div>
</div>
</Card>
);
};
export default AccountStorage;

View File

@@ -0,0 +1,99 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import { IBillingCharge, IBillingChargesResponse } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { formatDate } from '@/utils/timeUtils';
import { Table, TableProps, Tag } from 'antd';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
const ChargesTable: React.FC = () => {
const { t } = useTranslation('admin-center/current-bill');
const [charges, setCharges] = useState<IBillingChargesResponse>({});
const [loadingCharges, setLoadingCharges] = useState(false);
const fetchCharges = async () => {
try {
setLoadingCharges(true);
const res = await adminCenterApiService.getCharges();
if (res.done) {
setCharges(res.body);
}
} catch (error) {
logger.error('Error fetching charges:', error);
} finally {
setLoadingCharges(false);
}
};
const columns: TableProps<IBillingCharge>['columns'] = [
{
title: t('description'),
key: 'name',
dataIndex: 'name',
},
{
title: t('billingPeriod'),
key: 'billingPeriod',
render: record => {
return `${formatDate(new Date(record.start_date))} - ${formatDate(new Date(record.end_date))}`;
},
},
{
title: t('billStatus'),
key: 'status',
dataIndex: 'status',
render: (_, record) => {
return (
<Tag
color={
record.status === 'success' ? 'green' : record.status === 'deleted' ? 'red' : 'blue'
}
>
{record.status?.toUpperCase()}
</Tag>
);
},
},
{
title: t('perUserValue'),
key: 'perUserValue',
dataIndex: 'perUserValue',
render: (_, record) => (
<span>
{record.currency} {record.unit_price}
</span>
),
},
{
title: t('users'),
key: 'quantity',
dataIndex: 'quantity',
},
{
title: t('amount'),
key: 'amount',
dataIndex: 'amount',
render: (_, record) => (
<span>
{record.currency} {record.amount}
</span>
),
},
];
useEffect(() => {
fetchCharges();
}, []);
return (
<Table<IBillingCharge>
columns={columns}
dataSource={charges.plan_charges}
pagination={false}
loading={loadingCharges}
rowKey="id"
/>
);
};
export default ChargesTable;

View File

@@ -0,0 +1,105 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import { IBillingTransaction } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { formatDate } from '@/utils/timeUtils';
import { ContainerOutlined } from '@ant-design/icons';
import { Button, Table, TableProps, Tag, Tooltip } from 'antd';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
const InvoicesTable: React.FC = () => {
const { t } = useTranslation('admin-center/current-bill');
const [transactions, setTransactions] = useState<IBillingTransaction[]>([]);
const [loadingTransactions, setLoadingTransactions] = useState(false);
const fetchTransactions = async () => {
try {
setLoadingTransactions(true);
const res = await adminCenterApiService.getTransactions();
if (res.done) {
setTransactions(res.body);
}
} catch (error) {
logger.error('Error fetching transactions:', error);
} finally {
setLoadingTransactions(false);
}
};
const handleInvoiceViewClick = (record: IBillingTransaction) => {
if (!record.receipt_url) return;
window.open(record.receipt_url, '_blank');
};
useEffect(() => {
fetchTransactions();
}, []);
const columns: TableProps<IBillingTransaction>['columns'] = [
{
title: t('transactionId'),
key: 'transactionId',
dataIndex: 'subscription_payment_id',
},
{
title: t('transactionDate'),
key: 'transactionDate',
render: record => `${formatDate(new Date(record.event_time))}`,
},
{
title: t('billingPeriod'),
key: 'billingPeriod',
render: record => {
return `${formatDate(new Date(record.event_time))} - ${formatDate(new Date(record.next_bill_date))}`;
},
},
{
title: t('paymentMethod'),
key: 'paymentMethod',
dataIndex: 'payment_method',
},
{
title: t('status'),
key: 'status',
dataIndex: 'status',
render: (_, record) => (
<Tag
color={
record.payment_status === 'success'
? 'green'
: record.payment_status === 'failed'
? 'red'
: 'blue'
}
>
{record.payment_status?.toUpperCase()}
</Tag>
),
},
{
key: 'button',
render: (_, record) => (
<Tooltip title={t('viewInvoice')}>
<Button size="small">
<ContainerOutlined onClick={() => handleInvoiceViewClick(record)} />
</Button>
</Tooltip>
),
},
];
return (
<Table
columns={columns}
dataSource={transactions}
pagination={false}
loading={loadingTransactions}
rowKey="id"
/>
);
};
export default InvoicesTable;

View File

@@ -0,0 +1,12 @@
.current-billing .ant-card-head-wrapper {
padding: 16px 0;
}
.current-billing .ant-progress-inner {
width: 75px !important;
height: 75px !important;
}
:where(.css-dev-only-do-not-override-ezht69).ant-modal .ant-modal-footer {
margin-top: 0 !important;
}

View File

@@ -0,0 +1,141 @@
import { Button, Card, Col, Modal, Row, Tooltip, Typography } from 'antd';
import React, { useEffect } from 'react';
import './current-bill.css';
import { InfoCircleTwoTone } from '@ant-design/icons';
import ChargesTable from './billing-tables/charges-table';
import InvoicesTable from './billing-tables/invoices-table';
import UpgradePlansLKR from './drawers/upgrade-plans-lkr/upgrade-plans-lkr';
import UpgradePlans from './drawers/upgrade-plans/upgrade-plans';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useMediaQuery } from 'react-responsive';
import { useTranslation } from 'react-i18next';
import {
toggleDrawer,
toggleUpgradeModal,
} from '@/features/admin-center/billing/billing.slice';
import { fetchBillingInfo, fetchFreePlanSettings } from '@/features/admin-center/admin-center.slice';
import RedeemCodeDrawer from './drawers/redeem-code-drawer/redeem-code-drawer';
import CurrentPlanDetails from './current-plan-details/current-plan-details';
import AccountStorage from './account-storage/account-storage';
import { useAuthService } from '@/hooks/useAuth';
import { ISUBSCRIPTION_TYPE } from '@/shared/constants';
const CurrentBill: React.FC = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('admin-center/current-bill');
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { isUpgradeModalOpen } = useAppSelector(state => state.adminCenterReducer);
const isTablet = useMediaQuery({ query: '(min-width: 1025px)' });
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const currentSession = useAuthService().getCurrentSession();
useEffect(() => {
dispatch(fetchBillingInfo());
dispatch(fetchFreePlanSettings());
}, [dispatch]);
const titleStyle = {
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
fontWeight: 500,
fontSize: '16px',
display: 'flex',
gap: '4px',
};
const renderMobileView = () => (
<div>
<Col span={24}>
<Card
style={{ height: '100%' }}
title={<span style={titleStyle}>{t('currentPlanDetails')}</span>}
extra={
<div style={{ marginTop: '8px', marginRight: '8px' }}>
<Button type="primary" onClick={() => dispatch(toggleUpgradeModal())}>
{t('upgradePlan')}
</Button>
<Modal
open={isUpgradeModalOpen}
onCancel={() => dispatch(toggleUpgradeModal())}
width={1000}
centered
okButtonProps={{ hidden: true }}
cancelButtonProps={{ hidden: true }}
>
{browserTimeZone === 'Asia/Colombo' ? <UpgradePlansLKR /> : <UpgradePlans />}
</Modal>
</div>
}
>
<div style={{ display: 'flex', flexDirection: 'column', width: '50%', padding: '0 12px' }}>
<div style={{ marginBottom: '14px' }}>
<Typography.Text style={{ fontWeight: 700 }}>{t('cardBodyText01')}</Typography.Text>
<Typography.Text>{t('cardBodyText02')}</Typography.Text>
</div>
<Button
type="link"
style={{ margin: 0, padding: 0, width: '90px' }}
onClick={() => dispatch(toggleDrawer())}
>
{t('redeemCode')}
</Button>
<RedeemCodeDrawer />
</div>
</Card>
</Col>
<Col span={24} style={{ marginTop: '1.5rem' }}>
<AccountStorage themeMode={themeMode} />
</Col>
</div>
);
const renderChargesAndInvoices = () => (
<>
<div style={{ marginTop: '1.5rem' }}>
<Card
title={
<span style={titleStyle}>
<span>{t('charges')}</span>
<Tooltip title={t('tooltip')}>
<InfoCircleTwoTone />
</Tooltip>
</span>
}
style={{ marginTop: '16px' }}
>
<ChargesTable />
</Card>
</div>
<div style={{ marginTop: '1.5rem' }}>
<Card
title={<span style={titleStyle}>{t('invoices')}</span>}
style={{ marginTop: '16px' }}
>
<InvoicesTable />
</Card>
</div>
</>
);
return (
<div style={{ width: '100%' }} className="current-billing">
{isTablet ? (
<Row>
<Col span={16} style={{ paddingRight: '10px' }}>
<CurrentPlanDetails />
</Col>
<Col span={8} style={{ paddingLeft: '10px' }}>
<AccountStorage themeMode={themeMode} />
</Col>
</Row>
) : (
renderMobileView()
)}
{currentSession?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE && renderChargesAndInvoices()}
</div>
);
};
export default CurrentBill;

View File

@@ -0,0 +1,437 @@
import React, { useState } from 'react';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import {
evt_billing_pause_plan,
evt_billing_resume_plan,
evt_billing_add_more_seats,
} from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import logger from '@/utils/errorLogger';
import { Button, Card, Flex, Modal, Space, Tooltip, Typography, Statistic, Select, Form, Row, Col } from 'antd/es';
import RedeemCodeDrawer from '../drawers/redeem-code-drawer/redeem-code-drawer';
import {
fetchBillingInfo,
toggleRedeemCodeDrawer,
toggleUpgradeModal,
} from '@/features/admin-center/admin-center.slice';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import { WarningTwoTone, PlusOutlined } from '@ant-design/icons';
import { calculateTimeGap } from '@/utils/calculate-time-gap';
import { formatDate } from '@/utils/timeUtils';
import UpgradePlansLKR from '../drawers/upgrade-plans-lkr/upgrade-plans-lkr';
import UpgradePlans from '../drawers/upgrade-plans/upgrade-plans';
import { ISUBSCRIPTION_TYPE, SUBSCRIPTION_STATUS } from '@/shared/constants';
import { billingApiService } from '@/api/admin-center/billing.api.service';
const CurrentPlanDetails = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('admin-center/current-bill');
const { trackMixpanelEvent } = useMixpanelTracking();
const [pausingPlan, setPausingPlan] = useState(false);
const [cancellingPlan, setCancellingPlan] = useState(false);
const [addingSeats, setAddingSeats] = useState(false);
const [isMoreSeatsModalVisible, setIsMoreSeatsModalVisible] = useState(false);
const [selectedSeatCount, setSelectedSeatCount] = useState<number | string>(5);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { loadingBillingInfo, billingInfo, freePlanSettings, isUpgradeModalOpen } = useAppSelector(
state => state.adminCenterReducer
);
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
type SeatOption = { label: string; value: number | string };
const seatCountOptions: SeatOption[] = [1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90]
.map(value => ({ label: value.toString(), value }));
seatCountOptions.push({ label: '100+', value: '100+' });
const handleSubscriptionAction = async (action: 'pause' | 'resume') => {
const isResume = action === 'resume';
const setLoadingState = isResume ? setCancellingPlan : setPausingPlan;
const apiMethod = isResume
? adminCenterApiService.resumeSubscription
: adminCenterApiService.pauseSubscription;
const eventType = isResume ? evt_billing_resume_plan : evt_billing_pause_plan;
try {
setLoadingState(true);
const res = await apiMethod();
if (res.done) {
setTimeout(() => {
setLoadingState(false);
dispatch(fetchBillingInfo());
trackMixpanelEvent(eventType);
}, 8000);
return; // Exit function to prevent finally block from executing
}
} catch (error) {
logger.error(`Error ${action}ing subscription`, error);
setLoadingState(false); // Only set to false on error
}
};
const handleAddMoreSeats = () => {
setIsMoreSeatsModalVisible(true);
};
const handlePurchaseMoreSeats = async () => {
if (selectedSeatCount.toString() === '100+' || !billingInfo?.total_seats) return;
try {
setAddingSeats(true);
const totalSeats = Number(selectedSeatCount) + (billingInfo?.total_seats || 0);
const res = await billingApiService.purchaseMoreSeats(totalSeats);
if (res.done) {
setIsMoreSeatsModalVisible(false);
dispatch(fetchBillingInfo());
trackMixpanelEvent(evt_billing_add_more_seats);
}
} catch (error) {
logger.error('Error adding more seats', error);
} finally {
setAddingSeats(false);
}
};
const calculateRemainingSeats = () => {
if (billingInfo?.total_seats && billingInfo?.total_used) {
return billingInfo.total_seats - billingInfo.total_used;
}
return 0;
};
const checkSubscriptionStatus = (allowedStatuses: any[]) => {
if (!billingInfo?.status || billingInfo.is_ltd_user) return false;
return allowedStatuses.includes(billingInfo.status);
};
const shouldShowRedeemButton = () => {
if (billingInfo?.trial_in_progress) return true;
return billingInfo?.ltd_users ? billingInfo.ltd_users < 50 : false;
};
const showChangeButton = () => {
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.PASTDUE]);
};
const showPausePlanButton = () => {
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.ACTIVE, SUBSCRIPTION_STATUS.PASTDUE]);
};
const showResumePlanButton = () => {
return checkSubscriptionStatus([SUBSCRIPTION_STATUS.PAUSED]);
};
const shouldShowAddSeats = () => {
if (!billingInfo) return false;
return billingInfo.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
billingInfo.status === SUBSCRIPTION_STATUS.ACTIVE;
};
const renderExtra = () => {
if (!billingInfo || billingInfo.is_custom) return null;
return (
<Space>
{showPausePlanButton() && (
<Button
type="link"
danger
loading={pausingPlan}
onClick={() => handleSubscriptionAction('pause')}
>
{t('pausePlan')}
</Button>
)}
{showResumePlanButton() && (
<Button
type="primary"
loading={cancellingPlan}
onClick={() => handleSubscriptionAction('resume')}
>
{t('resumePlan')}
</Button>
)}
{billingInfo.trial_in_progress && (
<Button type="primary" onClick={() => dispatch(toggleUpgradeModal())}>
{t('upgradePlan')}
</Button>
)}
{showChangeButton() && (
<Button
type="primary"
loading={pausingPlan || cancellingPlan}
onClick={() => dispatch(toggleUpgradeModal())}
>
{t('changePlan')}
</Button>
)}
</Space>
);
};
const renderLtdDetails = () => {
if (!billingInfo || billingInfo.is_custom) return null;
return (
<Flex vertical>
<Typography.Text strong>{billingInfo.plan_name}</Typography.Text>
<Typography.Text>{t('ltdUsers', { ltd_users: billingInfo.ltd_users })}</Typography.Text>
</Flex>
);
};
const renderTrialDetails = () => {
const checkIfTrialExpired = () => {
if (!billingInfo?.trial_expire_date) return false;
const today = new Date();
today.setHours(0, 0, 0, 0); // Set to start of day for comparison
const trialExpireDate = new Date(billingInfo.trial_expire_date);
trialExpireDate.setHours(0, 0, 0, 0); // Set to start of day for comparison
return today > trialExpireDate;
};
const getExpirationMessage = (expireDate: string) => {
const today = new Date();
today.setHours(0, 0, 0, 0); // Set to start of day for comparison
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const expDate = new Date(expireDate);
expDate.setHours(0, 0, 0, 0); // Set to start of day for comparison
if (expDate.getTime() === today.getTime()) {
return t('expirestoday', 'today');
} else if (expDate.getTime() === tomorrow.getTime()) {
return t('expirestomorrow', 'tomorrow');
} else if (expDate < today) {
const diffTime = Math.abs(today.getTime() - expDate.getTime());
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
return t('expiredDaysAgo', '{{days}} days ago', { days: diffDays });
} else {
return calculateTimeGap(expireDate);
}
};
const isExpired = checkIfTrialExpired();
const trialExpireDate = billingInfo?.trial_expire_date || '';
return (
<Flex vertical>
<Typography.Text strong>
{t('trialPlan')}
{isExpired && <WarningTwoTone twoToneColor="#faad14" style={{ marginLeft: 8 }} />}
</Typography.Text>
<Tooltip title={formatDate(new Date(trialExpireDate))}>
<Typography.Text>
{isExpired
? t('trialExpired', {
trial_expire_string: getExpirationMessage(trialExpireDate)
})
: t('trialInProgress', {
trial_expire_string: getExpirationMessage(trialExpireDate)
})
}
</Typography.Text>
</Tooltip>
</Flex>
);
};
const renderFreePlan = () => (
<Flex vertical>
<Typography.Text strong>Free Plan</Typography.Text>
<Typography.Text>
<br />-{' '}
{freePlanSettings?.team_member_limit === 0
? t('unlimitedTeamMembers')
: `${freePlanSettings?.team_member_limit} ${t('teamMembers')}`}
<br />- {freePlanSettings?.projects_limit} {t('projects')}
<br />- {freePlanSettings?.free_tier_storage} MB {t('storage')}
</Typography.Text>
</Flex>
);
const renderPaddleSubscriptionInfo = () => {
return (
<Flex vertical>
<Typography.Text strong>{billingInfo?.plan_name}</Typography.Text>
<Flex>
<Typography.Text>{billingInfo?.default_currency}</Typography.Text>&nbsp;
<Typography.Text>
{billingInfo?.billing_type === 'year'
? billingInfo.unit_price_per_month
: billingInfo?.unit_price}
&nbsp;{t('perMonthPerUser')}
</Typography.Text>
</Flex>
{shouldShowAddSeats() && billingInfo?.total_seats && (
<div style={{ marginTop: '16px' }}>
<Row gutter={16} align="middle">
<Col span={6}>
<Statistic
title={t('totalSeats')}
value={billingInfo.total_seats}
valueStyle={{ fontSize: '24px', fontWeight: 'bold' }}
/>
</Col>
<Col span={8}>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddMoreSeats}
style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }}
>
{t('addMoreSeats')}
</Button>
</Col>
<Col span={6}>
<Statistic
title={t('availableSeats')}
value={calculateRemainingSeats()}
valueStyle={{ fontSize: '24px', fontWeight: 'bold' }}
/>
</Col>
</Row>
</div>
)}
</Flex>
);
};
const renderCreditSubscriptionInfo = () => {
return <Flex vertical>
<Typography.Text strong>Credit Plan</Typography.Text>
</Flex>
};
const renderCustomSubscriptionInfo = () => {
return <Flex vertical>
<Typography.Text strong>Custom Plan</Typography.Text>
<Typography.Text>Your plan is valid till {billingInfo?.valid_till_date}</Typography.Text>
</Flex>
};
return (
<Card
style={{ height: '100%' }}
title={
<Typography.Text
style={{
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
fontWeight: 500,
fontSize: '16px',
}}
>
{t('currentPlanDetails')}
</Typography.Text>
}
loading={loadingBillingInfo}
extra={renderExtra()}
>
<Flex vertical>
<div style={{ marginBottom: '14px' }}>
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.LIFE_TIME_DEAL && renderLtdDetails()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && renderTrialDetails()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.FREE && renderFreePlan()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE && renderPaddleSubscriptionInfo()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CREDIT && renderCreditSubscriptionInfo()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CUSTOM && renderCustomSubscriptionInfo()}
</div>
{shouldShowRedeemButton() && (
<>
<Button
type="link"
style={{ margin: 0, padding: 0, width: '90px' }}
onClick={() => dispatch(toggleRedeemCodeDrawer())}
>
{t('redeemCode')}
</Button>
<RedeemCodeDrawer />
</>
)}
<Modal
open={isUpgradeModalOpen}
onCancel={() => dispatch(toggleUpgradeModal())}
width={1000}
centered
okButtonProps={{ hidden: true }}
cancelButtonProps={{ hidden: true }}
>
{browserTimeZone === 'Asia/Colombo' ? <UpgradePlansLKR /> : <UpgradePlans />}
</Modal>
<Modal
title={t('addMoreSeats')}
open={isMoreSeatsModalVisible}
onCancel={() => setIsMoreSeatsModalVisible(false)}
footer={null}
width={500}
centered
>
<Flex vertical gap="middle" style={{ marginTop: '8px' }}>
<Typography.Paragraph style={{ fontSize: '16px', margin: '0 0 16px 0', fontWeight: 500 }}>
To continue, you'll need to purchase additional seats.
</Typography.Paragraph>
<Typography.Paragraph style={{ margin: '0 0 16px 0' }}>
You currently have {billingInfo?.total_seats} seats available.
</Typography.Paragraph>
<Typography.Paragraph style={{ margin: '0 0 24px 0' }}>
Please select the number of additional seats to purchase.
</Typography.Paragraph>
<div style={{ marginBottom: '24px' }}>
<span style={{ color: '#ff4d4f', marginRight: '4px' }}>*</span>
<span style={{ marginRight: '8px' }}>Seats:</span>
<Select
value={selectedSeatCount}
onChange={setSelectedSeatCount}
options={seatCountOptions}
style={{ width: '300px' }}
/>
</div>
<Flex justify="end">
{selectedSeatCount.toString() !== '100+' ? (
<Button
type="primary"
loading={addingSeats}
onClick={handlePurchaseMoreSeats}
style={{
minWidth: '100px',
backgroundColor: '#1890ff',
borderColor: '#1890ff',
borderRadius: '2px'
}}
>
Purchase
</Button>
) : (
<Button
type="primary"
size="middle"
>
Contact sales
</Button>
)}
</Flex>
</Flex>
</Modal>
</Flex>
</Card>
);
};
export default CurrentPlanDetails;

View File

@@ -0,0 +1,103 @@
import { Button, Drawer, Form, Input, notification, Typography } from 'antd';
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
fetchBillingInfo,
fetchStorageInfo,
toggleRedeemCodeDrawer,
} from '@features/admin-center/admin-center.slice';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import logger from '@/utils/errorLogger';
import { authApiService } from '@/api/auth/auth.api.service';
import { setUser } from '@/features/user/userSlice';
import { setSession } from '@/utils/session-helper';
const RedeemCodeDrawer: React.FC = () => {
const [form] = Form.useForm();
const { t } = useTranslation('admin-center/current-bill');
const { isRedeemCodeDrawerOpen } = useAppSelector(state => state.adminCenterReducer);
const dispatch = useAppDispatch();
const [redeemCode, setRedeemCode] = useState<string>('');
const [isLoading, setIsLoading] = useState(false);
const handleFormSubmit = async (values: any) => {
if (!values.redeemCode) return;
try {
setIsLoading(true);
const res = await adminCenterApiService.redeemCode(values.redeemCode);
if (res.done) {
form.resetFields();
const authorizeResponse = await authApiService.verify();
if (authorizeResponse.authenticated) {
setSession(authorizeResponse.user);
dispatch(setUser(authorizeResponse.user));
}
dispatch(toggleRedeemCodeDrawer());
dispatch(fetchBillingInfo());
dispatch(fetchStorageInfo());
}
} catch (error) {
logger.error('Error redeeming code', error);
} finally {
setIsLoading(false);
}
};
return (
<div>
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{t('drawerTitle')}
</Typography.Text>
}
open={isRedeemCodeDrawerOpen}
onClose={() => {
dispatch(toggleRedeemCodeDrawer());
form.resetFields();
}}
>
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
<Form.Item
name="redeemCode"
label={t('label')}
rules={[
{
required: true,
message: t('required'),
},
{
pattern: /^[A-Za-z0-9]+$/,
message: t('invalidCode'),
},
]}
>
<Input
placeholder={t('drawerPlaceholder')}
onChange={e => setRedeemCode(e.target.value.toUpperCase())}
count={{ show: true, max: 10 }}
value={redeemCode}
/>
</Form.Item>
<Form.Item>
<Button
type="primary"
style={{ width: '100%' }}
htmlType="submit"
disabled={redeemCode.length !== 10}
loading={isLoading}
>
{t('redeemSubmit')}
</Button>
</Form.Item>
</Form>
</Drawer>
</div>
);
};
export default RedeemCodeDrawer;

View File

@@ -0,0 +1,3 @@
.upgrade-plans .ant-card-head-wrapper {
padding: 16px 0;
}

View File

@@ -0,0 +1,235 @@
import { Button, Card, Col, Form, Input, notification, Row, Tag, Typography } from 'antd';
import React, { useState } from 'react';
import './upgrade-plans-lkr.css';
import { CheckCircleFilled } from '@ant-design/icons';
import { RootState } from '@/app/store';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
import { timeZoneCurrencyMap } from '@/utils/timeZoneCurrencyMap';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleUpgradeModal, fetchBillingInfo } from '@features/admin-center/admin-center.slice';
import { useAuthService } from '@/hooks/useAuth';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import logger from '@/utils/errorLogger';
import { setSession } from '@/utils/session-helper';
import { authApiService } from '@/api/auth/auth.api.service';
import { setUser } from '@/features/user/userSlice';
const UpgradePlansLKR: React.FC = () => {
const dispatch = useAppDispatch();
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
const [selectedPlan, setSelectedPlan] = useState(2);
const { t } = useTranslation('admin-center/current-bill');
const userTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
const userCurrency = timeZoneCurrencyMap[userTimeZone] || 'USD';
const [switchingToFreePlan, setSwitchingToFreePlan] = useState(false);
const currentSession = useAuthService().getCurrentSession();
const cardStyles = {
title: {
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
fontWeight: 500,
fontSize: '16px',
display: 'flex',
gap: '4px',
justifyContent: 'center',
},
priceContainer: {
display: 'grid',
gridTemplateColumns: 'auto',
rowGap: '10px',
padding: '20px 30px 0',
},
featureList: {
display: 'grid',
gridTemplateRows: 'auto auto auto',
gridTemplateColumns: '200px',
rowGap: '7px',
padding: '10px',
justifyItems: 'start',
alignItems: 'start',
},
checkIcon: { color: '#52c41a' },
};
const handlePlanSelect = (planIndex: number) => {
setSelectedPlan(planIndex);
};
const handleSeatsChange = (values: { seats: number }) => {
if (values.seats <= 15) {
setSelectedPlan(2);
} else if (values.seats > 15 && values.seats <= 200) {
setSelectedPlan(3);
} else if (values.seats > 200) {
setSelectedPlan(4);
}
};
const isSelected = (planIndex: number) =>
selectedPlan === planIndex ? { border: '2px solid #1890ff' } : {};
const handleSubmit = () => {
notification.success({
message: t('submitSuccess'),
description: t('submitSuccessDescription'),
placement: 'topRight',
});
dispatch(toggleUpgradeModal());
};
const renderFeature = (text: string) => (
<div>
<CheckCircleFilled style={cardStyles.checkIcon} />
&nbsp;
<span>{text}</span>
</div>
);
const renderPlanCard = (
planIndex: number,
title: string,
price: string | number,
subtitle: string,
users: string,
features: string[],
tag?: string
) => (
<Col span={6} style={{ padding: '0 4px' }}>
<Card
style={{ ...isSelected(planIndex), height: '100%' }}
hoverable
title={
<span style={cardStyles.title}>
{title}
{tag && <Tag color="volcano">{tag}</Tag>}
</span>
}
onClick={() => handlePlanSelect(planIndex)}
>
<div style={cardStyles.priceContainer}>
<Typography.Title level={1}>
{userCurrency} {price}
</Typography.Title>
<span>{subtitle}</span>
<Typography.Title level={5}>{users}</Typography.Title>
</div>
<div style={cardStyles.featureList}>
{features.map((feature, index) => renderFeature(t(feature)))}
</div>
</Card>
</Col>
);
const switchToFreePlan = async () => {
const teamId = currentSession?.team_id;
if (!teamId) return;
try {
setSwitchingToFreePlan(true);
const res = await adminCenterApiService.switchToFreePlan(teamId);
if (res.done) {
dispatch(fetchBillingInfo());
dispatch(toggleUpgradeModal());
const authorizeResponse = await authApiService.verify();
if (authorizeResponse.authenticated) {
setSession(authorizeResponse.user);
dispatch(setUser(authorizeResponse.user));
window.location.href = '/worklenz/admin-center/billing';
}
}
} catch (error) {
logger.error('Error switching to free plan', error);
} finally {
setSwitchingToFreePlan(false);
}
};
return (
<div className="upgrade-plans" style={{ marginTop: '1.5rem', textAlign: 'center' }}>
<Typography.Title level={2}>{t('modalTitle')}</Typography.Title>
{selectedPlan !== 1 && (
<Row justify="center">
<Form initialValues={{ seats: 15 }} onValuesChange={handleSeatsChange}>
<Form.Item name="seats" label={t('seatLabel')}>
<Input type="number" min={15} step={5} />
</Form.Item>
</Form>
</Row>
)}
<Row>
{renderPlanCard(1, t('freePlan'), 0.0, t('freeSubtitle'), t('freeUsers'), [
'freeText01',
'freeText02',
'freeText03',
])}
{renderPlanCard(2, t('startup'), 4990, t('startupSubtitle'), t('startupUsers'), [
'startupText01',
'startupText02',
'startupText03',
'startupText04',
'startupText05',
])}
{renderPlanCard(
3,
t('business'),
300,
t('businessSubtitle'),
'16 - 200 users',
['startupText01', 'startupText02', 'startupText03', 'startupText04', 'startupText05'],
t('tag')
)}
{renderPlanCard(4, t('enterprise'), 250, t('businessSubtitle'), t('enterpriseUsers'), [
'startupText01',
'startupText02',
'startupText03',
'startupText04',
'startupText05',
])}
</Row>
{selectedPlan === 1 ? (
<Row justify="center" style={{ marginTop: '1.5rem' }}>
<Button type="primary" loading={switchingToFreePlan} onClick={switchToFreePlan}>
{t('switchToFreePlan')}
</Button>
</Row>
) : (
<div
style={{
backgroundColor: themeMode === 'dark' ? '#141414' : '#e2e3e5',
padding: '1rem',
marginTop: '1.5rem',
}}
>
<Typography.Title level={4}>{t('footerTitle')}</Typography.Title>
<Form onFinish={handleSubmit}>
<Row justify="center" style={{ height: '32px' }}>
<Form.Item
style={{ margin: '0 24px 0 0' }}
name="contactNumber"
label={t('footerLabel')}
rules={[{ required: true }]}
>
<Input type="number" placeholder="07xxxxxxxx" maxLength={10} minLength={10} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
{t('footerButton')}
</Button>
</Form.Item>
</Row>
</Form>
</div>
)}
</div>
);
};
export default UpgradePlansLKR;

View File

@@ -0,0 +1,523 @@
import { useEffect, useState } from 'react';
import { Button, Card, Col, Flex, Form, Row, Select, Tag, Tooltip, Typography, message } from 'antd/es';
import { useTranslation } from 'react-i18next';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import {
IPricingPlans,
IUpgradeSubscriptionPlanResponse,
} from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IPaddlePlans, SUBSCRIPTION_STATUS } from '@/shared/constants';
import { CheckCircleFilled, InfoCircleOutlined } from '@ant-design/icons';
import { useAuthService } from '@/hooks/useAuth';
import { fetchBillingInfo, toggleUpgradeModal } from '@/features/admin-center/admin-center.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { billingApiService } from '@/api/admin-center/billing.api.service';
import { authApiService } from '@/api/auth/auth.api.service';
import { setUser } from '@/features/user/userSlice';
import { setSession } from '@/utils/session-helper';
// Extend Window interface to include Paddle
declare global {
interface Window {
Paddle?: {
Environment: { set: (env: string) => void };
Setup: (config: { vendor: number; eventCallback: (data: any) => void }) => void;
Checkout: { open: (params: any) => void };
};
}
}
declare const Paddle: any;
const UpgradePlans = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('admin-center/current-bill');
const [plans, setPlans] = useState<IPricingPlans>({});
const [selectedPlan, setSelectedCard] = useState(IPaddlePlans.ANNUAL);
const [selectedSeatCount, setSelectedSeatCount] = useState(5);
const [seatCountOptions, setSeatCountOptions] = useState<number[]>([]);
const [switchingToFreePlan, setSwitchingToFreePlan] = useState(false);
const [switchingToPaddlePlan, setSwitchingToPaddlePlan] = useState(false);
const [form] = Form.useForm();
const currentSession = useAuthService().getCurrentSession();
const paddlePlans = IPaddlePlans;
const { billingInfo } = useAppSelector(state => state.adminCenterReducer);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const [paddleLoading, setPaddleLoading] = useState(false);
const [paddleError, setPaddleError] = useState<string | null>(null);
const populateSeatCountOptions = (currentSeats: number) => {
if (!currentSeats) return [];
const step = 5;
const maxSeats = 90;
const minValue = Math.min(currentSeats + 1);
const rangeStart = Math.ceil(minValue / step) * step;
const range = Array.from(
{ length: Math.floor((maxSeats - rangeStart) / step) + 1 },
(_, i) => rangeStart + i * step
);
return currentSeats < step
? [...Array.from({ length: rangeStart - minValue }, (_, i) => minValue + i), ...range]
: range;
};
const fetchPricingPlans = async () => {
try {
const res = await adminCenterApiService.getPlans();
if (res.done) {
setPlans(res.body);
}
} catch (error) {
logger.error('Error fetching pricing plans', error);
}
};
const switchToFreePlan = async () => {
const teamId = currentSession?.team_id;
if (!teamId) return;
try {
setSwitchingToFreePlan(true);
const res = await adminCenterApiService.switchToFreePlan(teamId);
if (res.done) {
dispatch(fetchBillingInfo());
dispatch(toggleUpgradeModal());
const authorizeResponse = await authApiService.verify();
if (authorizeResponse.authenticated) {
setSession(authorizeResponse.user);
dispatch(setUser(authorizeResponse.user));
window.location.href = '/worklenz/admin-center/billing';
}
}
} catch (error) {
logger.error('Error switching to free plan', error);
} finally {
setSwitchingToFreePlan(false);
}
};
const handlePaddleCallback = (data: any) => {
console.log('Paddle event:', data);
switch (data.event) {
case 'Checkout.Loaded':
setSwitchingToPaddlePlan(false);
setPaddleLoading(false);
break;
case 'Checkout.Complete':
message.success('Subscription updated successfully!');
setPaddleLoading(true);
setTimeout(() => {
dispatch(fetchBillingInfo());
dispatch(toggleUpgradeModal());
setSwitchingToPaddlePlan(false);
setPaddleLoading(false);
}, 10000);
break;
case 'Checkout.Close':
setSwitchingToPaddlePlan(false);
setPaddleLoading(false);
// User closed the checkout without completing
// message.info('Checkout was closed without completing the subscription');
break;
case 'Checkout.Error':
setSwitchingToPaddlePlan(false);
setPaddleLoading(false);
setPaddleError(data.error?.message || 'An error occurred during checkout');
message.error('Error during checkout: ' + (data.error?.message || 'Unknown error'));
logger.error('Paddle checkout error', data.error);
break;
default:
// Handle other events if needed
break;
}
};
const initializePaddle = (data: IUpgradeSubscriptionPlanResponse) => {
setPaddleLoading(true);
setPaddleError(null);
// Check if Paddle is already loaded
if (window.Paddle) {
configurePaddle(data);
return;
}
const script = document.createElement('script');
script.src = 'https://cdn.paddle.com/paddle/paddle.js';
script.type = 'text/javascript';
script.async = true;
script.onload = () => {
configurePaddle(data);
};
script.onerror = () => {
setPaddleLoading(false);
setPaddleError('Failed to load Paddle checkout');
message.error('Failed to load payment processor');
logger.error('Failed to load Paddle script');
};
document.getElementsByTagName('head')[0].appendChild(script);
};
const configurePaddle = (data: IUpgradeSubscriptionPlanResponse) => {
try {
if (data.sandbox) Paddle.Environment.set('sandbox');
Paddle.Setup({
vendor: parseInt(data.vendor_id),
eventCallback: (eventData: any) => {
void handlePaddleCallback(eventData);
},
});
Paddle.Checkout.open(data.params);
} catch (error) {
setPaddleLoading(false);
setPaddleError('Failed to initialize checkout');
message.error('Failed to initialize checkout');
logger.error('Error initializing Paddle', error);
}
};
const upgradeToPaddlePlan = async (planId: string) => {
try {
setSwitchingToPaddlePlan(true);
setPaddleLoading(true);
setPaddleError(null);
if (billingInfo?.trial_in_progress && billingInfo.status === SUBSCRIPTION_STATUS.TRIALING) {
const res = await billingApiService.upgradeToPaidPlan(planId, selectedSeatCount);
if (res.done) {
initializePaddle(res.body);
} else {
setSwitchingToPaddlePlan(false);
setPaddleLoading(false);
setPaddleError('Failed to prepare checkout');
message.error('Failed to prepare checkout');
}
} else if (billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE) {
// For existing subscriptions, use changePlan endpoint
const res = await adminCenterApiService.changePlan(planId);
if (res.done) {
message.success('Subscription plan changed successfully!');
dispatch(fetchBillingInfo());
dispatch(toggleUpgradeModal());
setSwitchingToPaddlePlan(false);
setPaddleLoading(false);
} else {
setSwitchingToPaddlePlan(false);
setPaddleLoading(false);
setPaddleError('Failed to change plan');
message.error('Failed to change subscription plan');
}
}
} catch (error) {
setSwitchingToPaddlePlan(false);
setPaddleLoading(false);
setPaddleError('Error upgrading to paid plan');
message.error('Failed to upgrade to paid plan');
logger.error('Error upgrading to paddle plan', error);
}
};
const continueWithPaddlePlan = async () => {
if (selectedPlan && selectedSeatCount.toString() === '100+') {
message.info('Please contact sales for custom pricing on large teams');
return;
}
try {
setSwitchingToPaddlePlan(true);
setPaddleError(null);
let planId: string | null = null;
if (selectedPlan === paddlePlans.ANNUAL && plans.annual_plan_id) {
planId = plans.annual_plan_id;
} else if (selectedPlan === paddlePlans.MONTHLY && plans.monthly_plan_id) {
planId = plans.monthly_plan_id;
}
if (planId) {
upgradeToPaddlePlan(planId);
} else {
setSwitchingToPaddlePlan(false);
setPaddleError('Invalid plan selected');
message.error('Invalid plan selected');
}
} catch (error) {
setSwitchingToPaddlePlan(false);
setPaddleError('Error processing request');
message.error('Error processing request');
logger.error('Error upgrading to paddle plan', error);
}
};
const isSelected = (cardIndex: IPaddlePlans) =>
selectedPlan === cardIndex ? { border: '2px solid #1890ff' } : {};
const cardStyles = {
title: {
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
fontWeight: 500,
fontSize: '16px',
display: 'flex',
gap: '4px',
justifyContent: 'center',
},
priceContainer: {
display: 'grid',
gridTemplateColumns: 'auto',
rowGap: '10px',
padding: '20px 20px 0',
},
featureList: {
display: 'grid',
gridTemplateRows: 'auto auto auto',
gridTemplateColumns: '200px',
rowGap: '7px',
padding: '10px',
justifyItems: 'start',
alignItems: 'start',
},
checkIcon: { color: '#52c41a' },
};
const calculateAnnualTotal = (price: string | undefined) => {
if (!price) return;
return (12 * parseFloat(price) * selectedSeatCount).toFixed(2);
};
const calculateMonthlyTotal = (price: string | undefined) => {
if (!price) return;
return (parseFloat(price) * selectedSeatCount).toFixed(2);
};
useEffect(() => {
fetchPricingPlans();
if (billingInfo?.total_used) {
setSeatCountOptions(populateSeatCountOptions(billingInfo.total_used));
form.setFieldsValue({ seatCount: selectedSeatCount });
}
}, [billingInfo]);
const renderFeature = (text: string) => (
<div>
<CheckCircleFilled style={cardStyles.checkIcon} />
&nbsp;<span>{text}</span>
</div>
);
useEffect(() => {
// Cleanup Paddle script when component unmounts
return () => {
const paddleScript = document.querySelector('script[src*="paddle.js"]');
if (paddleScript) {
paddleScript.remove();
}
};
}, []);
return (
<div>
<Flex justify="center" align="center">
<Typography.Title level={2}>
{billingInfo?.status === SUBSCRIPTION_STATUS.TRIALING
? t('selectPlan')
: t('changeSubscriptionPlan')}
</Typography.Title>
</Flex>
<Flex justify="center" align="center">
<Form form={form}>
<Form.Item name="seatCount" label={t('noOfSeats')}>
<Select
style={{ width: 100 }}
value={selectedSeatCount}
options={seatCountOptions.map(option => ({
value: option,
text: option.toString(),
}))}
onChange={setSelectedSeatCount}
/>
</Form.Item>
</Form>
</Flex>
<Flex>
<Row className="w-full">
{/* Free Plan */}
<Col span={8} style={{ padding: '0 4px' }}>
<Card
style={{ ...isSelected(paddlePlans.FREE), height: '100%' }}
hoverable
title={<span style={cardStyles.title}>{t('freePlan')}</span>}
onClick={() => setSelectedCard(paddlePlans.FREE)}
>
<div style={cardStyles.priceContainer}>
<Flex justify="space-between" align="center">
<Typography.Title level={1}>$ 0.00</Typography.Title>
<Typography.Text>{t('freeForever')}</Typography.Text>
</Flex>
<Flex justify="center" align="center">
<Typography.Text strong style={{ fontSize: '16px' }}>
{t('bestForPersonalUse')}
</Typography.Text>
</Flex>
</div>
<div style={cardStyles.featureList}>
{renderFeature(`${plans.free_tier_storage} ${t('storage')}`)}
{renderFeature(`${plans.projects_limit} ${t('projects')}`)}
{renderFeature(`${plans.team_member_limit} ${t('teamMembers')}`)}
</div>
</Card>
</Col>
{/* Annual Plan */}
<Col span={8} style={{ padding: '0 4px' }}>
<Card
style={{ ...isSelected(paddlePlans.ANNUAL), height: '100%' }}
hoverable
title={
<span style={cardStyles.title}>
{t('annualPlan')}{' '}
<Tag color="volcano" style={{ lineHeight: '21px' }}>
{t('tag')}
</Tag>
</span>
}
onClick={() => setSelectedCard(paddlePlans.ANNUAL)}
>
<div style={cardStyles.priceContainer}>
<Flex justify="space-between" align="center">
<Typography.Title level={1}>$ {plans.annual_price}</Typography.Title>
<Typography.Text>seat / month</Typography.Text>
</Flex>
<Flex justify="center" align="center">
<Typography.Text strong style={{ fontSize: '16px' }}>
Total ${calculateAnnualTotal(plans.annual_price)}/ year
<Tooltip
title={
'$' + plans.annual_price + ' x 12 months x ' + selectedSeatCount + ' seats'
}
>
<InfoCircleOutlined
style={{ color: 'grey', fontSize: '16px', marginLeft: '4px' }}
/>
</Tooltip>
</Typography.Text>
</Flex>
<Flex justify="center" align="center">
<Typography.Text>{t('billedAnnually')}</Typography.Text>
</Flex>
</div>
<div style={cardStyles.featureList} className="mt-4">
{renderFeature(t('startupText01'))}
{renderFeature(t('startupText02'))}
{renderFeature(t('startupText03'))}
{renderFeature(t('startupText04'))}
{renderFeature(t('startupText05'))}
</div>
</Card>
</Col>
{/* Monthly Plan */}
<Col span={8} style={{ padding: '0 4px' }}>
<Card
style={{ ...isSelected(paddlePlans.MONTHLY), height: '100%' }}
hoverable
title={<span style={cardStyles.title}>{t('monthlyPlan')}</span>}
onClick={() => setSelectedCard(paddlePlans.MONTHLY)}
>
<div style={cardStyles.priceContainer}>
<Flex justify="space-between" align="center">
<Typography.Title level={1}>$ {plans.monthly_price}</Typography.Title>
<Typography.Text>seat / month</Typography.Text>
</Flex>
<Flex justify="center" align="center">
<Typography.Text strong style={{ fontSize: '16px' }}>
Total ${calculateMonthlyTotal(plans.monthly_price)}/ month
<Tooltip
title={'$' + plans.monthly_price + ' x ' + selectedSeatCount + ' seats'}
>
<InfoCircleOutlined
style={{ color: 'grey', fontSize: '16px', marginLeft: '4px' }}
/>
</Tooltip>
</Typography.Text>
</Flex>
<Flex justify="center" align="center">
<Typography.Text>{t('billedMonthly')}</Typography.Text>
</Flex>
</div>
<div style={cardStyles.featureList}>
{renderFeature(t('startupText01'))}
{renderFeature(t('startupText02'))}
{renderFeature(t('startupText03'))}
{renderFeature(t('startupText04'))}
{renderFeature(t('startupText05'))}
</div>
</Card>
</Col>
</Row>
</Flex>
{paddleError && (
<Row justify="center" className="mt-2">
<Typography.Text type="danger">{paddleError}</Typography.Text>
</Row>
)}
<Row justify="end" className="mt-4">
{selectedPlan === paddlePlans.FREE && (
<Button
type="primary"
htmlType="submit"
loading={switchingToFreePlan}
onClick={switchToFreePlan}
>
Try for free
</Button>
)}
{selectedPlan === paddlePlans.ANNUAL && (
<Button
type="primary"
htmlType="submit"
loading={switchingToPaddlePlan || paddleLoading}
onClick={continueWithPaddlePlan}
disabled={billingInfo?.plan_id === plans.annual_plan_id}
>
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE ? t('changeToPlan', {plan: t('annualPlan')}) : t('continueWith', {plan: t('annualPlan')})}
</Button>
)}
{selectedPlan === paddlePlans.MONTHLY && (
<Button
type="primary"
htmlType="submit"
loading={switchingToPaddlePlan || paddleLoading}
onClick={continueWithPaddlePlan}
disabled={billingInfo?.plan_id === plans.monthly_plan_id}
>
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE ? t('changeToPlan', {plan: t('monthlyPlan')}) : t('continueWith', {plan: t('monthlyPlan')})}
</Button>
)}
</Row>
</div>
);
};
export default UpgradePlans;

View File

@@ -0,0 +1,222 @@
import { Button, Card, Col, Divider, Form, Input, notification, Row, Select } from 'antd';
import React, { useEffect, useState } from 'react';
import { RootState } from '../../../app/store';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IBillingConfigurationCountry } from '@/types/admin-center/country.types';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import { IBillingConfiguration } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
const Configuration: React.FC = () => {
const themeMode = useAppSelector((state: RootState) => state.themeReducer.mode);
const [countries, setCountries] = useState<IBillingConfigurationCountry[]>([]);
const [configuration, setConfiguration] = useState<IBillingConfiguration>();
const [loading, setLoading] = useState(false);
const [form] = Form.useForm();
const fetchCountries = async () => {
try {
const res = await adminCenterApiService.getCountries();
if (res.done) {
setCountries(res.body);
}
} catch (error) {
logger.error('Error fetching countries:', error);
}
};
const fetchConfiguration = async () => {
const res = await adminCenterApiService.getBillingConfiguration();
if (res.done) {
setConfiguration(res.body);
form.setFieldsValue(res.body);
}
};
useEffect(() => {
fetchCountries();
fetchConfiguration();
}, []);
const handleSave = async (values: any) => {
try {
setLoading(true);
const res = await adminCenterApiService.updateBillingConfiguration(values);
if (res.done) {
fetchConfiguration();
}
} catch (error) {
logger.error('Error updating configuration:', error);
} finally {
setLoading(false);
}
};
const countryOptions = countries.map(country => ({
label: country.name,
value: country.id,
}));
return (
<div>
<Card
title={
<span
style={{
color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
fontWeight: 500,
fontSize: '16px',
display: 'flex',
gap: '4px',
}}
>
Billing Details
</span>
}
style={{ marginTop: '16px' }}
>
<Form
form={form}
initialValues={configuration}
onFinish={handleSave}
>
<Row>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item
name="name"
label="Name"
layout="vertical"
rules={[
{
required: true,
},
]}
>
<Input placeholder="Name" />
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item
name="email"
label="Email Address"
layout="vertical"
rules={[
{
required: true,
},
]}
>
<Input placeholder="Name" disabled />
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item
name="phone"
label="Contact Number"
layout="vertical"
rules={[
{
pattern: /^\d{10}$/,
message: 'Phone number must be exactly 10 digits',
},
]}
>
<Input
placeholder="Phone Number"
maxLength={10}
onInput={e => {
const input = e.target as HTMLInputElement; // Type assertion to access 'value'
input.value = input.value.replace(/[^0-9]/g, ''); // Restrict non-numeric input
}}
/>
</Form.Item>
</Col>
</Row>
<Divider orientation="left" style={{ margin: '16px 0' }}>
<span
style={{
color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
fontWeight: 600,
fontSize: '16px',
display: 'flex',
gap: '4px',
}}
>
Company Details
</span>
</Divider>
<Row>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item name="company_name" label="Company Name" layout="vertical">
<Input placeholder="Company Name" />
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item name="address_line_1" label="Address Line 01" layout="vertical">
<Input placeholder="Address Line 01" />
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item name="address_line_2" label="Address Line 02" layout="vertical">
<Input placeholder="Address Line 02" />
</Form.Item>
</Col>
</Row>
<Row>
<Col
span={8}
style={{
padding: '0 12px',
height: '86px',
scrollbarColor: 'red',
}}
>
<Form.Item name="country" label="Country" layout="vertical">
<Select
dropdownStyle={{ maxHeight: 256, overflow: 'auto' }}
placement="topLeft"
showSearch
placeholder="Country"
optionFilterProp="label"
allowClear
options={countryOptions}
/>
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item name="city" label="City" layout="vertical">
<Input placeholder="City" />
</Form.Item>
</Col>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item name="state" label="State" layout="vertical">
<Input placeholder="State" />
</Form.Item>
</Col>
</Row>
<Row>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item name="postal_code" label="Postal Code" layout="vertical">
<Input placeholder="Postal Code" />
</Form.Item>
</Col>
</Row>
<Row>
<Col style={{ paddingLeft: '12px' }}>
<Form.Item>
<Button type="primary" htmlType="submit">
Save
</Button>
</Form.Item>
</Col>
</Row>
</Form>
</Card>
</div>
);
};
export default Configuration;

View File

@@ -0,0 +1,60 @@
import { Table, TableProps, Typography } from 'antd';
import React, { useMemo } from 'react';
import { IOrganizationAdmin } from '@/types/admin-center/admin-center.types';
interface OrganizationAdminsTableProps {
organizationAdmins: IOrganizationAdmin[] | null;
loading: boolean;
themeMode: string;
}
const { Text } = Typography;
const OrganizationAdminsTable: React.FC<OrganizationAdminsTableProps> = ({
organizationAdmins,
loading,
themeMode,
}) => {
const columns = useMemo<TableProps<IOrganizationAdmin>['columns']>(
() => [
{
title: <Text strong>Name</Text>,
dataIndex: 'name',
key: 'name',
render: (text, record) => (
<div>
<Text>
{text}
{record.is_owner && <Text> (Owner)</Text>}
</Text>
</div>
),
},
{
title: <Text strong>Email</Text>,
dataIndex: 'email',
key: 'email',
render: text => <Text>{text}</Text>,
},
],
[]
);
return (
<Table<IOrganizationAdmin>
className="organization-admins-table"
columns={columns}
dataSource={organizationAdmins || []}
loading={loading}
showHeader={false}
pagination={{
size: 'small',
pageSize: 10,
hideOnSinglePage: true,
}}
rowKey="email"
/>
);
};
export default OrganizationAdminsTable;

View File

@@ -0,0 +1,122 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import logger from '@/utils/errorLogger';
import { EnterOutlined, EditOutlined } from '@ant-design/icons';
import { Card, Button, Tooltip, Typography } from 'antd';
import TextArea from 'antd/es/input/TextArea';
import Paragraph from 'antd/es/typography/Paragraph';
import { TFunction } from 'i18next';
import { useState, useEffect } from 'react';
interface OrganizationNameProps {
themeMode: string;
name: string;
t: TFunction;
refetch: () => void;
}
const OrganizationName = ({ themeMode, name, t, refetch }: OrganizationNameProps) => {
const [isEditable, setIsEditable] = useState(false);
const [newName, setNewName] = useState(name);
useEffect(() => {
setNewName(name);
}, [name]);
const handleBlur = () => {
if (newName.trim() === '') {
setNewName(name);
setIsEditable(false);
return;
}
if (newName !== name) {
updateOrganizationName();
}
setIsEditable(false);
};
const handleNameChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setNewName(e.target.value);
};
const updateOrganizationName = async () => {
try {
const trimmedName = newName.trim();
const res = await adminCenterApiService.updateOrganizationName({ name: trimmedName });
if (res.done) {
refetch();
}
} catch (error) {
logger.error('Error updating organization name', error);
setNewName(name);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Escape') {
setNewName(name);
setIsEditable(false);
}
};
return (
<Card>
<Typography.Title level={5} style={{ margin: 0, marginBottom: '0.5rem' }}>
{t('name')}
</Typography.Title>
<div style={{ paddingTop: '8px' }}>
<div style={{ marginBottom: '8px' }}>
{isEditable ? (
<div style={{ position: 'relative' }}>
<TextArea
style={{
height: '32px',
paddingRight: '40px',
resize: 'none',
borderRadius: '4px',
}}
onPressEnter={handleBlur}
value={newName}
onChange={handleNameChange}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
autoFocus
maxLength={100}
placeholder={t('enterOrganizationName')}
/>
<Button
icon={<EnterOutlined style={{ color: '#1890ff' }} />}
type="text"
style={{
position: 'absolute',
right: '4px',
top: '50%',
transform: 'translateY(-50%)',
padding: '4px 8px',
color: '#1890ff',
}}
onClick={handleBlur}
/>
</div>
) : (
<Typography.Text>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
{name}
<Tooltip title={t('edit')}>
<Button
onClick={() => setIsEditable(true)}
size="small"
type="text"
icon={<EditOutlined />}
style={{ padding: '4px', color: '#1890ff' }}
/>
</Tooltip>
</div>
</Typography.Text>
)}
</div>
</div>
</Card>
);
};
export default OrganizationName;

View File

@@ -0,0 +1,120 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import { IOrganization } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { MailOutlined, PhoneOutlined, EditOutlined } from '@ant-design/icons';
import { Card, Tooltip, Input, Button, Typography, InputRef } from 'antd';
import { TFunction } from 'i18next';
import { useEffect, useRef, useState } from 'react';
interface OrganizationOwnerProps {
themeMode: string;
organization: IOrganization | null;
t: TFunction;
refetch: () => void;
}
const OrganizationOwner = ({ themeMode, organization, t, refetch }: OrganizationOwnerProps) => {
const [isEditableContactNumber, setIsEditableContactNumber] = useState(false);
const [number, setNumber] = useState(organization?.contact_number || '');
const contactNoRef = useRef<InputRef>(null);
const handleContactNumberBlur = () => {
setIsEditableContactNumber(false);
updateOrganizationContactNumber();
};
const updateOrganizationContactNumber = async () => {
try {
const res = await adminCenterApiService.updateOwnerContactNumber({ contact_number: number });
if (res.done) {
refetch();
}
} catch (error) {
logger.error('Error updating organization contact number:', error);
}
};
const addContactNumber = () => {
setIsEditableContactNumber(true);
setTimeout(() => {
contactNoRef.current?.focus();
}, 500);
};
const handleEditContactNumber = () => {
setIsEditableContactNumber(true);
};
const handleContactNumber = (e: React.ChangeEvent<HTMLInputElement>) => {
const inputValue = e.target.value;
setNumber(inputValue);
};
return (
<Card>
<Typography.Title level={5} style={{ margin: 0, marginBottom: '0.5rem' }}>
{t('owner')}
</Typography.Title>
<div style={{ paddingTop: '8px' }}>
<div style={{ marginBottom: '8px' }}>
<Typography.Text
style={{
color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
}}
>
{organization?.owner_name || ''}
</Typography.Text>
</div>
</div>
<Typography.Paragraph style={{ display: 'flex', alignItems: 'center', margin: 0 }}>
<Typography.Text
style={{
color: `${themeMode === 'dark' ? '#ffffffd9' : '#000000d9'}`,
}}
>
<span style={{ marginRight: '8px' }}>
<Tooltip title="Email Address">
<MailOutlined />
</Tooltip>
</span>
{organization?.email || ''}
</Typography.Text>
</Typography.Paragraph>
<Typography.Paragraph style={{ marginTop: '0.5rem', marginBottom: 0 }}>
<Tooltip title="Contact Number">
<span style={{ marginRight: '8px' }}>
<PhoneOutlined />
</span>
</Tooltip>
{isEditableContactNumber ? (
<Input
onChange={handleContactNumber}
onPressEnter={handleContactNumberBlur}
onBlur={handleContactNumberBlur}
style={{ width: '200px' }}
value={number}
type="text"
maxLength={15}
ref={contactNoRef}
/>
) : number === '' ? (
<Typography.Link onClick={addContactNumber}>{t('contactNumber')}</Typography.Link>
) : (
<Typography.Text>
{number}
<Tooltip title="Edit">
<Button
onClick={handleEditContactNumber}
size="small"
type="link"
icon={<EditOutlined />}
/>
</Tooltip>
</Typography.Text>
)}
</Typography.Paragraph>
</Card>
);
};
export default OrganizationOwner;

View File

@@ -0,0 +1,85 @@
import React, { useRef, useState } from 'react';
import { Button, Drawer, Form, Input, InputRef, Typography } from 'antd';
import { fetchTeams } from '@features/teams/teamSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import logger from '@/utils/errorLogger';
import { teamsApiService } from '@/api/teams/teams.api.service';
interface AddTeamDrawerProps {
isDrawerOpen: boolean;
onClose: () => void;
reloadTeams: () => void;
}
const AddTeamDrawer: React.FC<AddTeamDrawerProps> = ({ isDrawerOpen, onClose, reloadTeams }) => {
const { t } = useTranslation('admin-center/teams');
const dispatch = useAppDispatch();
const [form] = Form.useForm();
const [creating, setCreating] = useState(false);
const addTeamNameInputRef = useRef<InputRef>(null);
const handleFormSubmit = async (values: any) => {
if (!values.name || values.name.trim() === '') return;
try {
setCreating(true);
const newTeam = {
name: values.name,
};
const res = await teamsApiService.createTeam(newTeam);
if (res.done) {
onClose();
reloadTeams();
dispatch(fetchTeams());
}
} catch (error) {
logger.error('Error adding team', error);
} finally {
setCreating(false);
}
form.resetFields();
};
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{t('drawerTitle')}
</Typography.Text>
}
open={isDrawerOpen}
destroyOnClose
afterOpenChange={() => {
setTimeout(() => {
addTeamNameInputRef.current?.focus();
}, 100);
}}
onClose={onClose}
>
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
<Form.Item
name="name"
label={t('label')}
rules={[
{
required: true,
message: t('message'),
},
]}
>
<Input placeholder={t('drawerPlaceholder')} ref={addTeamNameInputRef} />
</Form.Item>
<Form.Item>
<Button type="primary" style={{ width: '100%' }} htmlType="submit" loading={creating}>
{t('create')}
</Button>
</Form.Item>
</Form>
</Drawer>
);
};
export default AddTeamDrawer;

View File

@@ -0,0 +1,4 @@
.setting-team-table .ant-table-thead > tr > th,
.setting-team-table .ant-table-tbody > tr > td {
padding: 12px 10px !important;
}

View File

@@ -0,0 +1,198 @@
import {
Avatar,
Button,
Drawer,
Flex,
Form,
Input,
message,
Select,
Table,
TableProps,
Typography,
} from 'antd';
import React, { useState } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleSettingDrawer, updateTeam } from '@/features/teams/teamSlice';
import { TeamsType } from '@/types/admin-center/team.types';
import './settings-drawer.css';
import CustomAvatar from '@/components/CustomAvatar';
import { teamsApiService } from '@/api/teams/teams.api.service';
import logger from '@/utils/errorLogger';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import {
IOrganizationTeam,
IOrganizationTeamMember,
} from '@/types/admin-center/admin-center.types';
import Avatars from '@/components/avatars/avatars';
import { AvatarNamesMap } from '@/shared/constants';
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
import { useTranslation } from 'react-i18next';
interface SettingTeamDrawerProps {
teamId: string;
isSettingDrawerOpen: boolean;
setIsSettingDrawerOpen: (value: boolean) => void;
}
const SettingTeamDrawer: React.FC<SettingTeamDrawerProps> = ({
teamId,
isSettingDrawerOpen,
setIsSettingDrawerOpen,
}) => {
const dispatch = useAppDispatch();
const { t } = useTranslation('admin-center/teams');
const [form] = Form.useForm();
const [teamData, setTeamData] = useState<IOrganizationTeam | null>(null);
const [loadingTeamMembers, setLoadingTeamMembers] = useState(false);
const [updatingTeam, setUpdatingTeam] = useState(false);
const [total, setTotal] = useState(0);
const fetchTeamMembers = async () => {
if (!teamId) return;
try {
setLoadingTeamMembers(true);
const res = await adminCenterApiService.getOrganizationTeam(teamId);
if (res.done) {
setTeamData(res.body);
setTotal(res.body.team_members?.length || 0);
form.setFieldsValue({ name: res.body.name || '' });
}
} catch (error) {
logger.error('Error fetching team members', error);
} finally {
setLoadingTeamMembers(false);
}
};
const handleFormSubmit = async (values: any) => {
console.log(values);
// const newTeam: TeamsType = {
// teamId: teamId,
// teamName: values.name,
// membersCount: team?.membersCount || 1,
// members: team?.members || ['Raveesha Dilanka'],
// owner: values.name,
// created: team?.created || new Date(),
// isActive: false,
// };
// dispatch(updateTeam(newTeam));
// dispatch(toggleSettingDrawer());
// form.resetFields();
// message.success('Team updated!');
};
const roleOptions = [
{ value: 'Admin', label: t('admin') },
{ value: 'Member', label: t('member') },
{ value: 'Owner', label: t('owner') },
];
const columns: TableProps['columns'] = [
{
title: t('user'),
key: 'user',
render: (_, record: IOrganizationTeamMember) => (
<Flex align="center" gap="8px" key={record.id}>
<SingleAvatar avatarUrl={record.avatar_url} name={record.name} />
<Typography.Text>{record.name || ''}</Typography.Text>
</Flex>
),
},
{
title: t('role'),
key: 'role',
render: (_, record: IOrganizationTeamMember) => (
<div>
<Select
style={{ width: '150px', height: '32px' }}
options={roleOptions.map(option => ({ ...option, key: option.value }))}
defaultValue={record.role_name || ''}
disabled={record.role_name === 'Owner'}
/>
</div>
),
},
];
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{t('teamSettings')}
</Typography.Text>
}
width={550}
open={isSettingDrawerOpen}
onClose={() => {
form.resetFields();
setTeamData(null);
setTimeout(() => {
setIsSettingDrawerOpen(false);
}, 100);
}}
destroyOnClose
afterOpenChange={open => {
if (open) {
form.resetFields();
setTeamData(null);
fetchTeamMembers();
}
}}
footer={
<Flex justify="end">
<Button type="primary" onClick={form.submit} loading={updatingTeam}>
{t('update')}
</Button>
</Flex>
}
>
<Form
form={form}
layout="vertical"
onFinish={handleFormSubmit}
initialValues={{
name: teamData?.name,
}}
>
<Form.Item
name="name"
key="name"
label={t('teamName')}
rules={[
{
required: true,
message: t('message'),
},
]}
>
<Input placeholder={t('teamNamePlaceholder')} />
</Form.Item>
<Form.Item
name="users"
label={
<span>
{t('members')} ({teamData?.team_members?.length})
</span>
}
>
<Table
className="setting-team-table"
style={{ marginBottom: '24px' }}
columns={columns}
dataSource={teamData?.team_members?.map(member => ({ ...member, key: member.id }))}
pagination={false}
loading={loadingTeamMembers}
rowKey={record => record.team_member_id}
size="small"
/>
</Form.Item>
</Form>
</Drawer>
);
};
export default SettingTeamDrawer;

View File

@@ -0,0 +1,146 @@
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
import Avatars from '@/components/avatars/avatars';
import SettingTeamDrawer from '@/components/admin-center/teams/settings-drawer/settings-drawer';
import { toggleSettingDrawer, deleteTeam, fetchTeams } from '@/features/teams/teamSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { IOrganizationTeam } from '@/types/admin-center/admin-center.types';
import logger from '@/utils/errorLogger';
import { SettingOutlined, DeleteOutlined } from '@ant-design/icons';
import { Badge, Button, Card, Popconfirm, Table, TableProps, Tooltip, Typography } from 'antd';
import { TFunction } from 'i18next';
import { useState } from 'react';
import { useMediaQuery } from 'react-responsive';
interface TeamsTableProps {
teams: IOrganizationTeam[];
currentTeam: IOrganizationTeam | null;
t: TFunction;
loading: boolean;
reloadTeams: () => void;
}
const TeamsTable: React.FC<TeamsTableProps> = ({
teams,
currentTeam = null,
t,
loading,
reloadTeams,
}) => {
const dispatch = useAppDispatch();
const isTablet = useMediaQuery({ query: '(min-width: 1000px)' });
const [deleting, setDeleting] = useState(false);
const [isSettingDrawerOpen, setIsSettingDrawerOpen] = useState(false);
const [selectedTeam, setSelectedTeam] = useState<string>('');
const handleTeamDelete = async (teamId: string) => {
if (!teamId) return;
try {
setDeleting(true);
const res = await adminCenterApiService.deleteTeam(teamId);
if (res.done) {
reloadTeams();
dispatch(fetchTeams());
}
} catch (error) {
logger.error('Error deleting team', error);
} finally {
setDeleting(false);
}
};
const columns: TableProps['columns'] = [
{
title: t('team'),
key: 'teamName',
render: (record: IOrganizationTeam) => (
<Typography.Text style={{ fontSize: `${isTablet ? '14px' : '10px'}` }}>
<Badge
status={currentTeam?.id === record.id ? 'success' : 'default'}
style={{ marginRight: '8px' }}
/>
{record.name}
</Typography.Text>
),
},
{
title: <span style={{ display: 'flex', justifyContent: 'center' }}>{t('membersCount')}</span>,
key: 'membersCount',
render: (record: IOrganizationTeam) => (
<Typography.Text
style={{
display: 'flex',
justifyContent: 'center',
fontSize: `${isTablet ? '14px' : '10px'}`,
}}
>
{record.members_count}
</Typography.Text>
),
},
{
title: t('members'),
key: 'members',
render: (record: IOrganizationTeam) => (
<span>
<Avatars members={record.names} />
</span>
),
},
{
title: '',
key: 'button',
render: (record: IOrganizationTeam) => (
<div className="row-buttons">
<Tooltip title={t('settings')}>
<Button
style={{ marginRight: '8px' }}
size="small"
onClick={() => {
setSelectedTeam(record.id || '');
setIsSettingDrawerOpen(true);
}}
>
<SettingOutlined />
</Button>
</Tooltip>
<Tooltip title={t('delete')}>
<Popconfirm title={t('popTitle')} onConfirm={() => handleTeamDelete(record.id || '')}>
<Button size="small">
<DeleteOutlined />
</Button>
</Popconfirm>
</Tooltip>
</div>
),
},
];
return (
<>
<Card>
<Table
rowClassName="team-table-row"
className="team-table"
size="small"
columns={columns}
dataSource={teams}
rowKey={record => record.id}
loading={loading}
pagination={{
showSizeChanger: true,
defaultPageSize: 20,
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
}}
/>
</Card>
<SettingTeamDrawer
teamId={selectedTeam}
isSettingDrawerOpen={isSettingDrawerOpen}
setIsSettingDrawerOpen={setIsSettingDrawerOpen}
/>
</>
);
};
export default TeamsTable;

View File

@@ -0,0 +1,46 @@
import { Avatar, Tooltip } from 'antd';
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
interface AvatarsProps {
members: InlineMember[];
maxCount?: number;
}
const renderAvatar = (member: InlineMember, index: number) => (
<Tooltip
key={member.team_member_id || index}
title={member.end && member.names ? member.names.join(', ') : member.name}
>
{member.avatar_url ? (
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
</span>
) : (
<span onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Avatar
size={28}
key={member.team_member_id || index}
style={{
backgroundColor: member.color_code || '#ececec',
fontSize: '14px',
}}
>
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
</Avatar>
</span>
)}
</Tooltip>
);
const Avatars: React.FC<AvatarsProps> = ({ members, maxCount }) => {
const visibleMembers = maxCount ? members.slice(0, maxCount) : members;
return (
<div onClick={(e: React.MouseEvent) => e.stopPropagation()}>
<Avatar.Group>
{visibleMembers.map((member, index) => renderAvatar(member, index))}
</Avatar.Group>
</div>
);
};
export default Avatars;

View File

@@ -0,0 +1,222 @@
import { InputRef } from 'antd/es/input';
import Card from 'antd/es/card';
import Checkbox from 'antd/es/checkbox';
import Divider from 'antd/es/divider';
import Dropdown from 'antd/es/dropdown';
import Empty from 'antd/es/empty';
import Flex from 'antd/es/flex';
import Input from 'antd/es/input';
import List from 'antd/es/list';
import Typography from 'antd/es/typography';
import Button from 'antd/es/button';
import { useMemo, useRef, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleProjectMemberDrawer } from '../../../features/projects/singleProject/members/projectMembersSlice';
import { colors } from '../../../styles/colors';
import { PlusOutlined, UsergroupAddOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
import { CheckboxChangeEvent } from 'antd/es/checkbox';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
import { sortByBooleanField, sortBySelection, sortTeamMembers } from '@/utils/sort-team-members';
import { useAuthService } from '@/hooks/useAuth';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { getTeamMembers } from '@/features/team-members/team-members.slice';
interface BoardAssigneeSelectorProps {
task: IProjectTask;
groupId: string | null;
}
const BoardAssigneeSelector = ({ task, groupId = null }: BoardAssigneeSelectorProps) => {
const membersInputRef = useRef<InputRef>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
const [teamMembers, setTeamMembers] = useState<ITeamMembersViewModel>({ data: [], total: 0 });
const { projectId } = useAppSelector(state => state.projectReducer);
const currentSession = useAuthService().getCurrentSession();
const { socket } = useSocket();
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { t } = useTranslation('task-list-table');
const dispatch = useAppDispatch();
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
const filteredMembersData = useMemo(() => {
return teamMembers?.data?.filter(member =>
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [teamMembers, searchQuery]);
const handleInviteProjectMemberDrawer = () => {
dispatch(toggleProjectMemberDrawer());
};
const handleMembersDropdownOpen = (open: boolean) => {
if (open) {
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
const membersData = (members?.data || []).map(member => ({
...member,
selected: assignees?.includes(member.id),
}));
let sortedMembers = sortTeamMembers(membersData);
setTeamMembers({ data: sortedMembers });
setTimeout(() => {
membersInputRef.current?.focus();
}, 0);
} else {
setTeamMembers(members || { data: [] });
}
};
const handleMemberChange = (e: CheckboxChangeEvent | null, memberId: string) => {
if (!memberId || !projectId || !task?.id || !currentSession?.id) return;
const checked =
e?.target.checked ||
!task?.assignees?.some(assignee => assignee.team_member_id === memberId) ||
false;
const body = {
team_member_id: memberId,
project_id: projectId,
task_id: task.id,
reporter_id: currentSession?.id,
mode: checked ? 0 : 1,
parent_task: task.parent_task_id,
};
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body));
};
const checkMemberSelected = (memberId: string) => {
if (!memberId) return false;
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
return assignees?.includes(memberId);
};
const membersDropdownContent = (
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
<Flex vertical>
<Input
ref={membersInputRef}
value={searchQuery}
onChange={e => setSearchQuery(e.currentTarget.value)}
placeholder={t('searchInputPlaceholder')}
/>
<List style={{ padding: 0, height: 250, overflow: 'auto' }}>
{filteredMembersData?.length ? (
filteredMembersData.map(member => (
<List.Item
className={`${themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'} ${member.pending_invitation ? 'disabled cursor-not-allowed' : ''}`}
key={member.id}
style={{
display: 'flex',
gap: 8,
justifyContent: 'flex-start',
padding: '4px 8px',
border: 'none',
cursor: 'pointer',
}}
onClick={e => handleMemberChange(null, member.id || '')}
>
<Checkbox
id={member.id}
checked={checkMemberSelected(member.id || '')}
onChange={e => handleMemberChange(e, member.id || '')}
disabled={member.pending_invitation}
/>
<div>
<SingleAvatar
avatarUrl={member.avatar_url}
name={member.name}
email={member.email}
/>
</div>
<Flex vertical>
<Typography.Text>{member.name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{member.email}&nbsp;
{member.pending_invitation && (
<Typography.Text type="danger" style={{ fontSize: 10 }}>
({t('pendingInvitation')})
</Typography.Text>
)}
</Typography.Text>
</Flex>
</List.Item>
))
) : (
<Empty />
)}
</List>
<Divider style={{ marginBlock: 0 }} />
<Button
icon={<UsergroupAddOutlined />}
type="text"
style={{
color: colors.skyBlue,
border: 'none',
backgroundColor: colors.transparent,
width: '100%',
}}
onClick={handleInviteProjectMemberDrawer}
>
{t('assigneeSelectorInviteButton')}
</Button>
{/* <Divider style={{ marginBlock: 8 }} /> */}
{/* <Button
type="primary"
style={{ alignSelf: 'flex-end' }}
size="small"
onClick={handleAssignMembers}
>
{t('okButton')}
</Button> */}
</Flex>
</Card>
);
return (
<Dropdown
overlayClassName="custom-dropdown"
trigger={['click']}
dropdownRender={() => membersDropdownContent}
onOpenChange={handleMembersDropdownOpen}
>
<Button
type="dashed"
shape="circle"
size="small"
onClick={(e) => e.stopPropagation()}
icon={
<PlusOutlined
style={{
fontSize: 12,
width: 22,
height: 22,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
}
/>
</Dropdown>
);
};
export default BoardAssigneeSelector;

View File

@@ -0,0 +1,13 @@
.status-drawer-dropdown .ant-dropdown-menu {
padding: 0 !important;
margin-top: 8px !important;
overflow: hidden;
}
.status-drawer-dropdown .ant-dropdown-menu-item {
padding: 0 !important;
}
.status-drawer-dropdown .ant-card-body {
padding: 0 !important;
}

View File

@@ -0,0 +1,110 @@
import { Badge, Card, Dropdown, Flex, Menu, MenuProps } from 'antd';
import React from 'react';
import { TaskStatusType } from '../../../types/task.types';
import { colors } from '../../../styles/colors';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { RetweetOutlined, RightOutlined } from '@ant-design/icons';
import './ChangeCategoryDropdown.css';
import { updateStatusCategory } from '../../../features/projects/status/StatusSlice';
import { useTranslation } from 'react-i18next';
interface ChangeCategoryDropdownProps {
id: string;
}
const ChangeCategoryDropdown: React.FC<ChangeCategoryDropdownProps> = ({ id }) => {
const dispatch = useAppDispatch();
// const [currentStatus, setCurrentStatus] = useState(category);
const { t } = useTranslation('kanban-board');
const getStatuColor = (status: TaskStatusType) => {
if (status === 'todo') return colors.deepLightGray;
else if (status === 'doing') return colors.midBlue;
else return colors.lightGreen;
};
// menu type
type MenuItem = Required<MenuProps>['items'][number];
// status menu item
const statusMenuItems: MenuItem[] = [
{
key: 'todo',
label: (
<Flex gap={4}>
<Badge color={getStatuColor('todo')} /> Todo
</Flex>
),
},
{
key: 'doing',
label: (
<Flex gap={4}>
<Badge color={getStatuColor('doing')} /> Doing
</Flex>
),
},
{
key: 'done',
label: (
<Flex gap={4}>
<Badge color={getStatuColor('done')} /> Done
</Flex>
),
},
];
const onClick: MenuProps['onClick'] = e => {
if (e.key === 'todo') {
dispatch(updateStatusCategory({ id: id, category: 'todo' }));
} else if (e.key === 'doing') {
dispatch(updateStatusCategory({ id: id, category: 'doing' }));
} else if (e.key === 'done') {
dispatch(updateStatusCategory({ id: id, category: 'done' }));
}
};
const statusDropdownItems: MenuProps['items'] = [
{
key: '1',
label: (
<Card className="status-dropdown-card" bordered={false}>
<Menu
className="status-menu"
items={statusMenuItems}
defaultValue={'todo'}
onClick={onClick}
/>
</Card>
),
},
];
return (
<>
<Dropdown
menu={{ items: statusDropdownItems }}
overlayStyle={{
paddingLeft: '185px',
paddingBottom: '100px',
top: '350px',
}}
overlayClassName="status-drawer-dropdown"
>
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
width: '100%',
padding: '5px 12px',
gap: '8px',
}}
>
<RetweetOutlined /> <span>{t('changeCategory')}</span>{' '}
<RightOutlined style={{ color: '#00000073', fontSize: '10px' }} />
</div>
</Dropdown>
</>
);
};
export default ChangeCategoryDropdown;

View File

@@ -0,0 +1,297 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Dropdown, Input, InputRef, MenuProps, Typography } from 'antd';
import {
DeleteOutlined,
EditOutlined,
LoadingOutlined,
MoreOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { setTaskCardDisabled, initializeStatus } from '../../../features/board/create-card.slice';
import { TaskType } from '../../../types/task.types';
import TaskCreateCard from '../taskCreateCard/TaskCreateCard';
import TaskCard from '../taskCard/TaskCard';
import { useAppSelector } from '@/hooks/useAppSelector';
import '../commonStatusSection/CommonStatusSection';
import { deleteStatus } from '../../../features/projects/status/StatusSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import ChangeCategoryDropdown from '../changeCategoryDropdown/ChangeCategoryDropdown';
import { useTranslation } from 'react-i18next';
interface CommonMembersSectionProps {
status: string;
dataSource: TaskType[];
category: string;
id: string;
}
const CommonMembersSection: React.FC<CommonMembersSectionProps> = ({
status,
dataSource,
category,
id,
}) => {
const dispatch = useAppDispatch();
const createTaskInputRef = useRef<InputRef>(null);
const colorPalette = ['#d1d0d3', '#b9cef1', '#c2e4d0', '#f9e3b1', '#f6bfc0'];
const getRandomColorFromPalette = () =>
colorPalette[Math.floor(Math.random() * colorPalette.length)];
useEffect(() => {
dispatch(initializeStatus(status));
}, [dispatch, status]);
const isTopCardDisabled = useAppSelector(
state => state.createCardReducer.taskCardDisabledStatus[status]?.top
);
const isBottomCardDisabled = useAppSelector(
state => state.createCardReducer.taskCardDisabledStatus[status]?.bottom
);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const [addTaskCount, setAddTaskCount] = useState(0);
const [name, setName] = useState(status);
const [isEditable, setIsEditable] = useState(false);
const inputRef = useRef<InputRef>(null);
const [isLoading, setIsLoading] = useState(false);
const taskCardRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation('kanban-board');
const handleAddTaskClick = () => {
dispatch(setTaskCardDisabled({ status, position: 'bottom', disabled: false }));
setAddTaskCount(prev => prev + 1);
};
const handleTopAddTaskClick = () => {
dispatch(setTaskCardDisabled({ status, position: 'top', disabled: false }));
setAddTaskCount(prev => prev + 1);
};
useEffect(() => {
if (isEditable && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditable]);
useEffect(() => {
createTaskInputRef.current?.focus();
taskCardRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}, [dataSource, addTaskCount]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
};
const handleBlur = () => {
setIsEditable(false);
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
}, 3000);
};
const items: MenuProps['items'] = [
{
key: '1',
label: (
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
width: '100%',
padding: '5px 12px',
gap: '8px',
}}
onClick={() => setIsEditable(true)}
>
<EditOutlined /> <span>{t('rename')}</span>
</div>
),
},
{
key: '2',
label: <ChangeCategoryDropdown id={id} />,
},
{
key: '3',
label: (
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
width: '100%',
padding: '5px 12px',
gap: '8px',
}}
onClick={() => dispatch(deleteStatus(id))}
>
<DeleteOutlined /> <span>{t('delete')}</span>
</div>
),
},
];
return (
<div style={{ paddingTop: '6px' }}>
<div
className={`todo-wraper ${themeMode === 'dark' ? 'dark-mode' : ''}`}
style={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
flexBasis: 0,
maxWidth: '375px',
width: '375px',
marginRight: '8px',
padding: '8px',
borderRadius: '25px',
maxHeight: 'calc(100vh - 250px)',
backgroundColor: themeMode === 'dark' ? '#282828' : '#F8FAFC',
}}
>
<div
style={{
touchAction: 'none',
userSelect: 'none',
cursor: 'grab',
fontSize: '14px',
paddingTop: '0',
margin: '0.25rem',
}}
>
<div
style={{
fontWeight: 600,
marginBottom: '12px',
alignItems: 'center',
padding: '8px',
display: 'flex',
justifyContent: 'space-between',
backgroundColor: getRandomColorFromPalette(),
borderRadius: '10px',
}}
>
<div
style={{ display: 'flex', gap: '5px', alignItems: 'center' }}
onClick={() => setIsEditable(true)}
>
{isLoading ? (
<LoadingOutlined />
) : (
<Button
type="text"
size="small"
shape="circle"
style={{
backgroundColor: themeMode === 'dark' ? '#383838' : 'white',
}}
>
{dataSource.length}
</Button>
)}
{isEditable ? (
<Input
ref={inputRef}
value={name}
variant="borderless"
style={{
backgroundColor: themeMode === 'dark' ? 'black' : 'white',
}}
onChange={handleChange}
onBlur={handleBlur}
onPressEnter={handleBlur}
/>
) : (
<Typography.Text
style={{
textTransform: 'capitalize',
color: themeMode === 'dark' ? '#383838' : '',
}}
>
{name}
</Typography.Text>
)}
</div>
<div style={{ display: 'flex' }}>
<Button
type="text"
size="small"
shape="circle"
onClick={handleTopAddTaskClick}
style={{ color: themeMode === 'dark' ? '#383838' : '' }}
>
<PlusOutlined />
</Button>
<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>
</div>
</div>
<div
style={{
overflowY: 'auto',
maxHeight: 'calc(100vh - 250px)',
padding: '2px 6px 2px 2px',
}}
>
{!isTopCardDisabled && (
<TaskCreateCard ref={createTaskInputRef} status={status} position={'top'} />
)}
{dataSource.map(task => (
<TaskCard key={task.taskId} task={task} />
))}
{!isBottomCardDisabled && (
<TaskCreateCard ref={createTaskInputRef} status={status} position={'bottom'} />
)}
</div>
<div
style={{
textAlign: 'center',
margin: '7px 8px 8px 8px',
backgroundColor: themeMode === 'dark' ? '#383838' : 'white',
padding: '0',
}}
>
<Button
type="text"
style={{
height: '38px',
width: '100%',
}}
icon={<PlusOutlined />}
onClick={handleAddTaskClick}
>
{t('addTask')}
</Button>
</div>
</div>
</div>
);
};
export default CommonMembersSection;

View File

@@ -0,0 +1,294 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Dropdown, Input, InputRef, MenuProps, Typography } from 'antd';
import {
DeleteOutlined,
EditOutlined,
LoadingOutlined,
MoreOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { setTaskCardDisabled, initializeStatus } from '../../../features/board/create-card.slice';
import { TaskType } from '../../../types/task.types';
import TaskCreateCard from '../taskCreateCard/TaskCreateCard';
import TaskCard from '../taskCard/TaskCard';
import { useAppSelector } from '@/hooks/useAppSelector';
import '../commonStatusSection/CommonStatusSection';
import { deleteStatus } from '../../../features/projects/status/StatusSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import ChangeCategoryDropdown from '../changeCategoryDropdown/ChangeCategoryDropdown';
import { useTranslation } from 'react-i18next';
interface CommonPhaseSectionProps {
status: string;
dataSource: TaskType[];
category: string;
id: string;
}
const CommonPhaseSection: React.FC<CommonPhaseSectionProps> = ({
status,
dataSource,
category,
id,
}) => {
const dispatch = useAppDispatch();
const createTaskInputRef = useRef<InputRef>(null);
// Initialize status in the Redux store if not already set
useEffect(() => {
dispatch(initializeStatus(status));
}, [dispatch, status]);
// Get status-specific disable controls from Redux state
const isTopCardDisabled = useAppSelector(
state => state.createCardReducer.taskCardDisabledStatus[status]?.top
);
const isBottomCardDisabled = useAppSelector(
state => state.createCardReducer.taskCardDisabledStatus[status]?.bottom
);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const [addTaskCount, setAddTaskCount] = useState(0);
const [name, setName] = useState(status);
const [isEditable, setIsEditable] = useState(false);
const inputRef = useRef<InputRef>(null);
const [isLoading, setIsLoading] = useState(false);
const taskCardRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation('kanban-board');
const handleAddTaskClick = () => {
dispatch(setTaskCardDisabled({ status, position: 'bottom', disabled: false }));
setAddTaskCount(prev => prev + 1);
};
const handleTopAddTaskClick = () => {
dispatch(setTaskCardDisabled({ status, position: 'top', disabled: false }));
setAddTaskCount(prev => prev + 1);
};
useEffect(() => {
if (isEditable && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditable]);
useEffect(() => {
createTaskInputRef.current?.focus();
taskCardRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}, [dataSource, addTaskCount]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
};
const handleBlur = () => {
setIsEditable(false);
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
}, 3000);
};
const items: MenuProps['items'] = [
{
key: '1',
label: (
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
width: '100%',
padding: '5px 12px',
gap: '8px',
}}
onClick={() => setIsEditable(true)}
>
<EditOutlined /> <span>{t('rename')}</span>
</div>
),
},
{
key: '2',
label: <ChangeCategoryDropdown id={id} />,
},
{
key: '3',
label: (
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
width: '100%',
padding: '5px 12px',
gap: '8px',
}}
onClick={() => dispatch(deleteStatus(id))}
>
<DeleteOutlined /> <span>{t('delete')}</span>
</div>
),
},
];
return (
<div style={{ paddingTop: '6px' }}>
<div
className={`todo-wraper ${themeMode === 'dark' ? 'dark-mode' : ''}`}
style={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
flexBasis: 0,
maxWidth: '375px',
width: '375px',
marginRight: '8px',
padding: '8px',
borderRadius: '25px',
maxHeight: 'calc(100vh - 250px)',
backgroundColor: themeMode === 'dark' ? '#282828' : '#F8FAFC',
}}
>
<div
style={{
touchAction: 'none',
userSelect: 'none',
cursor: 'grab',
fontSize: '14px',
paddingTop: '0',
margin: '0.25rem',
}}
>
<div
style={{
fontWeight: 600,
marginBottom: '12px',
alignItems: 'center',
padding: '8px',
display: 'flex',
justifyContent: 'space-between',
backgroundColor: category === 'unmapped' ? 'rgba(251, 200, 76, 0.41)' : '#d1d0d3',
borderRadius: '10px',
}}
>
<div
style={{ display: 'flex', gap: '5px', alignItems: 'center' }}
onClick={() => setIsEditable(true)}
>
{isLoading ? (
<LoadingOutlined />
) : (
<Button
type="text"
size="small"
shape="circle"
style={{
backgroundColor: themeMode === 'dark' ? '#383838' : 'white',
}}
>
{dataSource.length}
</Button>
)}
{isEditable ? (
<Input
ref={inputRef}
value={name}
variant="borderless"
style={{
backgroundColor: themeMode === 'dark' ? 'black' : 'white',
}}
onChange={handleChange}
onBlur={handleBlur}
onPressEnter={handleBlur}
/>
) : (
<Typography.Text
style={{
textTransform: 'capitalize',
color: themeMode === 'dark' ? '#383838' : '',
}}
>
{name}
</Typography.Text>
)}
</div>
<div style={{ display: 'flex' }}>
<Button
type="text"
size="small"
shape="circle"
onClick={handleTopAddTaskClick}
style={{ color: themeMode === 'dark' ? '#383838' : '' }}
>
<PlusOutlined />
</Button>
<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>
</div>
</div>
<div
style={{
overflowY: 'auto',
maxHeight: 'calc(100vh - 250px)',
padding: '2px 6px 2px 2px',
}}
>
{!isTopCardDisabled && (
<TaskCreateCard ref={createTaskInputRef} status={status} position={'top'} />
)}
{dataSource.map(task => (
<TaskCard key={task.taskId} task={task} />
))}
{!isBottomCardDisabled && (
<TaskCreateCard ref={createTaskInputRef} status={status} position={'bottom'} />
)}
</div>
<div
style={{
textAlign: 'center',
margin: '7px 8px 8px 8px',
backgroundColor: themeMode === 'dark' ? '#383838' : 'white',
padding: '0',
}}
>
<Button
type="text"
style={{
height: '38px',
width: '100%',
}}
icon={<PlusOutlined />}
onClick={handleAddTaskClick}
>
{t('addTask')}
</Button>
</div>
</div>
</div>
);
};
export default CommonPhaseSection;

View File

@@ -0,0 +1,295 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Dropdown, Input, InputRef, MenuProps, Typography } from 'antd';
import {
DeleteOutlined,
EditOutlined,
LoadingOutlined,
MoreOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { setTaskCardDisabled, initializeStatus } from '../../../features/board/create-card.slice';
import { TaskType } from '../../../types/task.types';
import TaskCreateCard from '../taskCreateCard/TaskCreateCard';
import TaskCard from '../taskCard/TaskCard';
import { useAppSelector } from '@/hooks/useAppSelector';
import '../commonStatusSection/CommonStatusSection';
import { deleteStatus } from '../../../features/projects/status/StatusSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import ChangeCategoryDropdown from '../changeCategoryDropdown/ChangeCategoryDropdown';
import { useTranslation } from 'react-i18next';
interface CommonPrioritySectionProps {
status: string;
dataSource: TaskType[];
category: string;
id: string;
}
const CommonPrioritySection: React.FC<CommonPrioritySectionProps> = ({
status,
dataSource,
category,
id,
}) => {
const dispatch = useAppDispatch();
const createTaskInputRef = useRef<InputRef>(null);
// Initialize status in the Redux store if not already set
useEffect(() => {
dispatch(initializeStatus(status));
}, [dispatch, status]);
// Get status-specific disable controls from Redux state
const isTopCardDisabled = useAppSelector(
state => state.createCardReducer.taskCardDisabledStatus[status]?.top
);
const isBottomCardDisabled = useAppSelector(
state => state.createCardReducer.taskCardDisabledStatus[status]?.bottom
);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const [addTaskCount, setAddTaskCount] = useState(0);
const [name, setName] = useState(status);
const [isEditable, setIsEditable] = useState(false);
const inputRef = useRef<InputRef>(null);
const [isLoading, setIsLoading] = useState(false);
const taskCardRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation('kanban-board');
const handleAddTaskClick = () => {
dispatch(setTaskCardDisabled({ status, position: 'bottom', disabled: false }));
setAddTaskCount(prev => prev + 1);
};
const handleTopAddTaskClick = () => {
dispatch(setTaskCardDisabled({ status, position: 'top', disabled: false }));
setAddTaskCount(prev => prev + 1);
};
useEffect(() => {
if (isEditable && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditable]);
useEffect(() => {
createTaskInputRef.current?.focus();
taskCardRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}, [dataSource, addTaskCount]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
};
const handleBlur = () => {
setIsEditable(false);
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
}, 3000);
};
const items: MenuProps['items'] = [
{
key: '1',
label: (
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
width: '100%',
padding: '5px 12px',
gap: '8px',
}}
onClick={() => setIsEditable(true)}
>
<EditOutlined /> <span>{t('rename')}</span>
</div>
),
},
{
key: '2',
label: <ChangeCategoryDropdown id={id} />,
},
{
key: '3',
label: (
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
width: '100%',
padding: '5px 12px',
gap: '8px',
}}
onClick={() => dispatch(deleteStatus(id))}
>
<DeleteOutlined /> <span>{t('delete')}</span>
</div>
),
},
];
return (
<div style={{ paddingTop: '6px' }}>
<div
className={`todo-wraper ${themeMode === 'dark' ? 'dark-mode' : ''}`}
style={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
flexBasis: 0,
maxWidth: '375px',
width: '375px',
marginRight: '8px',
padding: '8px',
borderRadius: '25px',
maxHeight: 'calc(100vh - 250px)',
backgroundColor: themeMode === 'dark' ? '#282828' : '#F8FAFC',
}}
>
<div
style={{
touchAction: 'none',
userSelect: 'none',
cursor: 'grab',
fontSize: '14px',
paddingTop: '0',
margin: '0.25rem',
}}
>
<div
style={{
fontWeight: 600,
marginBottom: '12px',
alignItems: 'center',
padding: '8px',
display: 'flex',
justifyContent: 'space-between',
backgroundColor:
category === 'low' ? '#c2e4d0' : category === 'medium' ? '#f9e3b1' : '#f6bfc0',
borderRadius: '10px',
}}
>
<div
style={{ display: 'flex', gap: '5px', alignItems: 'center' }}
onClick={() => setIsEditable(true)}
>
{isLoading ? (
<LoadingOutlined />
) : (
<Button
type="text"
size="small"
shape="circle"
style={{
backgroundColor: themeMode === 'dark' ? '#383838' : 'white',
}}
>
{dataSource.length}
</Button>
)}
{isEditable ? (
<Input
ref={inputRef}
value={name}
variant="borderless"
style={{
backgroundColor: themeMode === 'dark' ? 'black' : 'white',
}}
onChange={handleChange}
onBlur={handleBlur}
onPressEnter={handleBlur}
/>
) : (
<Typography.Text
style={{
textTransform: 'capitalize',
color: themeMode === 'dark' ? '#383838' : '',
}}
>
{name}
</Typography.Text>
)}
</div>
<div style={{ display: 'flex' }}>
<Button
type="text"
size="small"
shape="circle"
onClick={handleTopAddTaskClick}
style={{ color: themeMode === 'dark' ? '#383838' : '' }}
>
<PlusOutlined />
</Button>
<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>
</div>
</div>
<div
style={{
overflowY: 'auto',
maxHeight: 'calc(100vh - 250px)',
padding: '2px 6px 2px 2px',
}}
>
{!isTopCardDisabled && (
<TaskCreateCard ref={createTaskInputRef} status={status} position={'top'} />
)}
{dataSource.map(task => (
<TaskCard key={task.taskId} task={task} />
))}
{!isBottomCardDisabled && (
<TaskCreateCard ref={createTaskInputRef} status={status} position={'bottom'} />
)}
</div>
<div
style={{
textAlign: 'center',
margin: '7px 8px 8px 8px',
backgroundColor: themeMode === 'dark' ? '#383838' : 'white',
padding: '0',
}}
>
<Button
type="text"
style={{
height: '38px',
width: '100%',
}}
icon={<PlusOutlined />}
onClick={handleAddTaskClick}
>
{t('addTask')}
</Button>
</div>
</div>
</div>
);
};
export default CommonPrioritySection;

View File

@@ -0,0 +1,16 @@
.todo-wraper:hover {
border: 1px solid #f0f0f0;
}
.todo-wraper.dark-mode:hover {
border: 1px solid #3a3a3a;
}
.todo-threedot-dropdown .ant-dropdown-menu-item {
padding: 0 !important;
}
.todo-threedot-dropdown-button .ant-btn {
display: flex;
justify-content: left;
}

View File

@@ -0,0 +1,295 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Dropdown, Input, InputRef, MenuProps, Typography } from 'antd';
import {
DeleteOutlined,
EditOutlined,
LoadingOutlined,
MoreOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { setTaskCardDisabled, initializeStatus } from '../../../features/board/create-card.slice';
import { TaskType } from '../../../types/task.types';
import TaskCreateCard from '../taskCreateCard/TaskCreateCard';
import TaskCard from '../taskCard/TaskCard';
import { useAppSelector } from '@/hooks/useAppSelector';
import './CommonStatusSection.css';
import { deleteStatus } from '../../../features/projects/status/StatusSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import ChangeCategoryDropdown from '../changeCategoryDropdown/ChangeCategoryDropdown';
import { useTranslation } from 'react-i18next';
interface CommonStatusSectionProps {
status: string;
dataSource: TaskType[];
category: string;
id: string;
}
const CommonStatusSection: React.FC<CommonStatusSectionProps> = ({
status,
dataSource,
category,
id,
}) => {
const dispatch = useAppDispatch();
const createTaskInputRef = useRef<InputRef>(null);
// Initialize status in the Redux store if not already set
useEffect(() => {
dispatch(initializeStatus(status));
}, [dispatch, status]);
// Get status-specific disable controls from Redux state
const isTopCardDisabled = useAppSelector(
state => state.createCardReducer.taskCardDisabledStatus[status]?.top
);
const isBottomCardDisabled = useAppSelector(
state => state.createCardReducer.taskCardDisabledStatus[status]?.bottom
);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const [addTaskCount, setAddTaskCount] = useState(0);
const [name, setName] = useState(status);
const [isEditable, setIsEditable] = useState(false);
const inputRef = useRef<InputRef>(null);
const [isLoading, setIsLoading] = useState(false);
const taskCardRef = useRef<HTMLDivElement>(null);
const { t } = useTranslation('kanban-board');
const handleAddTaskClick = () => {
dispatch(setTaskCardDisabled({ status, position: 'bottom', disabled: false }));
setAddTaskCount(prev => prev + 1);
};
const handleTopAddTaskClick = () => {
dispatch(setTaskCardDisabled({ status, position: 'top', disabled: false }));
setAddTaskCount(prev => prev + 1);
};
useEffect(() => {
if (isEditable && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditable]);
useEffect(() => {
createTaskInputRef.current?.focus();
taskCardRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}, [dataSource, addTaskCount]);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
};
const handleBlur = () => {
setIsEditable(false);
setIsLoading(true);
setTimeout(() => {
setIsLoading(false);
}, 3000);
};
const items: MenuProps['items'] = [
{
key: '1',
label: (
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
width: '100%',
padding: '5px 12px',
gap: '8px',
}}
onClick={() => setIsEditable(true)}
>
<EditOutlined /> <span>{t('rename')}</span>
</div>
),
},
{
key: '2',
label: <ChangeCategoryDropdown id={id} />,
},
{
key: '3',
label: (
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
width: '100%',
padding: '5px 12px',
gap: '8px',
}}
onClick={() => dispatch(deleteStatus(id))}
>
<DeleteOutlined /> <span>{t('delete')}</span>
</div>
),
},
];
return (
<div style={{ paddingTop: '6px' }}>
<div
className={`todo-wraper ${themeMode === 'dark' ? 'dark-mode' : ''}`}
style={{
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
flexBasis: 0,
maxWidth: '375px',
width: '375px',
marginRight: '8px',
padding: '8px',
borderRadius: '25px',
maxHeight: 'calc(100vh - 250px)',
backgroundColor: themeMode === 'dark' ? '#282828' : '#F8FAFC',
}}
>
<div
style={{
touchAction: 'none',
userSelect: 'none',
cursor: 'grab',
fontSize: '14px',
paddingTop: '0',
margin: '0.25rem',
}}
>
<div
style={{
fontWeight: 600,
marginBottom: '12px',
alignItems: 'center',
padding: '8px',
display: 'flex',
justifyContent: 'space-between',
backgroundColor:
category === 'todo' ? '#d1d0d3' : category === 'doing' ? '#b9cef1' : '#c2e4d0',
borderRadius: '10px',
}}
>
<div
style={{ display: 'flex', gap: '5px', alignItems: 'center' }}
onClick={() => setIsEditable(true)}
>
{isLoading ? (
<LoadingOutlined />
) : (
<Button
type="text"
size="small"
shape="circle"
style={{
backgroundColor: themeMode === 'dark' ? '#383838' : 'white',
}}
>
{dataSource.length}
</Button>
)}
{isEditable ? (
<Input
ref={inputRef}
value={name}
variant="borderless"
style={{
backgroundColor: themeMode === 'dark' ? 'black' : 'white',
}}
onChange={handleChange}
onBlur={handleBlur}
onPressEnter={handleBlur}
/>
) : (
<Typography.Text
style={{
textTransform: 'capitalize',
color: themeMode === 'dark' ? '#383838' : '',
}}
>
{name}
</Typography.Text>
)}
</div>
<div style={{ display: 'flex' }}>
<Button
type="text"
size="small"
shape="circle"
onClick={handleTopAddTaskClick}
style={{ color: themeMode === 'dark' ? '#383838' : '' }}
>
<PlusOutlined />
</Button>
<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>
</div>
</div>
<div
style={{
overflowY: 'auto',
maxHeight: 'calc(100vh - 250px)',
padding: '2px 6px 2px 2px',
}}
>
{!isTopCardDisabled && (
<TaskCreateCard ref={createTaskInputRef} status={status} position={'top'} />
)}
{dataSource.map(task => (
<TaskCard key={task.taskId} task={task} />
))}
{!isBottomCardDisabled && (
<TaskCreateCard ref={createTaskInputRef} status={status} position={'bottom'} />
)}
</div>
<div
style={{
textAlign: 'center',
margin: '7px 8px 8px 8px',
backgroundColor: themeMode === 'dark' ? '#383838' : 'white',
padding: '0',
}}
>
<Button
type="text"
style={{
height: '38px',
width: '100%',
}}
icon={<PlusOutlined />}
onClick={handleAddTaskClick}
>
{t('addTask')}
</Button>
</div>
</div>
</div>
);
};
export default CommonStatusSection;

View File

@@ -0,0 +1,30 @@
import { Button, Flex } from 'antd';
import AddMembersDropdown from '@/components/add-members-dropdown/add-members-dropdown';
import Avatars from '../avatars/avatars';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import BoardAssigneeSelector from './board-assignee-selector/board-assignee-selector';
type CustomAvatarGroupProps = {
task: IProjectTask;
sectionId: string;
};
const CustomAvatarGroup = ({ task, sectionId }: CustomAvatarGroupProps) => {
return (
<Flex
gap={4}
align="center"
onClick={(e) => e.stopPropagation()}
style={{
borderRadius: 4,
cursor: 'pointer',
}}
>
<Avatars members={task?.names || []} />
<BoardAssigneeSelector task={task} groupId={sectionId} />
</Flex>
);
};
export default CustomAvatarGroup;

View File

@@ -0,0 +1,101 @@
import React, { useState, useRef } from 'react';
import { DatePicker, Button, Flex } from 'antd';
import { CalendarOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import logger from '@/utils/errorLogger';
import { useAuthService } from '@/hooks/useAuth';
import { getUserSession } from '@/utils/session-helper';
const CustomDueDatePicker = ({
task,
onDateChange,
}: {
task: IProjectTask;
onDateChange: (date: Dayjs | null) => void;
}) => {
const { socket } = useSocket();
const [isDatePickerOpen, setIsDatePickerOpen] = useState<boolean>(false);
const containerRef = useRef<HTMLDivElement>(null); // Add ref to container
const dueDayjs = task?.end_date ? dayjs(task.end_date) : null;
const handleDateChange = (date: Dayjs | null) => {
onDateChange(date);
setIsDatePickerOpen(false);
try {
socket?.emit(
SocketEvents.TASK_END_DATE_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
end_date: date?.format(),
parent_task: task.parent_task_id,
time_zone: getUserSession()?.timezone_name
? getUserSession()?.timezone_name
: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
);
} catch (error) {
logger.error('Failed to update due date:', error);
}
};
// Stop propagation at the container level
const handleContainerClick = (e: React.MouseEvent<HTMLDivElement>) => {
e.stopPropagation();
};
return (
<div ref={containerRef} onClick={handleContainerClick}>
{task && task.end_date ? (
<DatePicker
value={dueDayjs}
format={'MMM DD, YYYY'}
onChange={handleDateChange}
variant="borderless"
suffixIcon={null}
style={{ textAlign: 'right', padding: 0, maxWidth: 100 }}
// Remove individual onClick handler since container handles it
/>
) : (
<Flex gap={4} align="center" style={{ position: 'relative', width: 26, height: 26 }}>
<DatePicker
open={isDatePickerOpen}
value={dueDayjs}
format={'MMM DD, YYYY'}
onChange={handleDateChange}
style={{ opacity: 0, width: 0, height: 0, padding: 0 }}
popupStyle={{ paddingBlock: 12 }}
onBlur={() => setIsDatePickerOpen(false)}
onOpenChange={open => setIsDatePickerOpen(open)}
variant="borderless"
// Remove individual onClick handler
/>
<Button
shape="circle"
type="dashed"
size="small"
style={{
background: 'transparent',
boxShadow: 'none',
position: 'absolute',
top: 0,
left: 0,
width: 26,
height: 26,
}}
onClick={(e) => {
e.stopPropagation(); // Keep this as a backup
setIsDatePickerOpen(true);
}}
icon={<CalendarOutlined />}
/>
</Flex>
)}
</div>
);
};
export default CustomDueDatePicker;

View File

@@ -0,0 +1,16 @@
.todo-wraper:hover {
border: 1px solid #f0f0f0;
}
.todo-wraper.dark-mode:hover {
border: 1px solid #3a3a3a;
}
.todo-threedot-dropdown .ant-dropdown-menu-item {
padding: 0 !important;
}
.todo-threedot-dropdown-button .ant-btn {
display: flex;
justify-content: left;
}

View File

@@ -0,0 +1,330 @@
import React, { useEffect, useRef, useState } from 'react';
import { Button, Dropdown, Input, InputRef, MenuProps, Typography } from 'antd';
import {
DeleteOutlined,
EditOutlined,
LoadingOutlined,
MoreOutlined,
PlusOutlined,
} from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useDroppable } from '@dnd-kit/core';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
import { setTaskCardDisabled, initializeGroup } from '@/features/board/create-card.slice';
import TaskCreateCard from '../taskCreateCard/TaskCreateCard';
import TaskCard from '../taskCard/TaskCard';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { deleteStatus } from '@features/projects/status/StatusSlice';
import ChangeCategoryDropdown from '../changeCategoryDropdown/ChangeCategoryDropdown';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import './kanban-group.css';
interface KanbanGroupProps {
title: string;
tasks: IProjectTask[];
id: string;
color: string;
}
interface GroupState {
name: string;
isEditable: boolean;
isLoading: boolean;
addTaskCount: number;
}
const KanbanGroup: React.FC<KanbanGroupProps> = ({ title, tasks, id, color }) => {
// Refs
const inputRef = useRef<InputRef>(null);
const createTaskInputRef = useRef<InputRef>(null);
const taskCardRef = useRef<HTMLDivElement>(null);
// State
const [groupState, setGroupState] = useState<GroupState>({
name: title,
isEditable: false,
isLoading: false,
addTaskCount: 0,
});
// Hooks
const dispatch = useAppDispatch();
const { t } = useTranslation('kanban-board');
// Selectors
const themeMode = useAppSelector(state => state.themeReducer.mode);
const isTopCardDisabled = useAppSelector(
state => state.createCardReducer.taskCardDisabledStatus[id]?.top
);
const isBottomCardDisabled = useAppSelector(
state => state.createCardReducer.taskCardDisabledStatus[id]?.bottom
);
// Add droppable functionality
const { setNodeRef } = useDroppable({
id: id,
});
// Effects
useEffect(() => {
dispatch(initializeGroup(id));
}, [dispatch, id]);
useEffect(() => {
if (groupState.isEditable && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [groupState.isEditable]);
useEffect(() => {
createTaskInputRef.current?.focus();
taskCardRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
}, [tasks, groupState.addTaskCount]);
// Handlers
const handleAddTaskClick = () => {
dispatch(setTaskCardDisabled({ group: id, position: 'bottom', disabled: false }));
setGroupState(prev => ({ ...prev, addTaskCount: prev.addTaskCount + 1 }));
};
const handleTopAddTaskClick = () => {
dispatch(setTaskCardDisabled({ group: id, position: 'top', disabled: false }));
setGroupState(prev => ({ ...prev, addTaskCount: prev.addTaskCount + 1 }));
};
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setGroupState(prev => ({ ...prev, name: e.target.value }));
};
const handleBlur = () => {
setGroupState(prev => ({ ...prev, isEditable: false, isLoading: true }));
setTimeout(() => {
setGroupState(prev => ({ ...prev, isLoading: false }));
}, 3000);
};
const handleEditClick = () => {
setGroupState(prev => ({ ...prev, isEditable: true }));
};
// Menu items
const items: MenuProps['items'] = [
{
key: '1',
label: (
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
width: '100%',
padding: '5px 12px',
gap: '8px',
}}
onClick={handleEditClick}
>
<EditOutlined /> <span>{t('rename')}</span>
</div>
),
},
{
key: '2',
label: <ChangeCategoryDropdown id={id} />,
},
{
key: '3',
label: (
<div
style={{
display: 'flex',
justifyContent: 'flex-start',
width: '100%',
padding: '5px 12px',
gap: '8px',
}}
onClick={() => dispatch(deleteStatus(id))}
>
<DeleteOutlined /> <span>{t('delete')}</span>
</div>
),
},
];
// Styles
const containerStyle = {
paddingTop: '6px',
};
const wrapperStyle = {
display: 'flex',
flexDirection: 'column' as const,
flexGrow: 1,
flexBasis: 0,
maxWidth: '375px',
width: '375px',
marginRight: '8px',
padding: '8px',
borderRadius: '25px',
maxHeight: 'calc(100vh - 250px)',
backgroundColor: themeMode === 'dark' ? '#282828' : '#F8FAFC',
};
const headerStyle = {
touchAction: 'none' as const,
userSelect: 'none' as const,
cursor: 'grab',
fontSize: '14px',
paddingTop: '0',
margin: '0.25rem',
};
const titleBarStyle = {
fontWeight: 600,
marginBottom: '12px',
alignItems: 'center',
padding: '8px',
display: 'flex',
justifyContent: 'space-between',
backgroundColor: color,
borderRadius: '10px',
};
return (
<div style={containerStyle}>
<div
className={`todo-wraper ${themeMode === 'dark' ? 'dark-mode' : ''}`}
style={wrapperStyle}
>
<div style={headerStyle}>
<div style={titleBarStyle}>
<div
style={{ display: 'flex', gap: '5px', alignItems: 'center' }}
onClick={handleEditClick}
>
{groupState.isLoading ? (
<LoadingOutlined />
) : (
<Button
type="text"
size="small"
shape="circle"
style={{
backgroundColor: themeMode === 'dark' ? '#383838' : 'white',
}}
>
{tasks.length}
</Button>
)}
{groupState.isEditable ? (
<Input
ref={inputRef}
value={groupState.name}
variant="borderless"
style={{
backgroundColor: themeMode === 'dark' ? 'black' : 'white',
}}
onChange={handleChange}
onBlur={handleBlur}
onPressEnter={handleBlur}
/>
) : (
<Typography.Text
style={{
textTransform: 'capitalize',
color: themeMode === 'dark' ? '#383838' : '',
}}
>
{groupState.name}
</Typography.Text>
)}
</div>
<div style={{ display: 'flex' }}>
<Button
type="text"
size="small"
shape="circle"
onClick={handleTopAddTaskClick}
style={{ color: themeMode === 'dark' ? '#383838' : '' }}
>
<PlusOutlined />
</Button>
<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>
</div>
</div>
<div
ref={setNodeRef}
style={{
overflowY: 'auto',
maxHeight: 'calc(100vh - 250px)',
padding: '2px 6px 2px 2px',
}}
>
{!isTopCardDisabled && (
<TaskCreateCard ref={createTaskInputRef} status={title} position={'top'} />
)}
<SortableContext
items={tasks.map(task => task.id)}
strategy={verticalListSortingStrategy}
>
<div className="App" style={{ display: 'flex', flexDirection: 'column' }}>
{tasks.map(task => (
<TaskCard key={task.id} task={task} />
))}
</div>
</SortableContext>
{!isBottomCardDisabled && (
<TaskCreateCard ref={createTaskInputRef} status={title} position={'bottom'} />
)}
</div>
<div
style={{
textAlign: 'center',
margin: '7px 8px 8px 8px',
backgroundColor: themeMode === 'dark' ? '#383838' : 'white',
padding: '0',
}}
>
<Button
type="text"
style={{
height: '38px',
width: '100%',
}}
icon={<PlusOutlined />}
onClick={handleAddTaskClick}
>
{t('addTask')}
</Button>
</div>
</div>
</div>
);
};
export default KanbanGroup;

View File

@@ -0,0 +1,158 @@
import React, { useEffect, useState } from 'react';
import { Avatar, Col, DatePicker, Divider, Flex, Row, Tooltip, Typography } from 'antd';
import StatusDropdown from '../../taskListCommon/statusDropdown/StatusDropdown';
import dayjs, { Dayjs } from 'dayjs';
import { useTranslation } from 'react-i18next';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import Avatars from '@/components/avatars/avatars';
interface SubTaskProps {
subtask: IProjectTask;
}
const SubTaskCard: React.FC<SubTaskProps> = ({ subtask }) => {
const [isSubToday, setIsSubToday] = useState(false);
const [isSubTomorrow, setIsSubTomorrow] = useState(false);
const [isItSubPrevDate, setIsItSubPrevDate] = useState(false);
const [subTaskDueDate, setSubTaskDueDate] = useState<Dayjs | null>(null);
const { t } = useTranslation('kanban-board');
const handleSubTaskDateChange = (date: Dayjs | null) => {
setSubTaskDueDate(date);
};
const formatDate = (date: Dayjs | null) => {
if (!date) return '';
const today = dayjs();
const tomorrow = today.add(1, 'day');
if (date.isSame(today, 'day')) {
return 'Today';
} else if (date.isSame(tomorrow, 'day')) {
return 'Tomorrow';
} else {
return date.isSame(today, 'year') ? date.format('MMM DD') : date.format('MMM DD, YYYY');
}
};
useEffect(() => {
if (subTaskDueDate) {
setIsSubToday(subTaskDueDate.isSame(dayjs(), 'day'));
setIsSubTomorrow(subTaskDueDate.isSame(dayjs().add(1, 'day'), 'day'));
setIsItSubPrevDate(subTaskDueDate.isBefore(dayjs()));
} else {
setIsSubToday(false);
setIsSubTomorrow(false);
setIsItSubPrevDate(false);
}
}, [subTaskDueDate]);
return (
<Row
key={subtask.id}
style={{
marginTop: '0.5rem',
width: '100%',
}}
>
<Col span={10}>
<Typography.Text
style={{ fontWeight: 500, fontSize: '12px' }}
delete={subtask.status === 'done'}
>
{subtask.name}
</Typography.Text>
</Col>
<Col span={4}>
<Avatar.Group
size="small"
max={{
count: 1,
style: {
color: '#f56a00',
backgroundColor: '#fde3cf',
},
}}
>
<Avatars members={subtask.names || []} />
</Avatar.Group>
</Col>
<Col span={10}>
<Flex>
<DatePicker
className={`custom-placeholder ${!subTaskDueDate ? 'empty-date' : isSubToday || isSubTomorrow ? 'selected-date' : isItSubPrevDate ? 'red-colored' : ''}`}
placeholder={t('dueDate')}
style={{
fontSize: '12px',
opacity: subTaskDueDate ? 1 : 0,
}}
onChange={handleSubTaskDateChange}
variant="borderless"
size="small"
suffixIcon={false}
format={value => formatDate(value)}
/>
<div>
<StatusDropdown currentStatus={subtask.status} />
</div>
</Flex>
</Col>
{/* <div
style={{
display: 'flex',
justifyContent: 'space-between',
}}
>
<Typography.Text
style={{ fontWeight: 500 }}
delete={subtask.status === 'done'}
>
{subtask.task}
</Typography.Text>
<StatusDropdown currentStatus={subtask.status} />
</div>
<div style={{ display: 'flex' }}>
<Avatar.Group
size="small"
max={{
count: 1,
style: {
color: '#f56a00',
backgroundColor: '#fde3cf',
},
}}
>
{subtask.members?.map((member) => (
<Avatar
style={{
backgroundColor: AvatarNamesMap[member.memberName.charAt(0)],
fontSize: '12px',
}}
size="small"
>
{member.memberName.charAt(0)}
</Avatar>
))}
</Avatar.Group>
<DatePicker
className={`custom-placeholder ${!subTaskDueDate ? 'empty-date' : isSubToday || isSubTomorrow ? 'selected-date' : isItSubPrevDate ? 'red-colored' : ''}`}
placeholder="Due date"
style={{
fontSize: '12px',
opacity: subTaskDueDate ? 1 : 0,
}}
onChange={handleSubTaskDateChange}
variant="borderless"
size="small"
suffixIcon={false}
format={(value) => formatDate(value)}
/>
</div> */}
<Divider style={{ margin: '5px' }} />
</Row>
);
};
export default SubTaskCard;

View File

@@ -0,0 +1,58 @@
.task-card {
transition: box-shadow 0.3s ease;
box-shadow:
#edeae9 0 0 0 1px,
#6d6e6f14 0 1px 4px;
}
.task-card:hover {
box-shadow:
#a8a8a8 0 0 0 1px,
#6d6e6f14 0 1px 4px;
}
.task-card.dark-mode {
box-shadow:
#2e2e2e 0 0 0 1px,
#0000001a 0 1px 4px;
}
.custom-placeholder .ant-picker-input input::placeholder {
font-size: 12px !important;
}
.task-card:hover .empty-date {
opacity: 1 !important;
}
.task-card:hover .hide-add-member-avatar {
opacity: 0.8;
}
.custom-placeholder .ant-picker-input input {
font-size: 12px !important;
}
.selected-date .ant-picker-input {
color: #87d068;
}
.red-colored .ant-picker-input {
color: red;
}
.sub-selected-date .ant-picker-input {
color: #87d068;
}
.sub-red-colored .ant-picker-input {
color: red;
}
.add-member-avatar {
opacity: 0.8;
}
.hide-add-member-avatar {
opacity: 0;
}

View File

@@ -0,0 +1,318 @@
import React, { useEffect, useState } from 'react';
import {
DatePicker,
Tooltip,
Tag,
Avatar,
Progress,
Typography,
Dropdown,
MenuProps,
Button,
} from 'antd';
import {
DoubleRightOutlined,
PauseOutlined,
UserAddOutlined,
InboxOutlined,
DeleteOutlined,
MinusOutlined,
ForkOutlined,
CaretRightFilled,
CaretDownFilled,
} from '@ant-design/icons';
import './TaskCard.css';
import dayjs, { Dayjs } from 'dayjs';
import AddMembersDropdown from '../../add-members-dropdown/add-members-dropdown';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { deleteTask } from '../../../features/tasks/tasks.slice';
import SubTaskCard from '../subTaskCard/SubTaskCard';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import Avatars from '@/components/avatars/avatars';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { UniqueIdentifier } from '@dnd-kit/core';
interface taskProps {
task: IProjectTask;
}
const TaskCard: React.FC<taskProps> = ({ task }) => {
const [isSubTaskShow, setIsSubTaskShow] = useState(false);
const [dueDate, setDueDate] = useState<Dayjs | null>(null);
const [isToday, setIsToday] = useState(false);
const [isTomorrow, setIsTomorrow] = useState(false);
const [isItPrevDate, setIsItPrevDate] = useState(false);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const dispatch = useAppDispatch();
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: task.id as UniqueIdentifier,
data: {
type: 'task',
task,
},
});
const handleDateChange = (date: Dayjs | null) => {
setDueDate(date);
};
const { t } = useTranslation('kanban-board');
const formatDate = (date: Dayjs | null) => {
if (!date) return '';
const today = dayjs();
const tomorrow = today.add(1, 'day');
if (date.isSame(today, 'day')) {
return t('today');
} else if (date.isSame(tomorrow, 'day')) {
return t('tomorrow');
} else {
return date.isSame(today, 'year') ? date.format('MMM DD') : date.format('MMM DD, YYYY');
}
};
useEffect(() => {
if (dueDate) {
setIsToday(dueDate.isSame(dayjs(), 'day'));
setIsTomorrow(dueDate.isSame(dayjs().add(1, 'day'), 'day'));
setIsItPrevDate(dueDate.isBefore(dayjs()));
} else {
setIsToday(false);
setIsTomorrow(false);
setIsItPrevDate(false);
}
}, [dueDate]);
const handleDelete = () => {
if (!task.id) return;
dispatch(deleteTask(task.id)); // Call delete function with taskId
};
const items: MenuProps['items'] = [
{
label: (
<span>
<UserAddOutlined /> <Typography.Text>{t('assignToMe')}</Typography.Text>
</span>
),
key: '1',
},
{
label: (
<span>
<InboxOutlined /> <Typography.Text>{t('archive')}</Typography.Text>
</span>
),
key: '2',
},
{
label: (
<span onClick={handleDelete}>
<DeleteOutlined /> <Typography.Text>{t('delete')}</Typography.Text>
</span>
),
key: '3',
},
];
// const progress = (task.subTasks?.length || 0 + 1 )/ (task.subTasks?.length || 0 + 1)
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
position: 'relative',
touchAction: 'none',
};
return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
<Dropdown menu={{ items }} trigger={['contextMenu']}>
<div
className={`task-card ${themeMode === 'dark' ? 'dark-mode' : ''}`}
style={{
zIndex: 99,
padding: '12px',
backgroundColor: themeMode === 'dark' ? '#383838' : 'white',
borderRadius: '4px',
marginBottom: '12px',
cursor: 'pointer',
position: 'relative',
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
gap: '10px',
}}
>
{/* Labels and Progress */}
<div style={{ display: 'flex' }}>
<div>
{task.labels?.length ? (
<>
{task.labels.slice(0, 2).map((label, index) => (
<Tag key={index} 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>}
</>
) : (
''
)}
</div>
<div
style={{
maxWidth: '30px',
height: '30px',
marginLeft: 'auto',
}}
>
<Tooltip title="1/1">
<Progress type="circle" percent={task.progress} size={26} />
</Tooltip>
</div>
</div>
{/* Action Icons */}
<div style={{ display: 'flex' }}>
{task.priority === 'low' ? (
<MinusOutlined
style={{
color: '#52c41a',
marginRight: '0.25rem',
}}
/>
) : task.priority === 'medium' ? (
<PauseOutlined
style={{
color: '#faad14',
transform: 'rotate(90deg)',
marginRight: '0.25rem',
}}
/>
) : (
<DoubleRightOutlined
style={{
color: '#f5222d',
transform: 'rotate(-90deg)',
marginRight: '0.25rem',
}}
/>
)}
<Typography.Text style={{ fontWeight: 500 }}>{task.name}</Typography.Text>
</div>
{/* Subtask Section */}
<div>
<div
style={{
marginTop: '0.5rem',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div
style={{
opacity: 1,
borderRadius: '4px',
cursor: 'pointer',
alignItems: 'center',
display: 'flex',
gap: '3px',
}}
>
<Avatars members={task.names || []} />
<Avatar
size="small"
className={
task.assignees?.length ? 'add-member-avatar' : 'hide-add-member-avatar'
}
style={{
backgroundColor: '#fff',
border: '1px dashed #c4c4c4',
color: '#000000d9',
fontSize: '12px',
}}
>
<AddMembersDropdown />
</Avatar>
</div>
<div
style={{
display: 'flex',
justifyContent: 'right',
alignItems: 'center',
}}
>
<div>
<DatePicker
className={`custom-placeholder ${
!dueDate
? 'empty-date'
: isToday
? 'selected-date'
: isTomorrow
? 'selected-date'
: isItPrevDate
? 'red-colored'
: ''
}`}
placeholder={t('dueDate')}
style={{
fontSize: '12px',
opacity: dueDate ? 1 : 0,
width: dueDate ? 'auto' : '100%',
maxWidth: '100px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
onChange={handleDateChange}
variant="borderless"
size="small"
suffixIcon={false}
format={value => formatDate(value)}
/>
</div>
{task.sub_tasks_count && task.sub_tasks_count > 0 && (
<Button
onClick={() => setIsSubTaskShow(!isSubTaskShow)}
size="small"
style={{ padding: 0 }}
type="text"
>
<Tag
bordered={false}
style={{ display: 'flex', alignItems: 'center', margin: 0 }}
>
<ForkOutlined rotate={90} />
<span>{task.sub_tasks_count}</span>
{isSubTaskShow ? <CaretDownFilled /> : <CaretRightFilled />}
</Tag>
</Button>
)}
</div>
</div>
{isSubTaskShow &&
task.sub_tasks_count &&
task.sub_tasks_count > 0 &&
task.sub_tasks?.map(subtask => <SubTaskCard subtask={subtask} />)}
</div>
</div>
</Dropdown>
</div>
);
};
export default TaskCard;

View File

@@ -0,0 +1,3 @@
.task-card .create-task-empty-date {
opacity: 1 !important;
}

View File

@@ -0,0 +1,255 @@
import { Avatar, Button, DatePicker, Input, InputRef } from 'antd';
import React, { forwardRef, useEffect, useRef, useState } from 'react';
import AddMembersDropdown from '../../add-members-dropdown/add-members-dropdown';
import dayjs, { Dayjs } from 'dayjs';
import './TaskCreateCard.css';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { addTask, addTaskToTop } from '../../../features/tasks/tasks.slice';
import { setTaskCardDisabled } from '../../../features/board/create-card.slice';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
interface StatusProps {
status: string;
position: 'top' | 'bottom';
}
const TaskCreateCard = forwardRef<InputRef, StatusProps>(({ status, position }, ref) => {
const [characterLength, setCharacterLength] = useState<number>(0);
const [dueDate, setDueDate] = useState<Dayjs | null>(null);
const [isToday, setIsToday] = useState(false);
const [isTomorrow, setIsTomorrow] = useState(false);
const [isItPrevDate, setIsItPrevDate] = useState(false);
const [taskName, setTaskName] = useState('');
const dispatch = useAppDispatch();
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { t } = useTranslation('kanban-board');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCharacterLength(e.target.value.length);
setTaskName(e.target.value);
};
const handleDateChange = (date: Dayjs | null) => {
setDueDate(date);
};
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (cardRef.current) {
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
if (ref && typeof ref === 'object' && ref.current) {
ref.current.focus();
}
}, []);
const formatDate = (date: Dayjs | null) => {
if (!date) return '';
const today = dayjs();
const tomorrow = today.add(1, 'day');
if (date.isSame(today, 'day')) {
return 'Today';
} else if (date.isSame(tomorrow, 'day')) {
return 'Tomorrow';
} else {
return date.isSame(today, 'year') ? date.format('MMM DD') : date.format('MMM DD, YYYY');
}
};
useEffect(() => {
if (dueDate) {
setIsToday(dueDate.isSame(dayjs(), 'day'));
setIsTomorrow(dueDate.isSame(dayjs().add(1, 'day'), 'day'));
setIsItPrevDate(dueDate.isBefore(dayjs()));
} else {
setIsToday(false);
setIsTomorrow(false);
setIsItPrevDate(false);
}
}, [dueDate]);
const handleAddTask = () => {
if (taskName.trim()) {
if (position === 'bottom') {
dispatch(
addTask({
taskId: `SP-${Date.now()}`,
task: taskName,
description: '-',
progress: status === 'done' ? 100 : 0,
members: [],
labels: [],
status: status,
priority: 'medium',
timeTracking: 0,
estimation: '-',
startDate: new Date(),
dueDate: dueDate ? dueDate.toDate() : null,
completedDate: null,
createdDate: new Date(),
lastUpdated: new Date(),
reporter: '-',
phase: '',
subTasks: [],
})
);
} else if (position === 'top') {
dispatch(
addTaskToTop({
taskId: `SP-${Date.now()}`,
task: taskName,
description: '-',
progress: status === 'done' ? 100 : 0,
members: [],
labels: [],
status: status,
priority: 'medium',
timeTracking: 0,
estimation: '-',
startDate: new Date(),
dueDate: dueDate ? dueDate.toDate() : null,
completedDate: null,
createdDate: new Date(),
lastUpdated: new Date(),
reporter: '-',
phase: '-',
subTasks: [],
})
);
}
}
setTaskName('');
};
const handleClose = () => {
dispatch(setTaskCardDisabled({ status, position, disabled: true }));
};
return (
<div
ref={cardRef}
className={`task-card ${themeMode === 'dark' ? 'dark-mode' : ''}`}
style={{
zIndex: 99,
padding: '12px',
backgroundColor: themeMode === 'dark' ? '#383838' : 'white',
borderRadius: '4px',
marginBottom: '12px',
cursor: 'pointer',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Input field */}
<div style={{ display: 'flex' }}>
<Input
ref={ref}
type="text"
maxLength={100}
onChange={handleChange}
value={taskName}
onPressEnter={handleAddTask}
placeholder="Enter task name"
/>
</div>
<div style={{ opacity: characterLength > 0 ? 1 : 0 }}>
{/* Character Length */}
<div
style={{
position: 'absolute',
zIndex: 1,
right: '15px',
top: '43px',
color: themeMode === 'dark' ? '#ffffffd9' : '#00000073',
fontSize: '10px',
}}
>
<span>{characterLength}/100</span>
</div>
{/* DatePicker and Avatars */}
<div
style={{
paddingTop: '0.25rem',
marginTop: '0.75rem',
display: 'flex',
marginBottom: '16px',
}}
>
<div style={{ height: '100%', width: '100%' }}>
<DatePicker
className={`custom-placeholder ${!dueDate ? 'create-task-empty-date' : isToday ? 'selected-date' : isTomorrow ? 'selected-date' : isItPrevDate ? 'red-colored' : ''}`}
placeholder={t('dueDate')}
style={{
fontSize: '12px',
opacity: dueDate ? 1 : 0,
}}
onChange={handleDateChange}
variant="borderless"
size="small"
suffixIcon={false}
format={value => formatDate(value)}
/>
</div>
<div style={{ marginLeft: 'auto' }}>
<div
style={{
opacity: 1,
borderRadius: '4px',
cursor: 'pointer',
alignItems: 'center',
height: '100%',
width: '100%',
display: 'flex',
gap: '3px',
}}
>
<Avatar.Group>
{/* <Avatar
style={{
backgroundColor:
AvatarNamesMap[
member?.charAt(0)
],
verticalAlign: 'middle',
fontSize: '12px',
}}
size="small"
>
{member.charAt(0)}
</Avatar> */}
</Avatar.Group>
<Avatar
size="small"
style={{
backgroundColor: '#fff',
border: '1px dashed #c4c4c4',
color: '#000000d9',
fontSize: '12px',
}}
>
<AddMembersDropdown />
</Avatar>
</div>
</div>
</div>
</div>
{/* Add Task Button and Cancel Button*/}
<div>
<Button size="small" style={{ marginRight: '8px', fontSize: '12px' }} onClick={handleClose}>
{t('cancel')}
</Button>
<Button size="small" type="primary" style={{ fontSize: '12px' }} onClick={handleAddTask}>
{t('addTask')}
</Button>
</div>
</div>
);
});
export default TaskCreateCard;

View File

@@ -0,0 +1,255 @@
import { Avatar, Button, DatePicker, Input, InputRef } from 'antd';
import React, { forwardRef, useEffect, useRef, useState } from 'react';
import AddMembersDropdown from '../../add-members-dropdown/add-members-dropdown';
import dayjs, { Dayjs } from 'dayjs';
import './TaskCreateCard.css';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { addTask, addTaskToTop } from '../../../features/tasks/tasks.slice';
import { setTaskCardDisabled } from '../../../features/board/create-card.slice';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
interface PriorityProps {
status: string;
position: 'top' | 'bottom';
}
const PriorityTaskCreateCard = forwardRef<InputRef, PriorityProps>(({ status, position }, ref) => {
const [characterLength, setCharacterLength] = useState<number>(0);
const [dueDate, setDueDate] = useState<Dayjs | null>(null);
const [isToday, setIsToday] = useState(false);
const [isTomorrow, setIsTomorrow] = useState(false);
const [isItPrevDate, setIsItPrevDate] = useState(false);
const [taskName, setTaskName] = useState('');
const dispatch = useAppDispatch();
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { t } = useTranslation('kanban-board');
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setCharacterLength(e.target.value.length);
setTaskName(e.target.value);
};
const handleDateChange = (date: Dayjs | null) => {
setDueDate(date);
};
const cardRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (cardRef.current) {
cardRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
if (ref && typeof ref === 'object' && ref.current) {
ref.current.focus();
}
}, []);
const formatDate = (date: Dayjs | null) => {
if (!date) return '';
const today = dayjs();
const tomorrow = today.add(1, 'day');
if (date.isSame(today, 'day')) {
return 'Today';
} else if (date.isSame(tomorrow, 'day')) {
return 'Tomorrow';
} else {
return date.isSame(today, 'year') ? date.format('MMM DD') : date.format('MMM DD, YYYY');
}
};
useEffect(() => {
if (dueDate) {
setIsToday(dueDate.isSame(dayjs(), 'day'));
setIsTomorrow(dueDate.isSame(dayjs().add(1, 'day'), 'day'));
setIsItPrevDate(dueDate.isBefore(dayjs()));
} else {
setIsToday(false);
setIsTomorrow(false);
setIsItPrevDate(false);
}
}, [dueDate]);
const handleAddTask = () => {
if (taskName.trim()) {
if (position === 'bottom') {
dispatch(
addTask({
taskId: `SP-${Date.now()}`,
task: taskName,
description: '-',
progress: status === 'done' ? 100 : 0,
members: [],
labels: [],
status: status,
priority: 'medium',
timeTracking: 0,
estimation: '-',
startDate: new Date(),
dueDate: dueDate ? dueDate.toDate() : null,
completedDate: null,
createdDate: new Date(),
lastUpdated: new Date(),
reporter: '-',
phase: '',
subTasks: [],
})
);
} else if (position === 'top') {
dispatch(
addTaskToTop({
taskId: `SP-${Date.now()}`,
task: taskName,
description: '-',
progress: status === 'done' ? 100 : 0,
members: [],
labels: [],
status: status,
priority: 'medium',
timeTracking: 0,
estimation: '-',
startDate: new Date(),
dueDate: dueDate ? dueDate.toDate() : null,
completedDate: null,
createdDate: new Date(),
lastUpdated: new Date(),
reporter: '-',
phase: '-',
subTasks: [],
})
);
}
}
setTaskName('');
};
const handleClose = () => {
dispatch(setTaskCardDisabled({ status, position, disabled: true }));
};
return (
<div
ref={cardRef}
className={`task-card ${themeMode === 'dark' ? 'dark-mode' : ''}`}
style={{
zIndex: 99,
padding: '12px',
backgroundColor: themeMode === 'dark' ? '#383838' : 'white',
borderRadius: '4px',
marginBottom: '12px',
cursor: 'pointer',
position: 'relative',
overflow: 'hidden',
}}
>
{/* Input field */}
<div style={{ display: 'flex' }}>
<Input
ref={ref}
type="text"
maxLength={100}
onChange={handleChange}
value={taskName}
onPressEnter={handleAddTask}
placeholder="Enter task name"
/>
</div>
<div style={{ opacity: characterLength > 0 ? 1 : 0 }}>
{/* Character Length */}
<div
style={{
position: 'absolute',
zIndex: 1,
right: '15px',
top: '43px',
color: themeMode === 'dark' ? '#ffffffd9' : '#00000073',
fontSize: '10px',
}}
>
<span>{characterLength}/100</span>
</div>
{/* DatePicker and Avatars */}
<div
style={{
paddingTop: '0.25rem',
marginTop: '0.75rem',
display: 'flex',
marginBottom: '16px',
}}
>
<div style={{ height: '100%', width: '100%' }}>
<DatePicker
className={`custom-placeholder ${!dueDate ? 'create-task-empty-date' : isToday ? 'selected-date' : isTomorrow ? 'selected-date' : isItPrevDate ? 'red-colored' : ''}`}
placeholder={t('dueDate')}
style={{
fontSize: '12px',
opacity: dueDate ? 1 : 0,
}}
onChange={handleDateChange}
variant="borderless"
size="small"
suffixIcon={false}
format={value => formatDate(value)}
/>
</div>
<div style={{ marginLeft: 'auto' }}>
<div
style={{
opacity: 1,
borderRadius: '4px',
cursor: 'pointer',
alignItems: 'center',
height: '100%',
width: '100%',
display: 'flex',
gap: '3px',
}}
>
<Avatar.Group>
{/* <Avatar
style={{
backgroundColor:
avatarNamesMap[
member?.charAt(0)
],
verticalAlign: 'middle',
fontSize: '12px',
}}
size="small"
>
{member.charAt(0)}
</Avatar> */}
</Avatar.Group>
<Avatar
size="small"
style={{
backgroundColor: '#fff',
border: '1px dashed #c4c4c4',
color: '#000000d9',
fontSize: '12px',
}}
>
<AddMembersDropdown />
</Avatar>
</div>
</div>
</div>
</div>
{/* Add Task Button and Cancel Button*/}
<div>
<Button size="small" style={{ marginRight: '8px', fontSize: '12px' }} onClick={handleClose}>
{t('cancel')}
</Button>
<Button size="small" type="primary" style={{ fontSize: '12px' }} onClick={handleAddTask}>
{t('addTask')}
</Button>
</div>
</div>
);
});
export default PriorityTaskCreateCard;

View File

@@ -0,0 +1,28 @@
import { Calendar } from 'antd';
import React, { useEffect } from 'react';
import type { Dayjs } from 'dayjs';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { selectedDate } from '../../../features/date/dateSlice';
import { useAppSelector } from '@/hooks/useAppSelector';
/* homepage calendar style override */
import './homeCalendar.css';
import { setHomeTasksConfig } from '@/features/home-page/home-page.slice';
const HomeCalendar = () => {
const dispatch = useAppDispatch();
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
const onSelect = (newValue: Dayjs) => {
dispatch(setHomeTasksConfig({ ...homeTasksConfig, selected_date: newValue }));
};
return (
<Calendar
className="home-calendar"
value={homeTasksConfig.selected_date || undefined}
onSelect={onSelect}
/>
);
};
export default HomeCalendar;

View File

@@ -0,0 +1,7 @@
.home-calendar .ant-picker-calendar-date-content {
display: none;
}
.home-calendar .ant-picker-calendar-date-value {
line-height: 44px !important;
}

View File

@@ -0,0 +1,26 @@
import { ReactNode } from 'react';
interface CollapsibleProps {
isOpen: boolean;
children: ReactNode;
className?: string;
color?: string;
}
const Collapsible = ({ isOpen, children, className = '', color }: CollapsibleProps) => {
return (
<div
style={{
borderLeft: `3px solid ${color}`,
marginTop: '6px',
}}
className={`transition-all duration-300 ease-in-out ${
isOpen ? 'max-h-[2000px] opacity-100 overflow-x-scroll' : 'max-h-0 opacity-0 overflow-hidden'
} ${className}`}
>
{children}
</div>
);
};
export default Collapsible;

View File

@@ -0,0 +1,180 @@
import { AutoComplete, Button, Drawer, Flex, Form, message, Select, Spin, Typography } from 'antd';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleInviteMemberDrawer, triggerTeamMembersRefresh } from '../../../features/settings/member/memberSlice';
import { useTranslation } from 'react-i18next';
import { useState, useEffect, useCallback } from 'react';
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
import { IJobTitle } from '@/types/job.types';
import { teamMembersApiService } from '@/api/team-members/teamMembers.api.service';
import { ITeamMemberCreateRequest } from '@/types/teamMembers/team-member-create-request';
interface FormValues {
email: string[];
jobTitle: string;
access: 'member' | 'admin';
}
const InviteTeamMembers = () => {
const [searching, setSearching] = useState(false);
const [jobTitles, setJobTitles] = useState<IJobTitle[]>([]);
const [emails, setEmails] = useState<string[]>([]);
const [selectedJobTitle, setSelectedJobTitle] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [form] = Form.useForm<FormValues>();
const { t } = useTranslation('settings/team-members');
const isDrawerOpen = useAppSelector(state => state.memberReducer.isInviteMemberDrawerOpen);
const dispatch = useAppDispatch();
const handleSearch = useCallback(
async (value: string) => {
try {
setSearching(true);
const res = await jobTitlesApiService.getJobTitles(1, 10, null, null, value || null);
if (res.done) {
setJobTitles(res.body.data || []);
}
} catch (error) {
message.error(t('Failed to fetch job titles'));
} finally {
setSearching(false);
}
},
[t]
);
useEffect(() => {
if (isDrawerOpen) {
handleSearch('');
}
}, [isDrawerOpen, handleSearch]);
const handleFormSubmit = async (values: FormValues) => {
try {
setLoading(true);
const body: ITeamMemberCreateRequest = {
job_title: selectedJobTitle,
emails: emails,
is_admin: values.access === 'admin',
};
const res = await teamMembersApiService.createTeamMember(body);
if (res.done) {
form.resetFields();
setEmails([]);
setSelectedJobTitle(null);
dispatch(triggerTeamMembersRefresh()); // Trigger refresh in TeamMembersSettings
dispatch(toggleInviteMemberDrawer());
}
} catch (error) {
message.error(t('createMemberErrorMessage'));
} finally {
setLoading(false);
}
};
const handleClose = () => {
form.resetFields();
dispatch(toggleInviteMemberDrawer());
};
const handleEmailChange = (value: string[]) => {
setEmails(value);
};
return (
<Drawer
title={
<Typography.Text strong style={{ fontSize: 16 }}>
{t('addMemberDrawerTitle')}
</Typography.Text>
}
open={isDrawerOpen}
onClose={handleClose}
destroyOnClose
afterOpenChange={visible => visible && handleSearch('')}
width={400}
loading={loading}
footer={
<Flex justify="end">
<Button type="primary" onClick={form.submit}>
{t('addToTeamButton')}
</Button>
</Flex>
}
>
<Form
form={form}
onFinish={handleFormSubmit}
layout="vertical"
initialValues={{ access: 'member' }}
>
<Form.Item
name="emails"
label={t('memberEmailLabel')}
rules={[
{
type: 'array',
required: true,
validator: (_, value) => {
if (!value?.length) return Promise.reject(t('memberEmailRequiredError'));
return Promise.resolve();
},
},
]}
>
<Flex vertical gap={4}>
<Select
mode="tags"
style={{ width: '100%' }}
placeholder={t('memberEmailPlaceholder')}
onChange={handleEmailChange}
notFoundContent={
<Typography.Text type="secondary">{t('noResultFound')}</Typography.Text>
}
tokenSeparators={[',', ' ', ';']}
/>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('addMemberEmailHint')}
</Typography.Text>
</Flex>
</Form.Item>
<Form.Item label={t('jobTitleLabel')} name="jobTitle">
<AutoComplete
options={jobTitles.map(job => ({
id: job.id,
label: job.name,
value: job.name,
}))}
allowClear
onSearch={handleSearch}
placeholder={t('jobTitlePlaceholder')}
onChange={(value, option) => {
form.setFieldsValue({ jobTitle: option?.label || value });
}}
onSelect={value => setSelectedJobTitle(value)}
dropdownRender={menu => (
<div>
{searching && <Spin size="small" />}
{menu}
</div>
)}
/>
</Form.Item>
<Form.Item label={t('memberAccessLabel')} name="access">
<Select
options={[
{ value: 'member', label: t('memberText') },
{ value: 'admin', label: t('adminText') },
]}
/>
</Form.Item>
</Form>
</Drawer>
);
};
export default InviteTeamMembers;

View File

@@ -0,0 +1,21 @@
import Icon, {
CheckCircleOutlined,
ClockCircleOutlined,
CloseCircleOutlined,
StopOutlined,
} from '@ant-design/icons';
const iconMap = {
'clock-circle': ClockCircleOutlined,
'close-circle': CloseCircleOutlined,
stop: StopOutlined,
'check-circle': CheckCircleOutlined,
};
const ProjectStatusIcon = ({ iconName, color }: { iconName: string; color: string }) => {
const IconComponent = iconMap[iconName as keyof typeof iconMap];
if (!IconComponent) return null;
return <IconComponent style={{ color: color }} />;
};
export default ProjectStatusIcon;

View File

@@ -0,0 +1,26 @@
import { AvatarNamesMap } from '@/shared/constants';
import { Avatar, Flex, Space } from 'antd';
interface SingleAvatarProps {
avatarUrl?: string;
name?: string;
email?: string;
}
const SingleAvatar: React.FC<SingleAvatarProps> = ({ avatarUrl, name, email = null }) => {
return (
<Avatar
src={avatarUrl}
size={28}
style={{
backgroundColor: avatarUrl ? 'transparent' : AvatarNamesMap[name?.charAt(0) || ''],
border: avatarUrl ? 'none' : '1px solid #d9d9d9',
marginRight: '8px',
}}
>
{name?.charAt(0)}
</Avatar>
);
};
export default SingleAvatar;

View File

@@ -0,0 +1,94 @@
.ant-drawer .ant-drawer-body {
overflow-x: hidden;
}
.ant-drawer-footer {
height: 55px;
}
.template-menu .ant-menu-item {
border-radius: 0 !important;
text-align: left !important;
padding-left: 8px !important;
}
.template-menu .ant-menu-item:hover {
background-color: transparent !important;
color: var(--color-skyBlue) !important;
}
.template-menu .ant-menu-item-selected {
border-right: 3px solid var(--color-skyBlue);
}
.template-menu .ant-menu-item-selected:hover {
background-color: var(--color-paleBlue) !important;
}
.template-menu {
height: 100%;
overflow-y: auto;
scrollbar-width: thin;
scrollbar-color: #888 transparent;
}
.template-menu::-webkit-scrollbar {
width: 10px;
}
.template-menu::-webkit-scrollbar-track {
background: transparent;
}
.template-menu::-webkit-scrollbar-thumb {
background-color: #888;
border-radius: 6px;
}
.template-menu::-webkit-scrollbar-thumb:hover {
background-color: #555;
}
.temp-details {
overflow-y: auto;
padding: 1rem 1.5rem !important;
height: 100%;
scrollbar-width: thin;
scrollbar-color: #888 transparent;
}
.temp-details::-webkit-scrollbar {
width: 10px;
}
.temp-details::-webkit-scrollbar-track {
background: transparent;
}
.temp-details::-webkit-scrollbar-thumb {
background-color: #888;
border-radius: 6px;
}
.temp-details::-webkit-scrollbar-thumb:hover {
background-color: #555;
}
.custom-template-list .ant-list-item {
cursor: pointer;
}
.custom-template-list .selected-custom-template {
background-color: var(--color-paleBlue);
}
.custom-template-list .selected-custom-template-dark {
background-color: #141414;
}
.custom-template-list .selected-custom-template:hover {
background-color: var(--color-paleBlue);
}

View File

@@ -0,0 +1,379 @@
import type { MenuProps } from 'antd';
import {
Empty,
List,
Menu,
Skeleton,
Tabs,
Tag,
Typography,
Image,
Input,
Flex,
Button,
} from 'antd';
import React, { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { useTranslation } from 'react-i18next';
import { RootState } from '@/app/store';
import { projectTemplatesApiService } from '@/api/project-templates/project-templates.api.service';
import {
ICustomTemplate,
IProjectTemplate,
IWorklenzTemplate,
} from '@/types/project-templates/project-templates.types';
import './template-drawer.css';
import { SearchOutlined } from '@ant-design/icons';
import logger from '@/utils/errorLogger';
const { Title, Text } = Typography;
interface TemplateDrawerProps {
showBothTabs: boolean;
templateSelected: (templateId: string) => void;
selectedTemplateType: (type: 'worklenz' | 'custom') => void;
}
const TemplateDrawer: React.FC<TemplateDrawerProps> = ({
showBothTabs = false,
templateSelected = (templateId: string) => {
if (!templateId) return;
templateId;
},
selectedTemplateType = (type: 'worklenz' | 'custom') => {
type;
},
}) => {
const themeMode = useSelector((state: RootState) => state.themeReducer.mode);
const { t } = useTranslation('template-drawer');
const [searchQuery, setSearchQuery] = useState('');
const [templates, setTemplates] = useState<IWorklenzTemplate[]>([]);
const [loadingTemplates, setLoadingTemplates] = useState(false);
const [customTemplates, setCustomTemplates] = useState<ICustomTemplate[]>([]);
const [loadingCustomTemplates, setLoadingCustomTemplates] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<IProjectTemplate | null>(null);
const [loadingSelectedTemplate, setLoadingSelectedTemplate] = useState(false);
const getSelectedTemplate = async (templateId: string) => {
try {
setLoadingSelectedTemplate(true);
const res = await projectTemplatesApiService.getByTemplateId(templateId);
if (res.done) {
setSelectedTemplate(res.body);
}
} catch (error) {
logger.error('Error loading template:', error);
} finally {
setLoadingSelectedTemplate(false);
}
};
const getTemplates = async () => {
try {
setLoadingTemplates(true);
const res = await projectTemplatesApiService.getWorklenzTemplates();
if (res.done) {
setTemplates(res.body);
if (res.body.length > 0 && res.body[0].id) {
templateSelected(res.body[0].id);
await getSelectedTemplate(res.body[0].id);
}
}
} catch (error) {
logger.error('Error loading templates:', error);
} finally {
setLoadingTemplates(false);
}
};
const getCustomTemplates = async () => {
try {
setLoadingCustomTemplates(true);
const res = await projectTemplatesApiService.getCustomTemplates();
if (res.done) {
setCustomTemplates(res.body);
}
} catch (error) {
logger.error('Error loading custom templates:', error);
} finally {
setLoadingCustomTemplates(false);
}
};
useEffect(() => {
getTemplates();
}, []);
const menuItems: MenuProps['items'] = templates.map(template => ({
key: template.id || '',
label: template.name || t('untitled'),
type: 'item',
}));
const handleMenuClick = (templateId: string) => {
templateSelected(templateId);
getSelectedTemplate(templateId);
};
const filteredCustomTemplates = customTemplates.filter(template =>
template.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
const renderTemplateDetails = () => {
if (!selectedTemplate) {
return <Empty description={t('noTemplateSelected')} />;
}
return (
<div>
{/* Description */}
<div className="template-detail-row mt-2">
<div className="template-detail-label">
<Text strong>{t('description')}</Text>
</div>
<div>
<Text>{selectedTemplate.description || t('noDescription')}</Text>
</div>
</div>
{/* Phase */}
<div className="template-detail-row mt-2">
<div className="template-detail-label">
<Text strong>{t('phase')}</Text>
</div>
<div>
{selectedTemplate.phases?.length ? (
selectedTemplate.phases.map(phase => (
<Tag
key={phase.name}
color={phase.color_code}
style={{ color: 'black', marginBottom: '8px' }}
>
{phase.name}
</Tag>
))
) : (
<Text type="secondary">{t('noPhases')}</Text>
)}
</div>
</div>
{/* Statuses */}
<div className="template-detail-row mt-2">
<div className="template-detail-label">
<Text strong>{t('statuses')}</Text>
</div>
<div>
{selectedTemplate.status?.length ? (
selectedTemplate.status.map(status => (
<Tag
key={status.name}
color={status.color_code}
style={{ color: 'black', marginBottom: '8px' }}
>
{status.name}
</Tag>
))
) : (
<Text type="secondary">{t('noStatuses')}</Text>
)}
</div>
</div>
{/* Priorities */}
<div className="template-detail-row mt-2">
<div className="template-detail-label">
<Text strong>{t('priorities')}</Text>
</div>
<div>
{selectedTemplate.priorities?.length ? (
selectedTemplate.priorities.map(priority => (
<Tag
key={priority.name}
color={priority.color_code}
style={{ color: 'black', marginBottom: '8px' }}
>
{priority.name}
</Tag>
))
) : (
<Text type="secondary">{t('noPriorities')}</Text>
)}
</div>
</div>
{/* Labels */}
<div className="template-detail-row mt-2">
<div className="template-detail-label">
<Text strong>{t('labels')}</Text>
</div>
<div>
{selectedTemplate.labels?.length ? (
selectedTemplate.labels.map(label => (
<Tag
key={label.name}
color={label.color_code}
style={{ color: 'black', marginBottom: '8px' }}
>
{label.name}
</Tag>
))
) : (
<Text type="secondary">{t('noLabels')}</Text>
)}
</div>
</div>
{/* Tasks */}
<div className="template-detail-row mt-2">
<div className="template-detail-label">
<Text strong>{t('tasks')}</Text>
</div>
<div style={{ marginTop: '0.5rem' }}>
{selectedTemplate.tasks?.length ? (
<List
dataSource={selectedTemplate.tasks}
renderItem={item => (
<List.Item key={item.name}>
<Text>{item.name}</Text>
</List.Item>
)}
/>
) : (
<Text type="secondary">{t('noTasks')}</Text>
)}
</div>
</div>
</div>
);
};
const menuContent = (
<div style={{ display: 'flex' }}>
{/* Menu Area */}
<div style={{ minWidth: '250px', overflowY: 'auto', height: '100%' }}>
<Skeleton loading={loadingTemplates} active>
<Menu
className="template-menu"
onClick={({ key }) => handleMenuClick(key)}
style={{ width: 256 }}
defaultSelectedKeys={[templates[0]?.id || '']}
mode="inline"
items={menuItems}
/>
</Skeleton>
</div>
{/* Content Area */}
<div
className="temp-details"
style={{
flex: 1,
maxHeight: 'calc(100vh - 200px)',
padding: '16px',
}}
>
<Title level={4}>Details</Title>
<Skeleton loading={loadingSelectedTemplate} active>
{selectedTemplate?.image_url && (
<Image preview={false} src={selectedTemplate.image_url} alt={selectedTemplate.name} />
)}
{renderTemplateDetails()}
</Skeleton>
</div>
</div>
);
const handleCustomTemplateClick = (templateId: string) => {
const updatedCustomTemplates = customTemplates.map(template =>
template.id === templateId
? { ...template, selected: true }
: { ...template, selected: false }
);
setCustomTemplates(updatedCustomTemplates);
templateSelected(templateId);
selectedTemplateType('custom');
};
const customTemplatesContent = (
<div>
<Flex justify="space-between" align="center">
<Input
placeholder={t('searchTemplates')}
suffix={<SearchOutlined />}
style={{ maxWidth: '300px' }}
onChange={e => setSearchQuery(e.target.value)}
/>
</Flex>
<List
className="custom-template-list mt-4"
bordered
dataSource={filteredCustomTemplates}
loading={loadingCustomTemplates}
renderItem={item => (
<List.Item
key={item.id}
onClick={() => handleCustomTemplateClick(item.id || '')}
className={
item.selected && themeMode === 'dark'
? 'selected-custom-template-dark'
: item.selected && themeMode === 'light'
? 'selected-custom-template'
: ''
}
>
{item.name}
</List.Item>
)}
/>
</div>
);
const tabs = [
{
key: '1',
label: t('worklenzTemplates'),
children: menuContent,
},
{
key: '2',
label: t('yourTemplatesLibrary'),
children: customTemplatesContent,
},
];
const handleTabChange = (key: string) => {
if (key === '1') {
getTemplates();
selectedTemplateType('worklenz');
} else {
getCustomTemplates();
selectedTemplateType('custom');
}
};
return (
<div style={{ height: '100vh', overflow: 'hidden' }}>
<div
style={{
position: 'sticky',
top: 0,
zIndex: 100,
backgroundColor: themeMode === 'dark' ? '' : '#fff',
overflow: 'hidden',
}}
>
{showBothTabs ? (
<Tabs type="card" items={tabs} onChange={handleTabChange} destroyInactiveTabPane />
) : (
menuContent
)}
</div>
</div>
);
};
export default TemplateDrawer;

View File

@@ -0,0 +1,19 @@
.status-dropdown .ant-dropdown-menu {
padding: 0 !important;
margin-top: 8px !important;
overflow: hidden;
}
.status-dropdown .ant-dropdown-menu-item {
padding: 0 !important;
}
.status-dropdown-card .ant-card-body {
padding: 0 !important;
}
.status-menu .ant-menu-item {
display: flex;
align-items: center;
height: 41px !important;
}

View File

@@ -0,0 +1,117 @@
import { Badge, Flex, Select } from 'antd';
import './home-tasks-status-dropdown.css';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useTranslation } from 'react-i18next';
import { ITaskStatus } from '@/types/status.types';
import { useState, useEffect, useMemo } from 'react';
import { ALPHA_CHANNEL } from '@/shared/constants';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.types';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { useGetMyTasksQuery } from '@/api/home-page/home-page.api.service';
type HomeTasksStatusDropdownProps = {
task: IProjectTask;
teamId: string;
};
const HomeTasksStatusDropdown = ({ task, teamId }: HomeTasksStatusDropdownProps) => {
const { t } = useTranslation('task-list-table');
const { socket, connected } = useSocket();
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
const {
refetch
} = useGetMyTasksQuery(homeTasksConfig, {
skip: true // Skip automatic queries entirely
});
const [selectedStatus, setSelectedStatus] = useState<ITaskStatus | undefined>(undefined);
const handleStatusChange = (statusId: string) => {
if (!task.id || !statusId) return;
socket?.emit(
SocketEvents.TASK_STATUS_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
status_id: statusId,
parent_task: task.parent_task_id || null,
team_id: teamId,
})
);
getTaskProgress(task.id);
};
const handleTaskStatusChange = (response: ITaskListStatusChangeResponse) => {
if (response && response.id === task.id) {
const updatedTask = {
...task,
status_color: response.color_code,
complete_ratio: +response.complete_ratio || 0,
status_id: response.status_id,
status_category: response.statusCategory,
};
setSelectedStatus(updatedTask);
// Only refetch when there's an actual status change
if (response.status_id !== task.status_id) {
refetch();
}
}
};
const getTaskProgress = (taskId: string) => {
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), taskId);
};
useEffect(() => {
const foundStatus = task.project_statuses?.find(status => status.id === task.status_id);
setSelectedStatus(foundStatus);
}, [task.status_id, task.project_statuses]);
useEffect(() => {
socket?.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
return () => {
socket?.removeListener(SocketEvents.TASK_STATUS_CHANGE.toString(), handleTaskStatusChange);
};
}, [connected]);
const options = useMemo(
() =>
task.project_statuses?.map(status => ({
value: status.id,
label: (
<Flex gap={8} align="center">
<Badge color={status.color_code} text={status.name} />
</Flex>
),
})),
[task.project_statuses]
);
return (
<>
{
<Select
variant="borderless"
value={task.status_id}
onChange={handleStatusChange}
dropdownStyle={{ borderRadius: 8, minWidth: 150, maxWidth: 200 }}
style={{
backgroundColor: selectedStatus?.color_code + ALPHA_CHANNEL,
borderRadius: 16,
height: 22,
}}
labelRender={value => {
const status = task.project_statuses?.find(status => status.id === value.value);
return status ? <span style={{ fontSize: 13 }}>{status.name}</span> : '';
}}
options={options}
/>
}
</>
);
};
export default HomeTasksStatusDropdown;

View File

@@ -0,0 +1,110 @@
import { useSocket } from "@/socket/socketContext";
import { IProjectTask } from "@/types/project/projectTasksViewModel.types";
import { DatePicker } from "antd";
import dayjs from 'dayjs';
import calendar from 'dayjs/plugin/calendar';
import { SocketEvents } from '@/shared/socket-events';
import type { Dayjs } from 'dayjs';
import { useTranslation } from "react-i18next";
import { useEffect, useState, useMemo } from "react";
import { useAppSelector } from "@/hooks/useAppSelector";
import { useGetMyTasksQuery } from "@/api/home-page/home-page.api.service";
import { getUserSession } from "@/utils/session-helper";
// Extend dayjs with the calendar plugin
dayjs.extend(calendar);
type HomeTasksDatePickerProps = {
record: IProjectTask;
};
const HomeTasksDatePicker = ({ record }: HomeTasksDatePickerProps) => {
const { socket, connected } = useSocket();
const { t } = useTranslation('home');
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
const { refetch } = useGetMyTasksQuery(homeTasksConfig, {
skip: true // Skip automatic queries entirely
});
// Use useMemo to avoid re-renders when record.end_date is the same
const initialDate = useMemo(() =>
record.end_date ? dayjs(record.end_date) : null
, [record.end_date]);
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(initialDate);
// Update selected date when record changes
useEffect(() => {
setSelectedDate(initialDate);
}, [initialDate]);
const handleChangeReceived = (value: any) => {
refetch();
};
useEffect(() => {
socket?.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleChangeReceived);
socket?.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleChangeReceived);
return () => {
socket?.removeListener(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleChangeReceived);
socket?.removeListener(SocketEvents.TASK_STATUS_CHANGE.toString(), handleChangeReceived);
};
}, [connected]);
const handleEndDateChanged = (value: Dayjs | null, task: IProjectTask) => {
setSelectedDate(value);
if (!task.id) return;
const body = {
task_id: task.id,
end_date: value?.format('YYYY-MM-DD'),
parent_task: task.parent_task_id,
time_zone: getUserSession()?.timezone_name
? getUserSession()?.timezone_name
: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
socket?.emit(SocketEvents.TASK_END_DATE_CHANGE.toString(), JSON.stringify(body));
};
// Function to dynamically format the date based on the calendar rules
const getFormattedDate = (date: Dayjs | null) => {
if (!date) return '';
return date.calendar(null, {
sameDay: '[Today]',
nextDay: '[Tomorrow]',
nextWeek: 'MMM DD',
lastDay: '[Yesterday]',
lastWeek: 'MMM DD',
sameElse: date.year() === dayjs().year() ? 'MMM DD' : 'MMM DD, YYYY',
});
};
return (
<DatePicker
allowClear
disabledDate={
record.start_date ? current => current.isBefore(dayjs(record.start_date)) : undefined
}
placeholder={t('tasks.dueDatePlaceholder')}
value={selectedDate}
onChange={value => handleEndDateChanged(value || null, record || null)}
format={(value) => getFormattedDate(value)} // Dynamically format the displayed value
style={{
color: selectedDate
? selectedDate.isSame(dayjs(), 'day') || selectedDate.isSame(dayjs().add(1, 'day'), 'day')
? '#52c41a'
: selectedDate.isAfter(dayjs().add(1, 'day'), 'day')
? undefined
: '#ff4d4f'
: undefined,
width: '125px', // Ensure the input takes full width
}}
inputReadOnly // Prevent manual input to avoid overflow issues
variant={'borderless'} // Make the DatePicker borderless
suffixIcon={null}
/>
);
};
export default HomeTasksDatePicker;

View File

@@ -0,0 +1,117 @@
import { teamsApiService } from '@/api/teams/teams.api.service';
import { verifyAuthentication } from '@/features/auth/authSlice';
import { setActiveTeam } from '@/features/teams/teamSlice';
import { setUser } from '@/features/user/userSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { createAuthService } from '@/services/auth/auth.service';
import { ITeamInvitationViewModel } from '@/types/notifications/notifications.types';
import { IAcceptTeamInvite } from '@/types/teams/team.type';
import logger from '@/utils/errorLogger';
import { TFunction } from 'i18next';
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
interface InvitationItemProps {
item: ITeamInvitationViewModel;
isUnreadNotifications: boolean;
t: TFunction;
}
const InvitationItem: React.FC<InvitationItemProps> = ({ item, isUnreadNotifications, t }) => {
const [accepting, setAccepting] = useState(false);
const [joining, setJoining] = useState(false);
const dispatch = useAppDispatch();
const navigate = useNavigate();
const authService = createAuthService(navigate);
const inProgress = () => accepting || joining;
const acceptInvite = async (showAlert?: boolean) => {
if (!item.team_member_id) return;
try {
setAccepting(true);
const body: IAcceptTeamInvite = {
team_member_id: item.team_member_id,
show_alert: showAlert,
};
const res = await teamsApiService.acceptInvitation(body);
setAccepting(false);
if (res.done && res.body.id) {
return res.body;
}
} catch (error) {
logger.error('Error accepting invitation', error);
}
return null;
};
const handleVerifyAuth = async () => {
const result = await dispatch(verifyAuthentication()).unwrap();
if (result.authenticated) {
dispatch(setUser(result.user));
authService.setCurrentSession(result.user);
}
};
const acceptAndJoin = async () => {
try {
const res = await acceptInvite(true);
if (res && res.id) {
setJoining(true);
await dispatch(setActiveTeam(res.id));
await handleVerifyAuth();
window.location.reload();
setJoining(false);
}
} catch (error) {
logger.error('Error accepting and joining invitation', error);
} finally {
setAccepting(false);
setJoining(false);
}
};
return (
<div
style={{ width: 'auto' }}
className="ant-notification-notice worklenz-notification rounded-4"
>
<div className="ant-notification-notice-content">
<div className="ant-notification-notice-description">
You have been invited to work with <b>{item.team_name}</b>.
</div>
{isUnreadNotifications && (
<div className="mt-2" style={{ display: 'flex', gap: '8px', justifyContent: 'space-between' }}>
<button
onClick={() => acceptInvite(true)}
disabled={inProgress()}
className="p-0"
style={{
background: 'none',
border: 'none',
cursor: inProgress() ? 'not-allowed' : 'pointer',
}}
>
{item.accepting ? 'Loading...' : <u>{t('notificationsDrawer.markAsRead')}</u>}
</button>
<button
onClick={() => acceptAndJoin()}
disabled={inProgress()}
style={{
background: 'none',
border: 'none',
cursor: inProgress() ? 'not-allowed' : 'pointer',
}}
>
{item.joining ? 'Loading...' : <u>{t('notificationsDrawer.readAndJoin')}</u>}
</button>
</div>
)}
</div>
</div>
);
};
export default InvitationItem;

View File

@@ -0,0 +1,303 @@
import { Drawer, Empty, Segmented, Typography, Spin, Button, Flex } from 'antd';
import { useEffect, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
fetchInvitations,
fetchNotifications,
setNotificationType,
toggleDrawer,
} from '../../../../../features/navbar/notificationSlice';
import { NOTIFICATION_OPTION_READ, NOTIFICATION_OPTION_UNREAD } from '@/shared/constants';
import { useTranslation } from 'react-i18next';
import { SocketEvents } from '@/shared/socket-events';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { useSocket } from '@/socket/socketContext';
import { ITeamInvitationViewModel } from '@/types/notifications/notifications.types';
import logger from '@/utils/errorLogger';
import NotificationItem from './notification-item';
import InvitationItem from './invitation-item';
import { notificationsApiService } from '@/api/notifications/notifications.api.service';
import { profileSettingsApiService } from '@/api/settings/profile/profile-settings.api.service';
import { INotificationSettings } from '@/types/settings/notifications.types';
import { toQueryString } from '@/utils/toQueryString';
import { showNotification } from './push-notification-template';
import { teamsApiService } from '@/api/teams/teams.api.service';
import { verifyAuthentication } from '@/features/auth/authSlice';
import { getUserSession } from '@/utils/session-helper';
import { setUser } from '@/features/user/userSlice';
import { useNavigate } from 'react-router-dom';
import { createAuthService } from '@/services/auth/auth.service';
const HTML_TAG_REGEXP = /<[^>]*>/g;
const NotificationDrawer = () => {
const { isDrawerOpen, notificationType, notifications, invitations } = useAppSelector(
state => state.notificationReducer
);
const dispatch = useAppDispatch();
const { t } = useTranslation('navbar');
const { socket, connected } = useSocket();
const [notificationsSettings, setNotificationsSettings] = useState<INotificationSettings>({});
const [showBrowserPush, setShowBrowserPush] = useState(false);
const notificationCount = notifications?.length || 0;
const [isLoading, setIsLoading] = useState(false);
const isPushEnabled = () => {
return notificationsSettings.popup_notifications_enabled && showBrowserPush;
};
const navigate = useNavigate();
const authService = createAuthService(navigate);
const createPush = (message: string, title: string, teamId: string | null, url?: string) => {
if (Notification.permission === 'granted' && showBrowserPush) {
const img = 'https://worklenz.com/assets/icons/icon-128x128.png';
const notification = new Notification(title, {
body: message.replace(HTML_TAG_REGEXP, ''),
icon: img,
badge: img,
});
notification.onclick = async event => {
if (url) {
window.focus();
if (teamId) {
await teamsApiService.setActiveTeam(teamId);
}
window.location.href = url;
}
};
}
};
const handleInvitationsUpdate = (data: ITeamInvitationViewModel[]) => {
dispatch(fetchInvitations());
};
const handleNotificationsUpdate = async (notification: IWorklenzNotification) => {
dispatch(fetchNotifications(notificationType));
dispatch(fetchInvitations());
if (isPushEnabled()) {
const title = notification.team ? `${notification.team} | Worklenz` : 'Worklenz';
let url = notification.url;
if (url && notification.params && Object.keys(notification.params).length) {
const q = toQueryString(notification.params);
url += q;
}
createPush(notification.message, title, notification.team_id, url);
}
// Show notification using the template
showNotification(notification);
};
const handleTeamInvitationsUpdate = async (data: ITeamInvitationViewModel) => {
const notification: IWorklenzNotification = {
id: data.id || '',
team: data.team_name || '',
team_id: data.team_id || '',
message: `You have been invited to join ${data.team_name || 'a team'}`,
};
if (isPushEnabled()) {
createPush(
notification.message,
notification.team || 'Worklenz',
notification.team_id || null
);
}
// Show notification using the template
showNotification(notification);
dispatch(fetchInvitations());
};
const askPushPermission = () => {
if ('Notification' in window && 'serviceWorker' in navigator && 'PushManager' in window) {
if (Notification.permission !== 'granted') {
Notification.requestPermission().then(permission => {
if (permission === 'granted') {
setShowBrowserPush(true);
logger.info('Permission granted');
}
});
} else if (Notification.permission === 'granted') {
setShowBrowserPush(true);
}
} else {
logger.error('This browser does not support notification permission.');
return;
}
};
const markNotificationAsRead = async (id: string) => {
if (!id) return;
const res = await notificationsApiService.updateNotification(id);
if (res.done) {
dispatch(fetchNotifications(notificationType));
dispatch(fetchInvitations());
}
};
const handleVerifyAuth = async () => {
const result = await dispatch(verifyAuthentication()).unwrap();
if (result.authenticated) {
dispatch(setUser(result.user));
authService.setCurrentSession(result.user);
}
};
const goToUrl = async (event: React.MouseEvent, notification: IWorklenzNotification) => {
event.preventDefault();
event.stopPropagation();
if (notification.url) {
dispatch(toggleDrawer());
setIsLoading(true);
try {
const currentSession = getUserSession();
if (currentSession?.team_id && notification.team_id !== currentSession.team_id) {
await handleVerifyAuth();
}
if (notification.project && notification.task_id) {
navigate(`${notification.url}${toQueryString({task: notification.params?.task, tab: notification.params?.tab})}`);
}
} catch (error) {
console.error('Error navigating to URL:', error);
} finally {
setIsLoading(false);
}
}
};
const fetchNotificationsSettings = async () => {
try {
setIsLoading(true);
const res = await profileSettingsApiService.getNotificationSettings();
if (res.done) {
setNotificationsSettings(res.body);
}
} catch (error) {
logger.error('Error fetching notifications settings', error);
} finally {
setIsLoading(false);
}
};
const handleMarkAllAsRead = async () => {
await notificationsApiService.readAllNotifications();
dispatch(fetchNotifications(notificationType));
dispatch(fetchInvitations());
};
useEffect(() => {
socket?.on(SocketEvents.INVITATIONS_UPDATE.toString(), handleInvitationsUpdate);
socket?.on(SocketEvents.NOTIFICATIONS_UPDATE.toString(), handleNotificationsUpdate);
socket?.on(SocketEvents.TEAM_MEMBER_REMOVED.toString(), handleTeamInvitationsUpdate);
fetchNotificationsSettings();
askPushPermission();
return () => {
socket?.removeListener(SocketEvents.INVITATIONS_UPDATE.toString(), handleInvitationsUpdate);
socket?.removeListener(
SocketEvents.NOTIFICATIONS_UPDATE.toString(),
handleNotificationsUpdate
);
socket?.removeListener(
SocketEvents.TEAM_MEMBER_REMOVED.toString(),
handleTeamInvitationsUpdate
);
};
}, [socket]);
useEffect(() => {
setIsLoading(true);
dispatch(fetchInvitations());
if (notificationType) {
dispatch(fetchNotifications(notificationType)).finally(() => setIsLoading(false));
}
}, [notificationType, dispatch]);
return (
<Drawer
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{notificationType === NOTIFICATION_OPTION_READ
? t('notificationsDrawer.read')
: t('notificationsDrawer.unread')}{' '}
({notificationCount})
</Typography.Text>
}
open={isDrawerOpen}
onClose={() => dispatch(toggleDrawer())}
width={400}
>
<Flex justify="space-between" align="center">
<Segmented<string>
options={['Unread', 'Read']}
defaultValue={NOTIFICATION_OPTION_UNREAD}
onChange={(value: string) => {
if (value === NOTIFICATION_OPTION_UNREAD)
dispatch(setNotificationType(NOTIFICATION_OPTION_UNREAD));
if (value === NOTIFICATION_OPTION_READ)
dispatch(setNotificationType(NOTIFICATION_OPTION_READ));
}}
/>
<Button type="link" onClick={handleMarkAllAsRead}>
{t('notificationsDrawer.markAsRead')}
</Button>
</Flex>
{isLoading && (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: 40 }}>
<Spin />
</div>
)}
{invitations && invitations.length > 0 && notificationType === NOTIFICATION_OPTION_UNREAD ? (
<div className="notification-list mt-3">
{invitations.map(invitation => (
<InvitationItem
key={invitation.id}
item={invitation}
isUnreadNotifications={notificationType === NOTIFICATION_OPTION_UNREAD}
t={t}
/>
))}
</div>
) : null}
{notifications && notifications.length > 0 ? (
<div className="notification-list mt-3">
{notifications.map(notification => (
<NotificationItem
key={notification.id}
notification={notification}
isUnreadNotifications={notificationType === NOTIFICATION_OPTION_UNREAD}
markNotificationAsRead={id => Promise.resolve(markNotificationAsRead(id))}
goToUrl={goToUrl}
/>
))}
</div>
) : (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={t('notificationsDrawer.noNotifications')}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
marginBlockStart: 32,
}}
/>
)}
</Drawer>
);
};
export default NotificationDrawer;

View File

@@ -0,0 +1,41 @@
import { BellOutlined } from '@ant-design/icons';
import { Badge, Button, Tooltip } from 'antd';
import { toggleDrawer } from '@features/navbar/notificationSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
const NotificationButton = () => {
const dispatch = useAppDispatch();
const { notifications, invitations } = useAppSelector(state => state.notificationReducer);
const { t } = useTranslation('navbar');
const hasNotifications = () => {
return notifications.length > 0 || invitations.length > 0;
};
const notificationCount = () => {
return notifications.length + invitations.length;
};
return (
<Tooltip title={t('notificationTooltip')} trigger={'hover'}>
<Button
style={{ height: '62px', width: '60px' }}
type="text"
icon={
hasNotifications() ? (
<Badge count={notificationCount()}>
<BellOutlined style={{ fontSize: 20 }} />
</Badge>
) : (
<BellOutlined style={{ fontSize: 20 }} />
)
}
onClick={() => dispatch(toggleDrawer())}
/>
</Tooltip>
);
};
export default NotificationButton;

View File

@@ -0,0 +1,398 @@
.ant-notification {
box-sizing: border-box;
margin: 0;
padding: 0;
color: rgba(0, 0, 0, 0.85);
font-size: 14px;
font-variant: tabular-nums;
line-height: 1.5715;
list-style: none;
font-feature-settings: "tnum";
position: fixed;
z-index: 1010;
margin-right: 24px;
}
.ant-notification-topLeft,
.ant-notification-bottomLeft {
margin-right: 0;
margin-left: 24px;
}
.ant-notification-topLeft .ant-notification-fade-enter.ant-notification-fade-enter-active,
.ant-notification-bottomLeft .ant-notification-fade-enter.ant-notification-fade-enter-active,
.ant-notification-topLeft .ant-notification-fade-appear.ant-notification-fade-appear-active,
.ant-notification-bottomLeft .ant-notification-fade-appear.ant-notification-fade-appear-active {
-webkit-animation-name: NotificationLeftFadeIn;
animation-name: NotificationLeftFadeIn;
}
.ant-notification-close-icon {
font-size: 14px;
cursor: pointer;
}
.ant-notification-hook-holder {
position: relative;
}
.ant-notification-notice {
position: relative;
width: 384px;
max-width: calc(100vw - 24px * 2);
/* margin-bottom: 16px; */
margin-left: auto;
padding: 16px 24px;
overflow: hidden;
line-height: 1.5715;
word-wrap: break-word;
background: #fff;
border-radius: 2px;
box-shadow:
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
}
.ant-notification-topLeft .ant-notification-notice,
.ant-notification-bottomLeft .ant-notification-notice {
margin-right: auto;
margin-left: 0;
}
.ant-notification-notice-message {
margin-bottom: 8px;
font-size: 16px;
line-height: 24px;
}
.ant-notification-notice-message-single-line-auto-margin {
display: block;
width: calc(384px - 24px * 2 - 24px - 48px - 100%);
max-width: 4px;
background-color: transparent;
pointer-events: none;
}
.ant-notification-notice-message-single-line-auto-margin::before {
display: block;
content: "";
}
.ant-notification-notice-description {
font-size: 14px;
}
.ant-notification-notice-closable .ant-notification-notice-message {
padding-right: 24px;
}
.ant-notification-notice-with-icon .ant-notification-notice-message {
margin-bottom: 4px;
margin-left: 48px;
font-size: 16px;
}
.ant-notification-notice-with-icon .ant-notification-notice-description {
margin-left: 48px;
font-size: 14px;
}
.ant-notification-notice-icon {
position: absolute;
margin-left: 4px;
font-size: 24px;
line-height: 24px;
}
.anticon.ant-notification-notice-icon-success {
color: #52c41a;
}
.anticon.ant-notification-notice-icon-info {
color: #1890ff;
}
.anticon.ant-notification-notice-icon-warning {
color: #faad14;
}
.anticon.ant-notification-notice-icon-error {
color: #ff4d4f;
}
.ant-notification-notice-close {
position: absolute;
top: 16px;
right: 22px;
color: rgba(0, 0, 0, 0.45);
outline: none;
}
.ant-notification-notice-close:hover {
color: rgba(0, 0, 0, 0.67);
}
.ant-notification-notice-btn {
float: right;
margin-top: 16px;
}
.ant-notification .notification-fade-effect {
-webkit-animation-duration: 0.24s;
animation-duration: 0.24s;
-webkit-animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
}
.ant-notification-fade-enter,
.ant-notification-fade-appear {
-webkit-animation-duration: 0.24s;
animation-duration: 0.24s;
-webkit-animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
opacity: 0;
-webkit-animation-play-state: paused;
animation-play-state: paused;
}
.ant-notification-fade-leave {
-webkit-animation-duration: 0.24s;
animation-duration: 0.24s;
-webkit-animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
animation-timing-function: cubic-bezier(0.645, 0.045, 0.355, 1);
-webkit-animation-fill-mode: both;
animation-fill-mode: both;
-webkit-animation-duration: 0.2s;
animation-duration: 0.2s;
-webkit-animation-play-state: paused;
animation-play-state: paused;
}
.ant-notification-fade-enter.ant-notification-fade-enter-active,
.ant-notification-fade-appear.ant-notification-fade-appear-active {
-webkit-animation-name: NotificationFadeIn;
animation-name: NotificationFadeIn;
-webkit-animation-play-state: running;
animation-play-state: running;
}
.ant-notification-fade-leave.ant-notification-fade-leave-active {
-webkit-animation-name: NotificationFadeOut;
animation-name: NotificationFadeOut;
-webkit-animation-play-state: running;
animation-play-state: running;
}
@-webkit-keyframes NotificationFadeIn {
0% {
left: 384px;
opacity: 0;
}
100% {
left: 0;
opacity: 1;
}
}
@keyframes NotificationFadeIn {
0% {
left: 384px;
opacity: 0;
}
100% {
left: 0;
opacity: 1;
}
}
@-webkit-keyframes NotificationLeftFadeIn {
0% {
right: 384px;
opacity: 0;
}
100% {
right: 0;
opacity: 1;
}
}
@keyframes NotificationLeftFadeIn {
0% {
right: 384px;
opacity: 0;
}
100% {
right: 0;
opacity: 1;
}
}
@-webkit-keyframes NotificationFadeOut {
0% {
max-height: 150px;
margin-bottom: 16px;
opacity: 1;
}
100% {
max-height: 0;
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
}
}
@keyframes NotificationFadeOut {
0% {
max-height: 150px;
margin-bottom: 16px;
opacity: 1;
}
100% {
max-height: 0;
margin-bottom: 0;
padding-top: 0;
padding-bottom: 0;
opacity: 0;
}
}
.notification-item {
border: 1px solid var(--border-color, #f0f0f0);
border-radius: 4px;
padding: 16px;
margin-bottom: 12px;
position: relative;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
transition: all 0.3s ease;
background-color: var(--bg-color, #fff);
}
.notification-item:hover {
background-color: var(--hover-bg-color, #fafafa);
}
.notification-item .ant-notification-notice-message {
margin-bottom: 8px;
font-weight: 500;
display: flex;
align-items: center;
gap: 8px;
color: var(--text-color, rgba(0, 0, 0, 0.85));
}
.notification-item .ant-notification-notice-description {
color: var(--description-color, rgba(0, 0, 0, 0.65));
}
/* Light mode (default) */
:root {
--border-color: #f0f0f0;
--bg-color: #fff;
--hover-bg-color: #fafafa;
--text-color: rgba(0, 0, 0, 0.85);
--description-color: rgba(0, 0, 0, 0.65);
}
/* Dark mode */
[data-theme="dark"] .notification-item,
.dark-theme .notification-item,
.ant-layout-dark .notification-item {
--border-color: #303030;
--bg-color: #141414;
--hover-bg-color: #1f1f1f;
--text-color: rgba(255, 255, 255, 0.85);
--description-color: rgba(255, 255, 255, 0.65);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
/* Ensure the close button is visible in both themes */
[data-theme="dark"] .notification-item .ant-btn,
.dark-theme .notification-item .ant-btn,
.ant-layout-dark .notification-item .ant-btn {
color: rgba(255, 255, 255, 0.65);
}
[data-theme="dark"] .notification-item .ant-btn:hover,
.dark-theme .notification-item .ant-btn:hover,
.ant-layout-dark .notification-item .ant-btn:hover {
color: rgba(255, 255, 255, 0.85);
}
/* Add these new styles at the end of the file */
.worklenz-notification {
padding: 12px;
margin-bottom: 12px;
transition: all 0.3s ease;
background-color: var(--background-color);
}
/* Light mode styles */
[data-theme="light"] .worklenz-notification {
--background-color: #fff;
--text-color: rgba(0, 0, 0, 0.85);
--secondary-text-color: rgba(0, 0, 0, 0.45);
--hover-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
/* Dark mode styles */
[data-theme="dark"] .worklenz-notification {
--background-color: #1f1f1f;
--text-color: rgba(255, 255, 255, 0.85);
--secondary-text-color: rgba(255, 255, 255, 0.45);
--hover-shadow: 0 2px 8px rgba(0, 0, 0, 0.45);
}
.worklenz-notification:hover {
box-shadow: var(--hover-shadow);
}
.worklenz-notification .ant-notification-notice-description {
color: var(--text-color);
}
.worklenz-notification .ant-typography-secondary {
color: var(--secondary-text-color) !important;
}
.rounded-4 {
border-radius: 4px;
}
.cursor-pointer {
cursor: pointer;
}
.mb-1 {
margin-bottom: 8px;
}
.mt-1 {
margin-top: 8px;
}
.d-flex {
display: flex;
}
.align-items-baseline {
align-items: baseline;
}
.justify-content-between {
justify-content: space-between;
}
.p-0 {
padding: 0;
}
.small {
font-size: 12px;
}

View File

@@ -0,0 +1,127 @@
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { BankOutlined } from '@ant-design/icons';
import { Button, Tag, Typography, theme } from 'antd';
import DOMPurify from 'dompurify';
import React, { useState } from 'react';
import { fromNow } from '@/utils/dateUtils';
import './notification-item.css';
const { Text } = Typography;
interface NotificationItemProps {
notification: IWorklenzNotification;
isUnreadNotifications?: boolean;
markNotificationAsRead?: (id: string) => Promise<void>;
goToUrl?: (e: React.MouseEvent, notification: IWorklenzNotification) => Promise<void>;
}
const NotificationItem = ({
notification,
isUnreadNotifications = true,
markNotificationAsRead,
goToUrl,
}: NotificationItemProps) => {
const { token } = theme.useToken();
const [loading, setLoading] = useState(false);
const isDarkMode =
token.colorBgContainer === '#141414' ||
token.colorBgContainer.includes('dark') ||
document.documentElement.getAttribute('data-theme') === 'dark';
const handleNotificationClick = async (e: React.MouseEvent) => {
await goToUrl?.(e, notification);
await markNotificationAsRead?.(notification.id);
};
const handleMarkAsRead = async (e: React.MouseEvent) => {
e.stopPropagation();
if (!notification.id) return;
setLoading(true);
try {
await markNotificationAsRead?.(notification.id);
} finally {
setLoading(false);
}
};
const createSafeHtml = (html: string) => {
return { __html: DOMPurify.sanitize(html) };
};
const getTagBackground = (color?: string) => {
if (!color) return {};
// Create a more transparent version of the color for the background
// This is equivalent to the color + '4d' in the Angular template
const bgColor = `${color}4d`;
// For dark mode, we might need to adjust the text color for better contrast
if (isDarkMode) {
return {
backgroundColor: bgColor,
color: '#ffffff',
borderColor: 'transparent',
};
}
return {
backgroundColor: bgColor,
borderColor: 'transparent',
};
};
return (
<div
style={{
width: 'auto',
border: notification.color ? `2px solid ${notification.color}4d` : undefined,
cursor: notification.url ? 'pointer' : 'default',
}}
onClick={handleNotificationClick}
className="ant-notification-notice worklenz-notification rounded-4"
>
<div className="ant-notification-notice-content">
<div className="ant-notification-notice-description">
{/* Team name */}
<div className="mb-1">
<Text type="secondary">
<BankOutlined /> {notification.team}
</Text>
</div>
{/* Message with HTML content */}
<div className="mb-1" dangerouslySetInnerHTML={createSafeHtml(notification.message)} />
{/* Project tag */}
{notification.project && (
<div>
<Tag style={getTagBackground(notification.color)}>{notification.project}</Tag>
</div>
)}
</div>
{/* Footer with mark as read button and timestamp */}
<div className="d-flex align-items-baseline justify-content-between mt-1">
{isUnreadNotifications && markNotificationAsRead && (
<Button
loading={loading}
type="link"
size="small"
shape="round"
className="p-0"
onClick={e => handleMarkAsRead(e)}
>
<u>Mark as read</u>
</Button>
)}
<Text type="secondary" className="small">
{notification.created_at ? fromNow(notification.created_at) : ''}
</Text>
</div>
</div>
</div>
);
};
export default NotificationItem;

View File

@@ -0,0 +1,95 @@
import { Button, Typography, Tag } from 'antd';
import { BankOutlined } from '@ant-design/icons';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { useNavigate } from 'react-router-dom';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleDrawer } from '../../../../../features/navbar/notificationSlice';
import { teamsApiService } from '@/api/teams/teams.api.service';
import { formatDistanceToNow } from 'date-fns';
import { tagBackground } from '@/utils/colorUtils';
interface NotificationTemplateProps {
item: IWorklenzNotification;
isUnreadNotifications: boolean;
markNotificationAsRead: (id: string) => Promise<void>;
loadersMap: Record<string, boolean>;
}
const NotificationTemplate: React.FC<NotificationTemplateProps> = ({
item,
isUnreadNotifications,
markNotificationAsRead,
loadersMap,
}) => {
const navigate = useNavigate();
const dispatch = useAppDispatch();
const goToUrl = async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
console.log('goToUrl triggered', { url: item.url, teamId: item.team_id });
if (item.url) {
dispatch(toggleDrawer());
if (item.team_id) {
await teamsApiService.setActiveTeam(item.team_id);
}
navigate(item.url, {
state: item.params || null,
});
}
};
const formatDate = (dateString?: string) => {
if (!dateString) return '';
return formatDistanceToNow(new Date(dateString), { addSuffix: true });
};
const handleMarkAsRead = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
markNotificationAsRead(item.id);
};
return (
<div
style={{ width: 'auto', border: `2px solid ${item.color}4d` }}
onClick={goToUrl}
className={`ant-notification-notice worklenz-notification rounded-4 ${item.url ? 'cursor-pointer' : ''}`}
>
<div className="ant-notification-notice-content">
<div className="ant-notification-notice-description">
<Typography.Text type="secondary" className="mb-1">
<BankOutlined /> {item.team}
</Typography.Text>
<div className="mb-1" dangerouslySetInnerHTML={{ __html: item.message }} />
{item.project && item.color && (
<Tag style={{ backgroundColor: tagBackground(item.color) }}>{item.project}</Tag>
)}
</div>
<div className="d-flex align-items-baseline justify-content-between mt-1">
{isUnreadNotifications && (
<Button
type="link"
shape="round"
size="small"
loading={loadersMap[item.id]}
onClick={handleMarkAsRead}
>
<u>Mark as read</u>
</Button>
)}
<Typography.Text type="secondary" className="small">
{formatDate(item.created_at)}
</Typography.Text>
</div>
</div>
</div>
);
};
export default NotificationTemplate;

View File

@@ -0,0 +1,7 @@
.notification-content.clickable {
transition: background-color 0.2s ease;
}
.notification-content.clickable:hover {
background-color: rgba(0, 0, 0, 0.02);
}

View File

@@ -0,0 +1,99 @@
import { notification } from 'antd';
import { IWorklenzNotification } from '@/types/notifications/notifications.types';
import { teamsApiService } from '@/api/teams/teams.api.service';
import { toQueryString } from '@/utils/toQueryString';
import { BankOutlined } from '@ant-design/icons';
import './push-notification-template.css';
const PushNotificationTemplate = ({ notification: notificationData }: { notification: IWorklenzNotification }) => {
const handleClick = async () => {
if (notificationData.url) {
let url = notificationData.url;
if (notificationData.params && Object.keys(notificationData.params).length) {
const q = toQueryString(notificationData.params);
url += q;
}
if (notificationData.team_id) {
await teamsApiService.setActiveTeam(notificationData.team_id);
}
window.location.href = url;
}
};
return (
<div
onClick={handleClick}
className={`notification-content ${notificationData.url ? 'clickable' : ''}`}
style={{
cursor: notificationData.url ? 'pointer' : 'default',
padding: '8px 0',
borderRadius: '8px'
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '8px',
color: '#262626',
fontSize: '14px',
fontWeight: 500
}}>
{notificationData.team && (
<>
<BankOutlined style={{ marginRight: '8px', color: '#1890ff' }} />
{notificationData.team}
</>
)}
{!notificationData.team && 'Worklenz'}
</div>
<div
style={{
color: '#595959',
fontSize: '13px',
lineHeight: '1.5',
marginTop: '4px'
}}
dangerouslySetInnerHTML={{ __html: notificationData.message }}
/>
</div>
);
};
let notificationQueue: IWorklenzNotification[] = [];
let isProcessing = false;
const processNotificationQueue = () => {
if (isProcessing || notificationQueue.length === 0) return;
isProcessing = true;
const notificationData = notificationQueue.shift();
if (notificationData) {
notification.info({
message: null,
description: <PushNotificationTemplate notification={notificationData} />,
placement: 'topRight',
duration: 5,
style: {
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
padding: '12px 16px',
minWidth: '300px',
maxWidth: '400px'
},
onClose: () => {
isProcessing = false;
processNotificationQueue();
}
});
} else {
isProcessing = false;
}
};
export const showNotification = (notificationData: IWorklenzNotification) => {
notificationQueue.push(notificationData);
processNotificationQueue();
};

View File

@@ -0,0 +1,27 @@
.table-tag:hover {
text-decoration: underline;
cursor: pointer;
}
.custom-row .hover-button {
visibility: hidden;
transition:
visibility 0s,
opacity ease-in-out;
opacity: 0;
}
.custom-row:hover .hover-button {
visibility: visible;
opacity: 1;
}
@media (max-width: 1000px) {
.table-tag {
font-size: 10px;
}
.project-progress {
font-size: 10px;
}
}

View File

@@ -0,0 +1,132 @@
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAuthService } from '@/hooks/useAuth';
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
import { ColumnsType } from 'antd/es/table';
import { ColumnFilterItem } from 'antd/es/table/interface';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { NavigateFunction } from 'react-router-dom';
import Avatars from '../avatars/avatars';
import { ActionButtons } from './project-list-table/project-list-actions/project-list-actions';
import { CategoryCell } from './project-list-table/project-list-category/project-list-category';
import { ProgressListProgress } from './project-list-table/project-list-progress/progress-list-progress';
import { ProjectListUpdatedAt } from './project-list-table/project-list-updated-at/project-list-updated';
import { ProjectNameCell } from './project-list-table/project-name/project-name-cell';
import { useAppSelector } from '@/hooks/useAppSelector';
import { ProjectRateCell } from './project-list-table/project-list-favorite/project-rate-cell';
const createFilters = (items: { id: string; name: string }[]) =>
items.map(item => ({ text: item.name, value: item.id })) as ColumnFilterItem[];
interface ITableColumnsProps {
navigate: NavigateFunction;
filteredInfo: any;
}
const TableColumns = ({
navigate,
filteredInfo,
}: ITableColumnsProps): ColumnsType<IProjectViewModel> => {
const { t } = useTranslation('all-project-list');
const dispatch = useAppDispatch();
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
const { filteredCategories, filteredStatuses } = useAppSelector(
state => state.projectsReducer
);
const columns = useMemo(
() => [
{
title: '',
dataIndex: 'favorite',
key: 'favorite',
render: (text: string, record: IProjectViewModel) => (
<ProjectRateCell key={record.id} t={t} record={record} />
),
},
{
title: t('name'),
dataIndex: 'name',
key: 'name',
sorter: true,
showSorterTooltip: false,
defaultSortOrder: 'ascend',
render: (text: string, record: IProjectViewModel) => (
<ProjectNameCell navigate={navigate} key={record.id} t={t} record={record} />
),
},
{
title: t('client'),
dataIndex: 'client_name',
key: 'client_name',
sorter: true,
showSorterTooltip: false,
},
{
title: t('category'),
dataIndex: 'category',
key: 'category_id',
filters: createFilters(
projectCategories.map(category => ({ id: category.id || '', name: category.name || '' }))
),
filteredValue: filteredInfo.category_id || filteredCategories || [],
filterMultiple: true,
render: (text: string, record: IProjectViewModel) => (
<CategoryCell key={record.id} t={t} record={record} />
),
sorter: true,
},
{
title: t('status'),
dataIndex: 'status',
key: 'status_id',
filters: createFilters(
projectStatuses.map(status => ({ id: status.id || '', name: status.name || '' }))
),
filteredValue: filteredInfo.status_id || [],
filterMultiple: true,
sorter: true,
},
{
title: t('tasksProgress'),
dataIndex: 'tasksProgress',
key: 'tasksProgress',
render: (_: string, record: IProjectViewModel) => <ProgressListProgress record={record} />,
},
{
title: t('updated_at'),
dataIndex: 'updated_at',
key: 'updated_at',
sorter: true,
showSorterTooltip: false,
render: (_: string, record: IProjectViewModel) => <ProjectListUpdatedAt record={record} />,
},
{
title: t('members'),
dataIndex: 'names',
key: 'members',
render: (members: InlineMember[]) => <Avatars members={members} />,
},
{
title: '',
key: 'button',
dataIndex: '',
render: (record: IProjectViewModel) => (
<ActionButtons
t={t}
record={record}
dispatch={dispatch}
isOwnerOrAdmin={isOwnerOrAdmin}
/>
),
},
],
[t, projectCategories, projectStatuses, filteredInfo, filteredCategories, filteredStatuses]
);
return columns as ColumnsType<IProjectViewModel>;
};
export default TableColumns;

View File

@@ -0,0 +1,93 @@
import { useGetProjectsQuery } from '@/api/projects/projects.v1.api.service';
import { AppDispatch } from '@/app/store';
import { fetchProjectData, setProjectId, toggleProjectDrawer } from '@/features/project/project-drawer.slice';
import {
toggleArchiveProjectForAll,
toggleArchiveProject,
} from '@/features/projects/projectsSlice';
import { useAppSelector } from '@/hooks/useAppSelector';
import useIsProjectManager from '@/hooks/useIsProjectManager';
import { useAuthService } from '@/hooks/useAuth';
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import logger from '@/utils/errorLogger';
import { SettingOutlined, InboxOutlined } from '@ant-design/icons';
import { Tooltip, Button, Popconfirm, Space } from 'antd';
import { evt_projects_archive, evt_projects_archive_all, evt_projects_settings_click } from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
interface ActionButtonsProps {
t: (key: string) => string;
record: IProjectViewModel;
dispatch: AppDispatch;
isOwnerOrAdmin: boolean;
}
export const ActionButtons: React.FC<ActionButtonsProps> = ({
t,
record,
dispatch,
isOwnerOrAdmin,
}) => {
// Add permission hooks
const isProjectManager = useIsProjectManager();
const isEditable = isOwnerOrAdmin;
const { trackMixpanelEvent } = useMixpanelTracking();
const { requestParams } = useAppSelector(state => state.projectsReducer);
const { refetch: refetchProjects } = useGetProjectsQuery(requestParams);
const handleSettingsClick = () => {
if (record.id) {
trackMixpanelEvent(evt_projects_settings_click);
dispatch(setProjectId(record.id));
dispatch(fetchProjectData(record.id));
dispatch(toggleProjectDrawer());
}
};
const handleArchiveClick = async () => {
if (!record.id) return;
try {
if (isOwnerOrAdmin) {
trackMixpanelEvent(evt_projects_archive_all);
await dispatch(toggleArchiveProjectForAll(record.id));
} else {
trackMixpanelEvent(evt_projects_archive);
await dispatch(toggleArchiveProject(record.id));
}
refetchProjects();
} catch (error) {
logger.error('Failed to archive project:', error);
}
};
return (
<Space onClick={e => e.stopPropagation()}>
<Tooltip title={t('setting')}>
<Button
className="action-button"
size="small"
onClick={handleSettingsClick}
icon={<SettingOutlined />}
/>
</Tooltip>
<Tooltip title={isEditable ? (record.archived ? t('unarchive') : t('archive')) : t('noPermission')}>
<Popconfirm
title={record.archived ? t('unarchive') : t('archive')}
description={record.archived ? t('unarchiveConfirm') : t('archiveConfirm')}
onConfirm={handleArchiveClick}
okText={t('yes')}
cancelText={t('no')}
disabled={!isEditable}
>
<Button
className="action-button"
size="small"
icon={<InboxOutlined />}
disabled={!isEditable}
/>
</Popconfirm>
</Tooltip>
</Space>
);
};

View File

@@ -0,0 +1,41 @@
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { Tooltip, Tag } from 'antd';
import { TFunction } from 'i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setFilteredCategories, setRequestParams } from '@/features/projects/projectsSlice';
import '../../TableColumns.css';
import { useAppSelector } from '@/hooks/useAppSelector';
export const CategoryCell: React.FC<{
record: IProjectViewModel;
t: TFunction;
}> = ({ record, t }) => {
if (!record.category_name) return '-';
const { requestParams } = useAppSelector(
state => state.projectsReducer
);
const dispatch = useAppDispatch();
const newParams: Partial<typeof requestParams> = {};
const filterByCategory = (categoryId: string | undefined) => {
if (!categoryId) return;
newParams.categories = categoryId;
dispatch(setFilteredCategories([categoryId]));
dispatch(setRequestParams(newParams));
};
return (
<Tooltip title={`${t('clickToFilter')} "${record.category_name}"`}>
<Tag
color={record.category_color}
className="rounded-full table-tag"
onClick={e => {
e.stopPropagation();
filterByCategory(record.category_id);
}}
>
{record.category_name}
</Tag>
</Tooltip>
);
};

View File

@@ -0,0 +1,59 @@
import { useEffect, useState } from 'react';
import {
useGetProjectsQuery,
useToggleFavoriteProjectMutation,
} from '@/api/projects/projects.v1.api.service';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { colors } from '@/styles/colors';
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { StarFilled } from '@ant-design/icons';
import { Button, ConfigProvider, Tooltip } from 'antd';
import { TFunction } from 'i18next';
import { useCallback, useMemo } from 'react';
export const ProjectRateCell: React.FC<{
record: IProjectViewModel;
t: TFunction;
}> = ({ record, t }) => {
const dispatch = useAppDispatch();
const [toggleFavoriteProject] = useToggleFavoriteProjectMutation();
const { requestParams } = useAppSelector(state => state.projectsReducer);
const { refetch: refetchProjects } = useGetProjectsQuery(requestParams);
const [isFavorite, setIsFavorite] = useState(record.favorite);
const handleFavorite = useCallback(async () => {
if (record.id) {
setIsFavorite(prev => !prev);
await toggleFavoriteProject(record.id);
// refetchProjects();
}
}, [dispatch, record.id]);
const checkIconColor = useMemo(
() => (isFavorite ? colors.yellow : colors.lightGray),
[isFavorite]
);
useEffect(() => {
setIsFavorite(record.favorite);}, [record.favorite]);
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, fontSize: '20px' }} />}
onClick={(e) => {
e.stopPropagation();
handleFavorite();
}}
/>
</Tooltip>
</ConfigProvider>
);
};

View File

@@ -0,0 +1,11 @@
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { getTaskProgressTitle } from '@/utils/project-list-utils';
import { Tooltip, Progress } from 'antd';
export const ProgressListProgress: React.FC<{ record: IProjectViewModel }> = ({ record }) => {
return (
<Tooltip title={getTaskProgressTitle(record)}>
<Progress percent={record.progress} className="project-progress" />
</Tooltip>
);
};

View File

@@ -0,0 +1,12 @@
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
import { Tooltip } from 'antd';
export const ProjectListUpdatedAt: React.FC<{ record: IProjectViewModel }> = ({ record }) => {
return (
<Tooltip title={record.updated_at ? formatDateTimeWithLocale(record.updated_at) : ''}>
{record.updated_at ? calculateTimeDifference(record.updated_at) : ''}
</Tooltip>
);
};

View File

@@ -0,0 +1,69 @@
import {
useGetProjectsQuery,
useToggleFavoriteProjectMutation,
} from '@/api/projects/projects.v1.api.service';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { formatDateRange } from '@/utils/project-list-utils';
import { CalendarOutlined } from '@ant-design/icons';
import { Badge, Tooltip } from 'antd';
import { TFunction } from 'i18next';
import { NavigateFunction } from 'react-router-dom';
export const ProjectNameCell: React.FC<{
record: IProjectViewModel;
t: TFunction;
navigate: NavigateFunction;
}> = ({ record, t, navigate }) => {
const dispatch = useAppDispatch();
const [toggleFavoriteProject] = useToggleFavoriteProjectMutation();
const { requestParams } = useAppSelector(state => state.projectsReducer);
const { refetch: refetchProjects } = useGetProjectsQuery(requestParams);
const selectProject = (record: IProjectViewModel) => {
if (!record.id) return;
let viewTab = 'tasks-list';
switch (record.team_member_default_view) {
case 'TASK_LIST':
viewTab = 'tasks-list';
break;
case 'BOARD':
viewTab = 'board';
break;
default:
viewTab = 'tasks-list';
}
const searchParams = new URLSearchParams({
tab: viewTab,
pinned_tab: viewTab,
});
navigate({
pathname: `/worklenz/projects/${record.id}`,
search: searchParams.toString(),
});
};
return (
<div className="flex items-center">
<Badge color="geekblue" className="mr-2" />
<span className="cursor-pointer">
<span onClick={() => selectProject(record)}>{record.name}</span>
{(record.start_date || record.end_date) && (
<Tooltip
title={formatDateRange({
startDate: record.start_date || null,
endDate: record.end_date || null,
})}
overlayStyle={{ width: '200px' }}
>
<CalendarOutlined className="ml-2" />
</Tooltip>
)}
</span>
</div>
);
};

View File

@@ -0,0 +1,32 @@
import { SettingOutlined } from '@ant-design/icons';
import Tooltip from 'antd/es/tooltip';
import Button from 'antd/es/button';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleDrawer } from '../../../features/projects/status/StatusSlice';
import { colors } from '@/styles/colors';
import { useTranslation } from 'react-i18next';
const CreateStatusButton = () => {
const { t } = useTranslation('task-list-filters');
const dispatch = useAppDispatch();
return (
<Tooltip title={t('createStatusButtonTooltip')}>
<Button
className="borderless-icon-btn"
style={{ backgroundColor: colors.transparent, boxShadow: 'none' }}
onClick={() => dispatch(toggleDrawer())}
icon={
<SettingOutlined
style={{
color: colors.skyBlue,
}}
/>
}
/>
</Tooltip>
);
};
export default CreateStatusButton;

View File

@@ -0,0 +1,36 @@
.custom-input-status {
padding: 4px 11px;
border: 1px solid #d9d9d9;
border-radius: 4px;
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background-color: #fff;
cursor: pointer;
transition: border-color 0.3s;
}
.custom-input-status.dark-mode {
background-color: #141414;
border: 1px solid #424242;
}
.custom-input-status:hover,
.custom-input-status:focus-within {
border: 1px solid #40a9ff;
}
.status-drawer-dropdown .ant-dropdown-menu {
padding: 0 !important;
margin-top: 8px !important;
overflow: hidden;
}
.status-drawer-dropdown .ant-dropdown-menu-item {
padding: 0 !important;
}
.status-drawer-dropdown .ant-card-body {
padding: 0 !important;
}

View File

@@ -0,0 +1,110 @@
import React, { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Flex from 'antd/es/flex';
import Badge from 'antd/es/badge';
import Drawer from 'antd/es/drawer';
import Form from 'antd/es/form';
import Input from 'antd/es/input';
import Select from 'antd/es/select';
import Button from 'antd/es/button/button';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleDrawer } from '@/features/projects/status/StatusSlice';
import './create-status-drawer.css';
import { createStatus, fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import { ITaskStatusCategory } from '@/types/status.types';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import useTabSearchParam from '@/hooks/useTabSearchParam';
import { evt_project_board_create_status } from '@/shared/worklenz-analytics-events';
import { fetchTaskGroups } from '@/features/tasks/tasks.slice';
import { fetchBoardTaskGroups } from '@/features/board/board-slice';
const StatusDrawer: React.FC = () => {
const dispatch = useAppDispatch();
const { trackMixpanelEvent } = useMixpanelTracking();
const { projectView } = useTabSearchParam();
const [form] = Form.useForm();
const { t } = useTranslation('task-list-filters');
const isCreateStatusDrawerOpen = useAppSelector(
state => state.statusReducer.isCreateStatusDrawerOpen
);
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
const { projectId } = useAppSelector(state => state.projectReducer);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const refreshTasks = useCallback(() => {
if (!projectId) return;
const fetchAction = projectView === 'list' ? fetchTaskGroups : fetchBoardTaskGroups;
dispatch(fetchAction(projectId));
}, [projectId, projectView, dispatch]);
const handleFormSubmit = async (values: { name: string; category: string }) => {
if (!projectId) return;
const body = {
name: values.name,
category_id: values.category,
project_id: projectId,
};
const res = await dispatch(createStatus({ body, currentProjectId: projectId })).unwrap();
if (res.done) {
trackMixpanelEvent(evt_project_board_create_status);
form.resetFields();
dispatch(toggleDrawer());
refreshTasks();
dispatch(fetchStatusesCategories());
}
};
const selectOptions = statusCategories.map((category: ITaskStatusCategory) => ({
value: category.id,
label: (
<Flex gap={4}>
<Badge color={category.color_code} /> {category.name}
</Flex>
),
}));
const handleDrawerOpenChange = () => {
if (statusCategories.length === 0) {
dispatch(fetchStatusesCategories());
}
};
return (
<Drawer
title={t('createStatus')}
onClose={() => dispatch(toggleDrawer())}
open={isCreateStatusDrawerOpen}
afterOpenChange={handleDrawerOpenChange}
>
<Form layout="vertical" onFinish={handleFormSubmit} form={form}>
<Form.Item
name="name"
label={t('name')}
rules={[{ required: true, message: t('pleaseEnterAName') }]}
>
<Input type="text" placeholder={t('name')} />
</Form.Item>
<Form.Item
name="category"
label={t('category')}
rules={[{ required: true, message: t('pleaseSelectACategory') }]}
>
<Select options={selectOptions} placeholder={t('selectCategory')} />
</Form.Item>
<Form.Item>
<Button htmlType="submit" type="primary" style={{ width: '100%' }}>
{t('create')}
</Button>
</Form.Item>
</Form>
</Drawer>
);
};
export default StatusDrawer;

View File

@@ -0,0 +1,146 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import Form from 'antd/es/form';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { fetchStatuses, fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import useTabSearchParam from '@/hooks/useTabSearchParam';
import { fetchTaskGroups } from '@/features/tasks/tasks.slice';
import { fetchBoardTaskGroups } from '@/features/board/board-slice';
import { deleteStatusToggleDrawer } from '@/features/projects/status/DeleteStatusSlice';
import { Drawer, Alert, Card, Select, Button, Typography, Badge } from 'antd';
import { DownOutlined } from '@ant-design/icons';
import { useSelector } from 'react-redux';
import {
deleteSection,
IGroupBy,
} from '@features/board/board-slice';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
import logger from '@/utils/errorLogger';
const { Title, Text } = Typography;
const { Option } = Select;
const DeleteStatusDrawer: React.FC = () => {
const [currentStatus, setCurrentStatus] = useState<string>('');
const [deletingStatus, setDeletingStatus] = useState(false);
const dispatch = useAppDispatch();
const { trackMixpanelEvent } = useMixpanelTracking();
const { projectView } = useTabSearchParam();
const [form] = Form.useForm();
const { t } = useTranslation('task-list-filters');
const { editableSectionId, groupBy } = useAppSelector(state => state.boardReducer);
const isDelteStatusDrawerOpen = useAppSelector(
state => state.deleteStatusReducer.isDeleteStatusDrawerOpen
);
const { isDeleteStatusDrawerOpen, status: selectedForDelete } = useAppSelector(
(state) => state.deleteStatusReducer
);
const { status, statusCategories } = useAppSelector(state => state.taskStatusReducer);
const { projectId } = useAppSelector(state => state.projectReducer);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const refreshTasks = useCallback(() => {
if (!projectId) return;
const fetchAction = projectView === 'list' ? fetchTaskGroups : fetchBoardTaskGroups;
dispatch(fetchAction(projectId));
}, [projectId, projectView, dispatch]);
const handleDrawerOpenChange = () => {
if (status.length === 0) {
dispatch(fetchStatusesCategories());
}
};
const setReplacingStatus = (value: string) => {
setCurrentStatus(value);
};
const moveAndDelete = async () => {
const groupId = selectedForDelete?.id;
if (!projectId || !currentStatus || !groupId) return;
setDeletingStatus(true);
try {
if (groupBy === IGroupBy.STATUS) {
const replacingStatusId = currentStatus;
if (!replacingStatusId) return;
const res = await statusApiService.deleteStatus(groupId, projectId, replacingStatusId);
if (res.done) {
dispatch(deleteSection({ sectionId: groupId }));
dispatch(deleteStatusToggleDrawer());
dispatch(fetchStatuses(projectId));
refreshTasks();
dispatch(fetchStatusesCategories());
} else{
console.error('Error deleting status', res);
}
} 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);
}finally {
setDeletingStatus(false);
}
};
useEffect(() => {
setCurrentStatus(status[0]?.id || '');
}, [isDelteStatusDrawerOpen]);
return (
<Drawer
title="You are deleting a status"
onClose={() => dispatch(deleteStatusToggleDrawer())}
open={isDelteStatusDrawerOpen}
afterOpenChange={handleDrawerOpenChange}
>
<Alert type="warning" message={selectedForDelete?.message.replace("$","")} />
<Card className="text-center" style={{ marginTop: 16 }}>
<Title level={5}>{selectedForDelete?.name}</Title>
<Title level={4} style={{ margin: '16px 0' }}>
<DownOutlined />
</Title>
<Select
value={currentStatus}
onChange={setReplacingStatus}
style={{ width: '100%' }}
optionLabelProp='name'
options={status.map((item) => ({
key: item.id,
value: item.id,
name: item.name,
label: (
<Badge
color={item.color_code}
text={item?.name || null}
style={{
opacity: item.id === selectedForDelete?.id ? 0.5 : undefined
}}
/>
),
disabled: item.id === selectedForDelete?.id
}))}
/>
<Button
type="primary"
block
loading={deletingStatus}
disabled={deletingStatus}
onClick={moveAndDelete}
style={{ marginTop: 16 }}
>
Done
</Button>
</Card>
</Drawer>
);
};
export default DeleteStatusDrawer;

View File

@@ -0,0 +1,86 @@
import { useEffect, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { CaretDownFilled } from '@ant-design/icons';
import { ConfigProvider, Flex, Dropdown, Button } from 'antd/es';
import { useSearchParams } from 'react-router-dom';
import ConfigPhaseButton from '@features/projects/singleProject/phase/ConfigPhaseButton';
import { useAppSelector } from '@/hooks/useAppSelector';
import CreateStatusButton from '@/components/project-task-filters/create-status-button/create-status-button';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { IGroupBy, setCurrentGroup, setGroup } from '@features/tasks/tasks.slice';
import { setBoardGroupBy, setCurrentBoardGroup } from '@/features/board/board-slice';
import { useAuthService } from '@/hooks/useAuth';
import useIsProjectManager from '@/hooks/useIsProjectManager';
const GroupByFilterDropdown = () => {
const { t } = useTranslation('task-list-filters');
const dispatch = useAppDispatch();
const [searchParams] = useSearchParams();
const isProjectManager = useIsProjectManager();
const { groupBy } = useAppSelector(state => state.taskReducer);
const { groupBy: boardGroupBy } = useAppSelector(state => state.boardReducer);
const { project } = useAppSelector(state => state.projectReducer);
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
const tab = searchParams.get('tab');
const projectView = tab === 'tasks-list' ? 'list' : 'kanban';
const currentGroup = projectView === 'list' ? groupBy : boardGroupBy;
const items = useMemo(() => {
const baseItems = [
{ key: IGroupBy.STATUS, label: t('statusText') },
{ key: IGroupBy.PRIORITY, label: t('priorityText') },
{ key: IGroupBy.PHASE, label: project?.phase_label || t('phaseText') },
];
// if (projectView === 'kanban') {
// return [...baseItems, { key: IGroupBy.MEMBERS, label: t('memberText') }];
// }
return baseItems;
}, [t, project?.phase_label, projectView]);
const handleGroupChange = (key: string) => {
const group = key as IGroupBy;
if (projectView === 'list') {
setCurrentGroup(group);
dispatch(setGroup(group));
} else {
setCurrentBoardGroup(group);
dispatch(setBoardGroupBy(group));
}
};
const selectedLabel = items.find(item => item.key === currentGroup)?.label;
return (
<Flex align="center" gap={4} style={{ marginInlineStart: 12 }}>
{t('groupByText')}:
<Dropdown
trigger={['click']}
menu={{
items,
onClick: (info) => handleGroupChange(info.key),
selectedKeys: [currentGroup],
}}
>
<Button>
{selectedLabel} <CaretDownFilled />
</Button>
</Dropdown>
{(currentGroup === IGroupBy.STATUS || currentGroup === IGroupBy.PHASE) && (isOwnerOrAdmin || isProjectManager) && (
<ConfigProvider wave={{ disabled: true }}>
{currentGroup === IGroupBy.PHASE && <ConfigPhaseButton />}
{currentGroup === IGroupBy.STATUS && <CreateStatusButton />}
</ConfigProvider>
)}
</Flex>
);
};
export default GroupByFilterDropdown;

View File

@@ -0,0 +1,166 @@
import { CaretDownFilled } from '@ant-design/icons';
import Badge from 'antd/es/badge';
import Button from 'antd/es/button';
import Card from 'antd/es/card';
import Checkbox from 'antd/es/checkbox';
import Dropdown from 'antd/es/dropdown';
import Empty from 'antd/es/empty';
import Flex from 'antd/es/flex';
import Input, { InputRef } from 'antd/es/input';
import List from 'antd/es/list';
import Space from 'antd/es/space';
import { useSearchParams } from 'react-router-dom';
import { useMemo, useRef, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { colors } from '@/styles/colors';
import { useTranslation } from 'react-i18next';
import { fetchLabelsByProject, fetchTaskGroups, setLabels } from '@/features/tasks/tasks.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setBoardLabels } from '@/features/board/board-slice';
import { fetchBoardTaskGroups } from '@/features/board/board-slice';
const LabelsFilterDropdown = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation('task-list-filters');
const [searchParams] = useSearchParams();
const labelInputRef = useRef<InputRef>(null);
const [searchQuery, setSearchQuery] = useState<string>('');
const { labels, loadingLabels } = useAppSelector(state => state.taskReducer);
const { labels: boardLabels, loadingLabels: boardLoadingLabels } = useAppSelector(
state => state.boardReducer
);
const { projectId } = useAppSelector(state => state.projectReducer);
const tab = searchParams.get('tab');
const projectView = tab === 'tasks-list' ? 'list' : 'kanban';
const filteredLabelData = useMemo(() => {
if (projectView === 'list') {
return labels.filter(label => label.name?.toLowerCase().includes(searchQuery.toLowerCase()));
} else {
return boardLabels.filter(label =>
label.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
}
}, [labels, boardLabels, searchQuery, projectView]);
const labelsCount = useMemo(() => {
if (projectView === 'list') {
return labels.filter(label => label.selected).length;
} else {
return boardLabels.filter(label => label.selected).length;
}
}, [labels, boardLabels, projectView]);
const themeMode = useAppSelector(state => state.themeReducer.mode);
// handle selected filters count
const handleLabelSelect = (checked: boolean, labelId: string) => {
if (projectView === 'list') {
dispatch(
setLabels(
labels.map(label => (label.id === labelId ? { ...label, selected: checked } : label))
)
);
if (projectId) dispatch(fetchTaskGroups(projectId));
} else {
dispatch(
setBoardLabels(
boardLabels.map(label => (label.id === labelId ? { ...label, selected: checked } : label))
)
);
if (projectId) dispatch(fetchBoardTaskGroups(projectId));
}
};
// function to focus labels input
const handleLabelsDropdownOpen = (open: boolean) => {
if (open) {
setTimeout(() => {
labelInputRef.current?.focus();
}, 0);
if (projectView === 'kanban') {
dispatch(setBoardLabels(labels));
}
}
};
// custom dropdown content
const labelsDropdownContent = (
<Card className="custom-card" styles={{ body: { padding: 8, width: 260 } }}>
<Flex vertical gap={8}>
<Input
ref={labelInputRef}
value={searchQuery}
onChange={e => setSearchQuery(e.currentTarget.value)}
placeholder={t('searchInputPlaceholder')}
/>
<List style={{ padding: 0, maxHeight: 250, overflow: 'auto' }}>
{filteredLabelData.length ? (
filteredLabelData.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}
checked={label.selected}
onChange={e => handleLabelSelect(e.target.checked, label.id || '')}
>
<Flex gap={8}>
<Badge color={label.color_code} />
{label.name}
</Flex>
</Checkbox>
</List.Item>
))
) : (
<Empty />
)}
</List>
</Flex>
</Card>
);
return (
<Dropdown
overlayClassName="custom-dropdown"
trigger={['click']}
dropdownRender={() => labelsDropdownContent}
onOpenChange={handleLabelsDropdownOpen}
>
<Button
icon={<CaretDownFilled />}
iconPosition="end"
loading={loadingLabels}
style={{
backgroundColor:
labelsCount > 0
? themeMode === 'dark'
? '#003a5c'
: colors.paleBlue
: colors.transparent,
color: labelsCount > 0 ? (themeMode === 'dark' ? 'white' : colors.darkGray) : 'inherit',
}}
>
<Space>
{t('labelsText')}
{labelsCount > 0 && <Badge size="small" count={labelsCount} color={colors.skyBlue} />}
</Space>
</Button>
</Dropdown>
);
};
export default LabelsFilterDropdown;

View File

@@ -0,0 +1,179 @@
import { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { CaretDownFilled } from '@ant-design/icons';
import {
Badge,
Button,
Card,
Checkbox,
Dropdown,
Empty,
Flex,
Input,
List,
Space,
Typography
} from 'antd';
import type { InputRef } from 'antd';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { colors } from '@/styles/colors';
import SingleAvatar from '@components/common/single-avatar/single-avatar';
import { fetchTaskGroups, setMembers } from '@/features/tasks/tasks.slice';
import { fetchBoardTaskGroups, setBoardMembers } from '@/features/board/board-slice';
import useTabSearchParam from '@/hooks/useTabSearchParam';
interface Member {
id: string;
name?: string;
email?: string;
avatar_url?: string;
selected: boolean;
}
const MembersFilterDropdown = () => {
const membersInputRef = useRef<InputRef>(null);
const dispatch = useAppDispatch();
const { projectView } = useTabSearchParam();
const [searchQuery, setSearchQuery] = useState('');
const { t } = useTranslation('task-list-filters');
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { taskAssignees } = useAppSelector(state => state.taskReducer);
const { taskAssignees: boardTaskAssignees } = useAppSelector(state => state.boardReducer);
const { projectId } = useAppSelector(state => state.projectReducer);
useEffect(() => {
if (projectId) {
// Reset task assignees selections
const resetTaskMembers = taskAssignees.map(member => ({
...member,
selected: false
}));
dispatch(setMembers(resetTaskMembers));
// Reset board assignees selections
const resetBoardMembers = boardTaskAssignees.map(member => ({
...member,
selected: false
}));
dispatch(setBoardMembers(resetBoardMembers));
}
}, [projectId, dispatch]);
const selectedCount = useMemo(() => {
return projectView === 'list' ? taskAssignees.filter(member => member.selected).length : boardTaskAssignees.filter(member => member.selected).length;
}, [taskAssignees, boardTaskAssignees, projectView]);
const filteredMembersData = useMemo(() => {
const members = projectView === 'list' ? taskAssignees : boardTaskAssignees;
return members.filter(member =>
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [taskAssignees, boardTaskAssignees, searchQuery, projectView]);
const handleSelectedFiltersCount = useCallback(async (memberId: string | undefined, checked: boolean) => {
if (!memberId || !projectId) return;
if (!memberId || !projectId) return;
const updateMembers = async (members: Member[], setAction: any, fetchAction: any) => {
const updatedMembers = members.map(member =>
member.id === memberId ? { ...member, selected: checked } : member
);
await dispatch(setAction(updatedMembers));
dispatch(fetchAction(projectId));
};
if (projectView === 'list') {
await updateMembers(taskAssignees as Member[], setMembers, fetchTaskGroups);
} else {
await updateMembers(boardTaskAssignees as Member[], setBoardMembers, fetchBoardTaskGroups);
}
}, [projectId, projectView, taskAssignees, boardTaskAssignees, dispatch]);
const renderMemberItem = (member: Member) => (
<List.Item
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
key={member.id}
style={{ display: 'flex', gap: 8, padding: '4px 8px', border: 'none' }}
>
<Checkbox
id={member.id}
checked={member.selected}
onChange={e => handleSelectedFiltersCount(member.id, e.target.checked)}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<SingleAvatar
avatarUrl={member.avatar_url}
name={member.name}
email={member.email}
/>
<Flex vertical>
{member.name}
<Typography.Text style={{ fontSize: 12, color: colors.lightGray }}>
{member.email}
</Typography.Text>
</Flex>
</div>
</Checkbox>
</List.Item>
);
const membersDropdownContent = (
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
<Flex vertical gap={8}>
<Input
ref={membersInputRef}
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder={t('searchInputPlaceholder')}
/>
<List style={{ padding: 0, maxHeight: 250, overflow: 'auto' }}>
{filteredMembersData.length ?
filteredMembersData.map((member, index) => renderMemberItem(member as Member)) :
<Empty />
}
</List>
</Flex>
</Card>
);
const handleMembersDropdownOpen = useCallback((open: boolean) => {
if (open) {
setTimeout(() => membersInputRef.current?.focus(), 0);
if (taskAssignees.length) {
dispatch(setBoardMembers(taskAssignees));
}
}
}, [dispatch, taskAssignees]);
const buttonStyle = {
backgroundColor: selectedCount > 0
? themeMode === 'dark' ? '#003a5c' : colors.paleBlue
: colors.transparent,
color: selectedCount > 0 ? (themeMode === 'dark' ? 'white' : colors.darkGray) : 'inherit',
};
return (
<Dropdown
overlayClassName="custom-dropdown"
trigger={['click']}
dropdownRender={() => membersDropdownContent}
onOpenChange={handleMembersDropdownOpen}
>
<Button
icon={<CaretDownFilled />}
iconPosition="end"
style={buttonStyle}
>
<Space>
{t('membersText')}
{selectedCount > 0 && <Badge size="small" count={selectedCount} color={colors.skyBlue} />}
</Space>
</Button>
</Dropdown>
);
};
export default MembersFilterDropdown;

View File

@@ -0,0 +1,133 @@
import { CaretDownFilled } from '@ant-design/icons';
import { useMemo, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge, Button, Card, Checkbox, Dropdown, List, Space } from 'antd';
import { useAppSelector } from '@/hooks/useAppSelector';
import { colors } from '@/styles/colors';
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
import { setPriorities } from '@/features/tasks/tasks.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import useTabSearchParam from '@/hooks/useTabSearchParam';
import { fetchBoardTaskGroups, setBoardPriorities } from '@/features/board/board-slice';
import { fetchTaskGroups as fetchTaskGroupsList } from '@/features/tasks/tasks.slice';
interface PriorityFilterDropdownProps {
priorities: ITaskPriority[];
}
const PriorityFilterDropdown = ({ priorities }: PriorityFilterDropdownProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation('task-list-filters');
const { priorities: selectedPriorities, loadingGroups } = useAppSelector(
state => state.taskReducer
);
const { priorities: boardSelectedPriorities, loadingGroups: boardLoadingGroups } = useAppSelector(
state => state.boardReducer
);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { projectId } = useAppSelector(state => state.projectReducer);
const { projectView } = useTabSearchParam();
const selectedCount = projectView === 'list'
? selectedPriorities.length
: boardSelectedPriorities.length;
const buttonStyle = {
backgroundColor: selectedCount > 0
? themeMode === 'dark' ? '#003a5c' : colors.paleBlue
: colors.transparent,
color: selectedCount > 0 ? (themeMode === 'dark' ? 'white' : colors.darkGray) : 'inherit',
};
useEffect(() => {
if (projectId) {
if (projectView === 'list' && !loadingGroups) {
dispatch(fetchTaskGroupsList(projectId));
} else if (projectView === 'kanban' && !boardLoadingGroups) {
dispatch(fetchBoardTaskGroups(projectId));
}
}
}, [dispatch, projectId, selectedPriorities, boardSelectedPriorities, projectView]);
const handleSelectedPriority = useCallback((priorityId: string) => {
if (!projectId) return;
const updatePriorities = (currentPriorities: string[], setAction: any, fetchAction: any) => {
const newPriorities = currentPriorities.includes(priorityId)
? currentPriorities.filter(id => id !== priorityId)
: [...currentPriorities, priorityId];
dispatch(setAction(newPriorities));
dispatch(fetchAction(projectId));
};
if (projectView === 'list') {
updatePriorities(selectedPriorities, setPriorities, fetchTaskGroupsList);
} else {
updatePriorities(boardSelectedPriorities, setBoardPriorities, fetchBoardTaskGroups);
}
}, [dispatch, projectId, projectView, selectedPriorities, boardSelectedPriorities]);
const priorityDropdownContent = useMemo(() => (
<Card className="custom-card" style={{ width: 120 }} styles={{ body: { padding: 0 } }}>
<List style={{ padding: 0, maxHeight: 250, overflow: 'auto' }}>
{priorities?.map(priority => (
<List.Item
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
key={priority.id}
onClick={() => handleSelectedPriority(priority.id)}
style={{
display: 'flex',
gap: 8,
padding: '4px 8px',
border: 'none',
cursor: 'pointer',
}}
>
<Space>
<Checkbox
id={priority.id}
checked={
projectView === 'list'
? selectedPriorities.includes(priority.id)
: boardSelectedPriorities.includes(priority.id)
}
onChange={() => handleSelectedPriority(priority.id)}
/>
<Badge color={priority.color_code} />
{priority.name}
</Space>
</List.Item>
))}
</List>
</Card>
), [priorities, selectedPriorities, boardSelectedPriorities, themeMode, handleSelectedPriority]);
return (
<Dropdown
overlayClassName="custom-dropdown"
trigger={['click']}
dropdownRender={() => priorityDropdownContent}
>
<Button
icon={<CaretDownFilled />}
iconPosition="end"
style={buttonStyle}
>
<Space>
{t('priorityText')}
{selectedCount > 0 && (
<Badge size="small" count={selectedCount} color={colors.skyBlue} />
)}
</Space>
</Button>
</Dropdown>
);
};
export default PriorityFilterDropdown;

View File

@@ -0,0 +1,106 @@
import React, { useCallback, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDispatch } from 'react-redux';
import debounce from 'lodash/debounce';
import { useSearchParams } from 'react-router-dom';
import { InputRef } from 'antd/es/input';
import Button from 'antd/es/button';
import Card from 'antd/es/card';
import Flex from 'antd/es/flex';
import Input from 'antd/es/input';
import Space from 'antd/es/space';
import Dropdown from 'antd/es/dropdown';
import { setSearch } from '@/features/tasks/tasks.slice';
import { SearchOutlined } from '@ant-design/icons';
import { setBoardSearch } from '@/features/board/board-slice';
const SearchDropdown = () => {
const { t } = useTranslation('task-list-filters');
const dispatch = useDispatch();
const [searchValue, setSearchValue] = useState('');
const [isOpen, setIsOpen] = useState(false);
const searchInputRef = useRef<InputRef>(null);
const [searchParams] = useSearchParams();
const tab = searchParams.get('tab');
const projectView = tab === 'tasks-list' ? 'list' : 'kanban';
// Debounced search dispatch
const debouncedSearch = useCallback(
debounce((value: string) => {
if (projectView === 'list') {
dispatch(setSearch(value));
} else {
dispatch(setBoardSearch(value));
}
}, 300),
[dispatch, projectView]
);
const handleSearch = useCallback(() => {
debouncedSearch(searchValue);
}, [searchValue, debouncedSearch]);
const handleReset = useCallback(() => {
setSearchValue('');
if (projectView === 'list') {
dispatch(setSearch(''));
} else {
dispatch(setBoardSearch(''));
}
}, [dispatch, projectView]);
const handleSearchChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(e.target.value);
}, []);
// Memoized dropdown content
const searchDropdownContent = useMemo(
() => (
<Card className="custom-card" styles={{ body: { padding: 8, width: 360 } }}>
<Flex vertical gap={8}>
<Input
ref={searchInputRef}
value={searchValue}
onChange={handleSearchChange}
placeholder={t('searchInputPlaceholder')}
/>
<Space>
<Button type="primary" onClick={handleSearch}>
{t('searchButton')}
</Button>
<Button onClick={handleReset}>{t('resetButton')}</Button>
</Space>
</Flex>
</Card>
),
[searchValue, handleSearch, handleReset, handleSearchChange, t]
);
const handleSearchDropdownOpen = useCallback((open: boolean) => {
setIsOpen(open);
if (open) {
setTimeout(() => {
searchInputRef.current?.focus();
}, 0);
}
}, []);
return (
<Dropdown
open={isOpen}
overlayClassName="custom-dropdown"
trigger={['click']}
dropdownRender={() => searchDropdownContent}
onOpenChange={handleSearchDropdownOpen}
>
<Button icon={<SearchOutlined />} />
</Dropdown>
);
};
export default React.memo(SearchDropdown);

View File

@@ -0,0 +1,81 @@
import { MoreOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import Button from 'antd/es/button';
import Checkbox from 'antd/es/checkbox';
import Dropdown from 'antd/es/dropdown';
import Space from 'antd/es/space';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
updateColumnVisibility,
updateCustomColumnPinned,
} from '@/features/tasks/tasks.slice';
import { ITaskListColumn } from '@/types/tasks/taskList.types';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
const ShowFieldsFilterDropdown = () => {
const { socket, connected } = useSocket();
// localization
const { t } = useTranslation('task-list-filters');
const dispatch = useAppDispatch();
const columnList = useAppSelector(state => state.taskReducer.columns);
const { projectId, project } = useAppSelector(state => state.projectReducer);
const visibilityChangableColumnList = columnList.filter(
column => column.key !== 'selector' && column.key !== 'TASK' && column.key !== 'customColumn'
);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const handleColumnVisibilityChange = async (col: ITaskListColumn) => {
if (!projectId) return;
const column = { ...col, is_visible: !col.pinned, pinned: !col.pinned };
if (col.custom_column) {
socket?.emit(SocketEvents.CUSTOM_COLUMN_PINNED_CHANGE.toString(), {
column_id: col.id,
project_id: projectId,
is_visible: !col.pinned,
});
socket?.once(SocketEvents.CUSTOM_COLUMN_PINNED_CHANGE.toString(), (data: any) => {
if (col.id) {
dispatch(updateCustomColumnPinned({ columnId: col.id, isVisible: !col.pinned }));
}
});
} else {
await dispatch(updateColumnVisibility({ projectId, item: column }));
}
};
const menuItems = visibilityChangableColumnList.map(col => ({
key: col.key,
label: (
<Space>
<Checkbox checked={col.pinned} onChange={e => handleColumnVisibilityChange(col)}>
{col.key === 'PHASE' ? project?.phase_label : ''}
{col.key !== 'PHASE' &&
(col.custom_column
? col.name
: t(`${col.key?.replace('_', '').toLowerCase() + 'Text'}`))}
</Checkbox>
</Space>
),
}));
return (
<Dropdown
menu={{
items: menuItems,
style: { maxHeight: '400px', overflowY: 'auto' },
}}
trigger={['click']}
>
<Button icon={<MoreOutlined />}>{t('showFieldsText')}</Button>
</Dropdown>
);
};
export default ShowFieldsFilterDropdown;

View File

@@ -0,0 +1,139 @@
import { CaretDownFilled, SortAscendingOutlined, SortDescendingOutlined } from '@ant-design/icons';
import Badge from 'antd/es/badge';
import Button from 'antd/es/button';
import Card from 'antd/es/card';
import Checkbox from 'antd/es/checkbox';
import Dropdown from 'antd/es/dropdown';
import List from 'antd/es/list';
import Space from 'antd/es/space';
import { colors } from '@/styles/colors';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
import { ITaskListSortableColumn } from '@/types/tasks/taskListFilters.types';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setFields } from '@/features/tasks/tasks.slice';
enum SORT_ORDER {
ASCEND = 'ascend',
DESCEND = 'descend',
}
const SortFilterDropdown = () => {
const { t } = useTranslation('task-list-filters');
const dispatch = useAppDispatch();
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { fields } = useAppSelector(state => state.taskReducer);
const handleSelectedFiltersCount = (item: ITaskListSortableColumn) => {
if (!item.key) return;
let newFields = [...fields];
if (newFields.some(field => field.key === item.key)) {
newFields.splice(newFields.indexOf(item), 1);
} else {
newFields.push(item);
}
dispatch(setFields(newFields));
};
const handleSortChange = (key: string) => {
if (!key) return;
let newFields = [...fields];
newFields = newFields.map(item => {
if (item.key === key) {
return {
...item,
sort_order:
item.sort_order === SORT_ORDER.ASCEND ? SORT_ORDER.DESCEND : SORT_ORDER.ASCEND,
};
}
return item;
});
dispatch(setFields(newFields));
};
const sortFieldsList: ITaskListSortableColumn[] = [
{ label: t('taskText'), key: 'name', sort_order: SORT_ORDER.ASCEND },
{ label: t('statusText'), key: 'status', sort_order: SORT_ORDER.ASCEND },
{ label: t('priorityText'), key: 'priority', sort_order: SORT_ORDER.ASCEND },
{ label: t('startDateText'), key: 'start_date', sort_order: SORT_ORDER.ASCEND },
{ label: t('endDateText'), key: 'end_date', sort_order: SORT_ORDER.ASCEND },
{ label: t('completedDateText'), key: 'completed_at', sort_order: SORT_ORDER.ASCEND },
{ label: t('createdDateText'), key: 'created_at', sort_order: SORT_ORDER.ASCEND },
{ label: t('lastUpdatedText'), key: 'updated_at', sort_order: SORT_ORDER.ASCEND },
];
// custom dropdown content
const sortDropdownContent = (
<Card className="custom-card" styles={{ body: { padding: 0 } }}>
<List style={{ padding: 0 }}>
{sortFieldsList.map(sortField => (
<List.Item
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
key={sortField.key}
style={{
display: 'flex',
gap: 8,
padding: '4px 8px',
border: 'none',
}}
>
<Space>
<Checkbox
id={sortField.key}
checked={fields.some(field => field.key === sortField.key)}
onChange={e => handleSelectedFiltersCount(sortField)}
>
{sortField.label}
</Checkbox>
</Space>
<Button
onClick={() => handleSortChange(sortField.key || '')}
icon={
sortField.sort_order === SORT_ORDER.ASCEND ? (
<SortAscendingOutlined />
) : (
<SortDescendingOutlined />
)
}
/>
</List.Item>
))}
</List>
</Card>
);
return (
<Dropdown
overlayClassName="custom-dropdown"
trigger={['click']}
dropdownRender={() => sortDropdownContent}
>
<Button
icon={<CaretDownFilled />}
iconPosition="end"
style={{
backgroundColor:
fields.length > 0
? themeMode === 'dark'
? '#003a5c'
: colors.paleBlue
: colors.transparent,
color: fields.length > 0 ? (themeMode === 'dark' ? 'white' : colors.darkGray) : 'inherit',
}}
>
<Space>
<SortAscendingOutlined />
{t('sortText')}
{fields.length > 0 && <Badge size="small" count={fields.length} color={colors.skyBlue} />}
</Space>
</Button>
</Dropdown>
);
};
export default SortFilterDropdown;

View File

@@ -0,0 +1,162 @@
import { Button, Drawer, Dropdown } from 'antd';
import { useEffect, useState } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { DownOutlined, EditOutlined, ImportOutlined } from '@ant-design/icons';
import TemplateDrawer from '@/components/common/template-drawer/template-drawer';
import { useTranslation } from 'react-i18next';
import { useLocation, useNavigate } from 'react-router-dom';
import {
setProjectData,
setProjectId,
toggleProjectDrawer,
} from '@/features/project/project-drawer.slice';
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { projectTemplatesApiService } from '@/api/project-templates/project-templates.api.service';
import { evt_projects_create_click } from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
interface CreateProjectButtonProps {
className?: string;
}
const CreateProjectButton: React.FC<CreateProjectButtonProps> = ({ className }) => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { trackMixpanelEvent } = useMixpanelTracking();
const [isTemplateDrawerOpen, setIsTemplateDrawerOpen] = useState(false);
const [currentTemplateId, setCurrentTemplateId] = useState<string>('');
const [selectedType, setSelectedType] = useState<'worklenz' | 'custom'>('worklenz');
const [projectImporting, setProjectImporting] = useState(false);
const [currentPath, setCurrentPath] = useState<string>('');
const location = useLocation();
const { t } = useTranslation('create-first-project-form');
useEffect(() => {
const pathKey = location.pathname.split('/').pop();
setCurrentPath(pathKey ?? 'home');
}, [location]);
const handleTemplateDrawerOpen = () => {
setIsTemplateDrawerOpen(true);
};
const handleTemplateDrawerClose = () => {
setIsTemplateDrawerOpen(false);
setCurrentTemplateId('');
setSelectedType('worklenz');
};
const handleTemplateSelect = (templateId: string) => {
setCurrentTemplateId(templateId);
};
const createFromWorklenzTemplate = async () => {
if (!currentTemplateId || currentTemplateId === '') return;
try {
setProjectImporting(true);
} catch (e) {
console.error(e);
} finally {
setProjectImporting(false);
handleTemplateDrawerClose();
}
};
const createFromCustomTemplate = async () => {
if (!currentTemplateId || currentTemplateId === '') return;
try {
setProjectImporting(true);
} catch (e) {
console.error(e);
} finally {
setProjectImporting(false);
handleTemplateDrawerClose();
}
};
const setCreatedProjectTemplate = async () => {
if (!currentTemplateId || currentTemplateId === '') return;
try {
setProjectImporting(true);
if (selectedType === 'worklenz') {
const res = await projectTemplatesApiService.createFromWorklenzTemplate({
template_id: currentTemplateId,
});
if (res.done) {
navigate(`/worklenz/projects/${res.body.project_id}?tab=tasks-list&pinned_tab=tasks-list`);
}
} else {
const res = await projectTemplatesApiService.createFromCustomTemplate({
template_id: currentTemplateId,
});
if (res.done) {
navigate(`/worklenz/projects/${res.body.project_id}?tab=tasks-list&pinned_tab=tasks-list`);
}
}
} catch (e) {
console.error(e);
} finally {
setProjectImporting(false);
handleTemplateDrawerClose();
}
};
const dropdownItems = [
{
key: 'template',
label: (
<div className="w-full m-0 p-0" onClick={handleTemplateDrawerOpen}>
<ImportOutlined className="mr-2" />
{currentPath === 'home' ? t('templateButton') : t('createFromTemplate')}
</div>
),
},
];
const handleCreateProject = () => {
trackMixpanelEvent(evt_projects_create_click);
dispatch(setProjectId(null));
dispatch(setProjectData({} as IProjectViewModel));
setTimeout(() => {
dispatch(toggleProjectDrawer());
}, 300);
};
return (
<div className={className}>
<Dropdown.Button
type="primary"
trigger={['click']}
icon={<DownOutlined />}
onClick={handleCreateProject}
menu={{ items: dropdownItems }}
>
<EditOutlined /> {t('createProject')}
</Dropdown.Button>
<Drawer
title={t('templateDrawerTitle')}
width={1000}
onClose={handleTemplateDrawerClose}
open={isTemplateDrawerOpen}
footer={
<div className="flex justify-end px-4 py-2.5">
<Button className="mr-2" onClick={handleTemplateDrawerClose}>
{t('cancel')}
</Button>
<Button type="primary" loading={projectImporting} onClick={setCreatedProjectTemplate}>
{t('create')}
</Button>
</div>
}
>
<TemplateDrawer
showBothTabs={true}
templateSelected={handleTemplateSelect}
selectedTemplateType={setSelectedType}
/>
</Drawer>
</div>
);
};
export default CreateProjectButton;

View File

@@ -0,0 +1,45 @@
import { ColorPicker, Form, FormInstance, Input } from 'antd';
import { useTranslation } from 'react-i18next';
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
interface ProjectBasicInfoProps {
editMode: boolean;
project: IProjectViewModel | null;
form: FormInstance;
disabled: boolean;
}
const ProjectBasicInfo = ({ editMode, project, form, disabled }: ProjectBasicInfoProps) => {
const { t } = useTranslation('project-drawer');
const defaultColorCode = '#154c9b';
return (
<>
<Form.Item
name="name"
label={t('name')}
rules={[{ required: true, message: t('pleaseEnterAName') }]}
>
<Input placeholder={t('enterProjectName')} disabled={disabled} />
</Form.Item>
{editMode && (
<Form.Item name="key" label={t('key')}>
<Input placeholder={t('enterProjectKey')} value={project?.key} disabled={disabled} />
</Form.Item>
)}
<Form.Item name="color_code" label={t('projectColor')} layout="horizontal" required>
<ColorPicker
value={project?.color_code || defaultColorCode}
onChange={value => form.setFieldValue('color_code', value.toHexString())}
disabled={disabled}
/>
</Form.Item>
</>
);
};
export default ProjectBasicInfo;

View File

@@ -0,0 +1,159 @@
import { useRef, useState } from 'react';
import { TFunction } from 'i18next';
import {
Button,
Divider,
Flex,
Form,
FormInstance,
Input,
InputRef,
Select,
Typography,
} from 'antd';
import { PlusOutlined } from '@ant-design/icons';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
addCategory,
createProjectCategory,
} from '@/features/projects/lookups/projectCategories/projectCategoriesSlice';
import { colors } from '@/styles/colors';
import { IProjectCategory } from '@/types/project/projectCategory.types';
interface ProjectCategorySectionProps {
categories: IProjectCategory[];
form: FormInstance;
t: TFunction;
disabled: boolean;
}
const defaultColorCode = '#ee87c5';
const ProjectCategorySection = ({ categories, form, t, disabled }: ProjectCategorySectionProps) => {
const dispatch = useAppDispatch();
const [isAddCategoryInputShow, setIsAddCategoryInputShow] = useState(false);
const [categoryText, setCategoryText] = useState('');
const [creating, setCreating] = useState(false);
const categoryInputRef = useRef<InputRef>(null);
const categoryOptions = categories.map((category, index) => ({
key: index,
value: category.id,
label: category.name,
}));
const handleCategoryInputFocus = () => {
setTimeout(() => {
categoryInputRef.current?.focus();
}, 0);
};
const handleShowAddCategoryInput = () => {
setIsAddCategoryInputShow(true);
handleCategoryInputFocus();
};
const handleAddCategoryInputBlur = (category: string) => {
setIsAddCategoryInputShow(false);
if (!category.trim()) return;
try {
const existingCategory = categoryOptions.find(
option => option.label?.toLowerCase() === category.toLowerCase()
);
if (existingCategory) {
form.setFieldValue('category_id', existingCategory.value);
}
form.setFieldValue('category_id', undefined);
} catch (error) {
console.error(error);
} finally {
setIsAddCategoryInputShow(false);
setCategoryText('');
return;
}
};
const handleAddCategoryItem = async (category: string) => {
if (!category.trim()) return;
try {
const existingCategory = categoryOptions.find(
option => option.label?.toLowerCase() === category.toLowerCase()
);
if (existingCategory) {
form.setFieldValue('category_id', existingCategory.value);
setCategoryText('');
setIsAddCategoryInputShow(false);
return;
}
setCreating(true);
const newCategory = {
name: category,
};
const res = await dispatch(createProjectCategory(newCategory)).unwrap();
if (res.id) {
form.setFieldValue('category_id', res.id);
setCategoryText('');
setIsAddCategoryInputShow(false);
}
} catch (error) {
console.error(error);
} finally {
setCreating(false);
}
};
return (
<>
<Form.Item name="category_id" label={t('category')}>
{!isAddCategoryInputShow ? (
<Select
options={categoryOptions}
placeholder={t('addCategory')}
loading={creating}
allowClear
dropdownRender={menu => (
<>
{menu}
<Divider style={{ margin: '8px 0' }} />
<Button
style={{ width: '100%' }}
type="dashed"
icon={<PlusOutlined />}
onClick={handleShowAddCategoryInput}
>
{t('newCategory')}
</Button>
</>
)}
disabled={disabled}
/>
) : (
<Flex vertical gap={4}>
<Input
ref={categoryInputRef}
placeholder={t('enterCategoryName')}
value={categoryText}
onChange={e => setCategoryText(e.currentTarget.value)}
allowClear
onClear={() => {
setIsAddCategoryInputShow(false)
}}
onPressEnter={() => handleAddCategoryItem(categoryText)}
onBlur={() => handleAddCategoryInputBlur(categoryText)}
disabled={disabled}
/>
<Typography.Text style={{ color: colors.lightGray }}>
{t('hitEnterToCreate')}
</Typography.Text>
</Flex>
)}
</Form.Item>
</>
);
};
export default ProjectCategorySection;

View File

@@ -0,0 +1,124 @@
import { createClient, fetchClients } from '@/features/settings/client/clientSlice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { IClientsViewModel } from '@/types/client.types';
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { QuestionCircleOutlined } from '@ant-design/icons';
import { AutoComplete, Flex, Form, FormInstance, Spin, Tooltip, Typography } from 'antd';
import { TFunction } from 'i18next';
import { useState } from 'react';
interface ProjectClientSectionProps {
clients: IClientsViewModel;
form: FormInstance;
t: TFunction;
project: IProjectViewModel | null;
loadingClients: boolean;
disabled: boolean;
}
const ProjectClientSection = ({
clients,
form,
t,
project = null,
loadingClients = false,
disabled = false,
}: ProjectClientSectionProps) => {
const dispatch = useAppDispatch();
const [searchTerm, setSearchTerm] = useState<string>('');
const clientOptions = [
...(clients.data?.map((client, index) => ({
key: index,
value: client.id,
label: client.name,
})) || []),
...(searchTerm && clients.data?.length === 0 && !loadingClients
? [
{
key: 'create',
value: 'create',
label: (
<>
+ {t('add')} <Typography.Text strong>{searchTerm}</Typography.Text>{' '}
{t('asClient').toLowerCase()}
</>
),
},
]
: []),
];
const handleClientSelect = async (value: string, option: any) => {
if (option.key === 'create') {
const res = await dispatch(createClient({ name: searchTerm })).unwrap();
if (res.done) {
setSearchTerm('');
form.setFieldsValue({
client_name: res.body.name,
client_id: res.body.id,
});
}
return;
}
form.setFieldsValue({
client_name: option.label,
client_id: option.value,
});
};
const handleClientChange = (value: string) => {
setSearchTerm(value);
form.setFieldsValue({ client_name: value });
};
const handleClientSearch = (value: string): void => {
if (value.length > 2) {
dispatch(
fetchClients({ index: 1, size: 5, field: null, order: null, search: value || null })
);
form.setFieldValue('client_name', value);
}
};
return (
<>
<Form.Item name="client_id" hidden initialValue={''}>
<input />
</Form.Item>
<Form.Item
name="client_name"
label={
<Typography.Text>
{t('client')}&nbsp;
<Tooltip title={t('youCanManageClientsUnderSettings')}>
<QuestionCircleOutlined />
</Tooltip>
</Typography.Text>
}
>
<AutoComplete
options={clientOptions}
allowClear
onSearch={handleClientSearch}
onSelect={handleClientSelect}
onChange={handleClientChange}
placeholder={t('typeToSearchClients')}
dropdownRender={menu => (
<>
{loadingClients && (
<Flex justify="center" align="center" style={{ height: '100px' }}>
<Spin />
</Flex>
)}
{menu}
</>
)}
disabled={disabled}
/>
</Form.Item>
</>
);
};
export default ProjectClientSection;

View File

@@ -0,0 +1,489 @@
import { useEffect, useState, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import {
Alert,
Button,
DatePicker,
Divider,
Drawer,
Flex,
Form,
Input,
notification,
Popconfirm,
Skeleton,
Space,
Tooltip,
Typography,
} from 'antd';
import dayjs from 'dayjs';
import { fetchClients } from '@/features/settings/client/clientSlice';
import {
useCreateProjectMutation,
useDeleteProjectMutation,
useGetProjectsQuery,
useUpdateProjectMutation,
} from '@/api/projects/projects.v1.api.service';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import { projectColors } from '@/lib/project/project-constants';
import { setProject, setProjectId } from '@/features/project/project.slice';
import { fetchProjectCategories } from '@/features/projects/lookups/projectCategories/projectCategoriesSlice';
import { fetchProjectHealth } from '@/features/projects/lookups/projectHealth/projectHealthSlice';
import { fetchProjectStatuses } from '@/features/projects/lookups/projectStatuses/projectStatusesSlice';
import ProjectManagerDropdown from '../project-manager-dropdown/project-manager-dropdown';
import ProjectBasicInfo from './project-basic-info/project-basic-info';
import ProjectHealthSection from './project-health-section/project-health-section';
import ProjectStatusSection from './project-status-section/project-status-section';
import ProjectCategorySection from './project-category-section/project-category-section';
import ProjectClientSection from './project-client-section/project-client-section';
import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
import logger from '@/utils/errorLogger';
import { setProjectData, toggleProjectDrawer, setProjectId as setDrawerProjectId } from '@/features/project/project-drawer.slice';
import useIsProjectManager from '@/hooks/useIsProjectManager';
import { useAuthService } from '@/hooks/useAuth';
import { evt_projects_create } from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
const ProjectDrawer = ({ onClose }: { onClose: () => void }) => {
const dispatch = useAppDispatch();
const navigate = useNavigate();
const { trackMixpanelEvent } = useMixpanelTracking();
const { t } = useTranslation('project-drawer');
const [form] = Form.useForm();
const [loading, setLoading] = useState<boolean>(true);
const currentSession = useAuthService().getCurrentSession();
// State
const [editMode, setEditMode] = useState<boolean>(false);
const [selectedProjectManager, setSelectedProjectManager] = useState<ITeamMemberViewModel | null>(
null
);
const [isFormValid, setIsFormValid] = useState<boolean>(true);
// Selectors
const { clients, loading: loadingClients } = useAppSelector(state => state.clientReducer);
const { requestParams } = useAppSelector(state => state.projectsReducer);
const { isProjectDrawerOpen, projectId, projectLoading, project } = useAppSelector(
state => state.projectDrawerReducer
);
const { projectStatuses } = useAppSelector(state => state.projectStatusesReducer);
const { projectHealths } = useAppSelector(state => state.projectHealthReducer);
const { projectCategories } = useAppSelector(state => state.projectCategoriesReducer);
// API Hooks
const { refetch: refetchProjects } = useGetProjectsQuery(requestParams);
const [deleteProject, { isLoading: isDeletingProject }] = useDeleteProjectMutation();
const [updateProject, { isLoading: isUpdatingProject }] = useUpdateProjectMutation();
const [createProject, { isLoading: isCreatingProject }] = useCreateProjectMutation();
// Memoized values
const defaultFormValues = useMemo(
() => ({
color_code: project?.color_code || projectColors[0],
status_id: project?.status_id || projectStatuses.find(status => status.is_default)?.id,
health_id: project?.health_id || projectHealths.find(health => health.is_default)?.id,
client_id: project?.client_id || null,
client: project?.client_name || null,
category_id: project?.category_id || null,
working_days: project?.working_days || 0,
man_days: project?.man_days || 0,
hours_per_day: project?.hours_per_day || 8,
}),
[project, projectStatuses, projectHealths]
);
// Auth and permissions
const isProjectManager = currentSession?.team_member_id == selectedProjectManager?.id;
const isOwnerorAdmin = useAuthService().isOwnerOrAdmin();
const isEditable = isProjectManager || isOwnerorAdmin;
// Effects
useEffect(() => {
const loadInitialData = async () => {
const fetchPromises = [];
if (projectStatuses.length === 0) fetchPromises.push(dispatch(fetchProjectStatuses()));
if (projectCategories.length === 0) fetchPromises.push(dispatch(fetchProjectCategories()));
if (projectHealths.length === 0) fetchPromises.push(dispatch(fetchProjectHealth()));
if (!clients.data?.length) {
fetchPromises.push(
dispatch(fetchClients({ index: 1, size: 5, field: null, order: null, search: null }))
);
}
await Promise.all(fetchPromises);
};
loadInitialData();
}, [dispatch]);
useEffect(() => {
const startDate = form.getFieldValue('start_date');
const endDate = form.getFieldValue('end_date');
if (startDate && endDate) {
const days = calculateWorkingDays(
dayjs.isDayjs(startDate) ? startDate : dayjs(startDate),
dayjs.isDayjs(endDate) ? endDate : dayjs(endDate)
);
form.setFieldsValue({ working_days: days });
}
}, [form]);
// Handlers
const handleFormSubmit = async (values: any) => {
try {
const projectModel: IProjectViewModel = {
name: values.name,
color_code: values.color_code,
status_id: values.status_id,
category_id: values.category_id || null,
health_id: values.health_id,
notes: values.notes,
key: values.key,
client_id: values.client_id,
client_name: values.client_name,
start_date: values.start_date,
end_date: values.end_date,
working_days: parseInt(values.working_days),
man_days: parseInt(values.man_days),
hours_per_day: parseInt(values.hours_per_day),
project_manager: selectedProjectManager,
};
const action =
editMode && projectId
? updateProject({ id: projectId, project: projectModel })
: createProject(projectModel);
const response = await action;
if (response?.data?.done) {
form.resetFields();
dispatch(toggleProjectDrawer());
if (!editMode) {
trackMixpanelEvent(evt_projects_create);
navigate(`/worklenz/projects/${response.data.body.id}?tab=tasks-list&pinned_tab=tasks-list`);
}
refetchProjects();
window.location.reload(); // Refresh the page
} else {
notification.error({ message: response?.data?.message });
logger.error(
editMode ? 'Error updating project' : 'Error creating project',
response?.data?.message
);
}
} catch (error) {
logger.error('Error saving project', error);
}
};
const calculateWorkingDays = (startDate: dayjs.Dayjs | null, endDate: dayjs.Dayjs | null): number => {
if (!startDate || !endDate || !startDate.isValid() || !endDate.isValid() || startDate.isAfter(endDate)) {
return 0;
}
let workingDays = 0;
let currentDate = startDate.clone().startOf('day');
const end = endDate.clone().startOf('day');
while (currentDate.isBefore(end) || currentDate.isSame(end)) {
const dayOfWeek = currentDate.day();
if (dayOfWeek !== 0 && dayOfWeek !== 6) {
workingDays++;
}
currentDate = currentDate.add(1, 'day');
}
return workingDays;
};
const handleVisibilityChange = useCallback(
(visible: boolean) => {
if (visible && projectId) {
setEditMode(true);
if (project) {
form.setFieldsValue({
...project,
start_date: project.start_date ? dayjs(project.start_date) : null,
end_date: project.end_date ? dayjs(project.end_date) : null,
working_days: form.getFieldValue('start_date') && form.getFieldValue('end_date') ? calculateWorkingDays(form.getFieldValue('start_date'), form.getFieldValue('end_date')) : project.working_days || 0,
});
setSelectedProjectManager(project.project_manager || null);
setLoading(false);
}
} else {
resetForm();
}
},
[projectId, project]
);
const resetForm = useCallback(() => {
setEditMode(false);
form.resetFields();
setSelectedProjectManager(null);
}, [form]);
const handleDrawerClose = useCallback(() => {
setLoading(true);
resetForm();
dispatch(setProjectData({} as IProjectViewModel));
dispatch(setProjectId(null));
dispatch(setDrawerProjectId(null));
dispatch(toggleProjectDrawer());
onClose();
}, [resetForm, dispatch, onClose]);
const handleDeleteProject = async () => {
if (!projectId) return;
try {
const res = await deleteProject(projectId);
if (res?.data?.done) {
dispatch(setProject({} as IProjectViewModel));
dispatch(setProjectData({} as IProjectViewModel));
dispatch(setProjectId(null));
dispatch(toggleProjectDrawer());
navigate('/worklenz/projects');
refetchProjects();
window.location.reload(); // Refresh the page
} else {
notification.error({ message: res?.data?.message });
logger.error('Error deleting project', res?.data?.message);
}
} catch (error) {
logger.error('Error deleting project', error);
}
};
const disabledStartDate = useCallback(
(current: dayjs.Dayjs) => {
const endDate = form.getFieldValue('end_date');
return current && endDate ? current > dayjs(endDate) : false;
},
[form]
);
const disabledEndDate = useCallback(
(current: dayjs.Dayjs) => {
const startDate = form.getFieldValue('start_date');
return current && startDate ? current < dayjs(startDate) : false;
},
[form]
);
const handleFieldsChange = (_: any, allFields: any[]) => {
const isValid = allFields.every(field => field.errors.length === 0);
setIsFormValid(isValid);
};
return (
<Drawer
// loading={loading}
title={
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
{projectId ? t('editProject') : t('createProject')}
</Typography.Text>
}
open={isProjectDrawerOpen}
onClose={handleDrawerClose}
destroyOnClose
afterOpenChange={handleVisibilityChange}
footer={
<Flex justify="space-between">
<Space>
{editMode && (isProjectManager || isOwnerorAdmin) && (
<Popconfirm
title={t('deleteConfirmation')}
description={t('deleteConfirmationDescription')}
onConfirm={handleDeleteProject}
okText={t('yes')}
cancelText={t('no')}
>
<Button danger type="dashed" loading={isDeletingProject}>
{t('delete')}
</Button>
</Popconfirm>
)}
</Space>
<Space>
{(isProjectManager || isOwnerorAdmin) && (
<Button
type="primary"
onClick={() => form.submit()}
loading={isCreatingProject || isUpdatingProject}
disabled={!isFormValid}
>
{editMode ? t('update') : t('create')}
</Button>
)}
</Space>
</Flex>
}
>
{!isEditable && (
<Alert
message={t('noPermission')}
type="warning"
showIcon
style={{ marginBottom: 16 }}
/>
)}
<Skeleton active paragraph={{ rows: 12 }} loading={projectLoading}>
<Form
form={form}
layout="vertical"
onFinish={handleFormSubmit}
initialValues={defaultFormValues}
onFieldsChange={handleFieldsChange}
>
<ProjectBasicInfo
editMode={editMode}
project={project}
form={form}
disabled={!isProjectManager && !isOwnerorAdmin}
/>
<ProjectStatusSection
statuses={projectStatuses}
form={form}
t={t}
disabled={!isProjectManager && !isOwnerorAdmin}
/>
<ProjectHealthSection
healths={projectHealths}
form={form}
t={t}
disabled={!isProjectManager && !isOwnerorAdmin}
/>
<ProjectCategorySection
categories={projectCategories}
form={form}
t={t}
disabled={!isProjectManager && !isOwnerorAdmin}
/>
<Form.Item name="notes" label={t('notes')}>
<Input.TextArea
placeholder={t('enterNotes')}
disabled={!isProjectManager && !isOwnerorAdmin}
/>
</Form.Item>
<ProjectClientSection
clients={clients}
form={form}
t={t}
project={project}
loadingClients={loadingClients}
disabled={!isProjectManager && !isOwnerorAdmin}
/>
<Form.Item name="project_manager" label={t('projectManager')} layout="horizontal">
<ProjectManagerDropdown
selectedProjectManager={selectedProjectManager}
setSelectedProjectManager={setSelectedProjectManager}
disabled={!isProjectManager && !isOwnerorAdmin}
/>
</Form.Item>
<Form.Item name="date" layout="horizontal">
<Flex gap={8}>
<Form.Item
name="start_date"
label={t('startDate')}
>
<DatePicker
disabledDate={disabledStartDate}
disabled={!isProjectManager && !isOwnerorAdmin}
onChange={(date) => {
const endDate = form.getFieldValue('end_date');
if (date && endDate) {
const days = calculateWorkingDays(date, endDate);
form.setFieldsValue({ working_days: days });
}
}}
/>
</Form.Item>
<Form.Item
name="end_date"
label={t('endDate')}
>
<DatePicker
disabledDate={disabledEndDate}
disabled={!isProjectManager && !isOwnerorAdmin}
onChange={(date) => {
const startDate = form.getFieldValue('start_date');
if (startDate && date) {
const days = calculateWorkingDays(startDate, date);
form.setFieldsValue({ working_days: days });
}
}}
/>
</Form.Item>
</Flex>
</Form.Item>
{/* <Form.Item
name="working_days"
label={t('estimateWorkingDays')}
>
<Input
type="number"
disabled // Make it read-only since it's calculated
/>
</Form.Item> */}
<Form.Item name="working_days" label={t('estimateWorkingDays')}>
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
</Form.Item>
<Form.Item name="man_days" label={t('estimateManDays')}>
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
</Form.Item>
<Form.Item
name="hours_per_day"
label={t('hoursPerDay')}
rules={[
{
validator: (_, value) => {
if (value === undefined || (value >= 0 && value <= 24)) {
return Promise.resolve();
}
return Promise.reject(new Error(t('hoursPerDayValidationMessage', { min: 0, max: 24 })));
},
},
]}
>
<Input type="number" disabled={!isProjectManager && !isOwnerorAdmin} />
</Form.Item>
</Form>
{editMode && (
<Flex vertical gap={4}>
<Divider />
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('createdAt')}&nbsp;
<Tooltip title={formatDateTimeWithLocale(project?.created_at || '')}>
{calculateTimeDifference(project?.created_at || '')}
</Tooltip>{' '}
{t('by')} {project?.project_owner || ''}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{t('updatedAt')}&nbsp;
<Tooltip title={formatDateTimeWithLocale(project?.updated_at || '')}>
{calculateTimeDifference(project?.updated_at || '')}
</Tooltip>
</Typography.Text>
</Flex>
)}
</Skeleton>
</Drawer>
);
};
export default ProjectDrawer;

View File

@@ -0,0 +1,35 @@
import { TFunction } from 'i18next';
import { Badge, Form, FormInstance, Select, Typography } from 'antd';
import { IProjectHealth } from '@/types/project/projectHealth.types';
interface ProjectHealthSectionProps {
healths: IProjectHealth[];
form: FormInstance;
t: TFunction;
disabled: boolean;
}
const ProjectHealthSection = ({ healths, form, t, disabled }: ProjectHealthSectionProps) => {
const healthOptions = healths.map((status, index) => ({
key: index,
value: status.id,
label: (
<Typography.Text style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Badge color={status.color_code} /> {status.name}
</Typography.Text>
),
}));
return (
<Form.Item name="health_id" label={t('health')}>
<Select
options={healthOptions}
onChange={value => form.setFieldValue('health_id', value)}
disabled={disabled}
/>
</Form.Item>
);
};
export default ProjectHealthSection;

View File

@@ -0,0 +1,39 @@
import { Form, FormInstance, Select, Typography } from 'antd';
import { TFunction } from 'i18next';
import { IProjectStatus } from '@/types/project/projectStatus.types';
import { getStatusIcon } from '@/utils/projectUtils';
interface ProjectStatusSectionProps {
statuses: IProjectStatus[];
form: FormInstance;
t: TFunction;
disabled: boolean;
}
const ProjectStatusSection = ({ statuses, form, t, disabled }: ProjectStatusSectionProps) => {
const statusOptions = statuses.map((status, index) => ({
key: index,
value: status.id,
label: (
<Typography.Text style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
{status.icon && status.color_code && getStatusIcon(status.icon, status.color_code)}
{status.name}
</Typography.Text>
),
}));
return (
<Form.Item name="status_id" label={t('status')}>
<Select
options={statusOptions}
onChange={value => form.setFieldValue('status_id', value)}
placeholder={t('selectStatus')}
disabled={disabled}
/>
</Form.Item>
);
};
export default ProjectStatusSection;

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