init
This commit is contained in:
27
worklenz-frontend/src/components/AuthPageHeader.tsx
Normal file
27
worklenz-frontend/src/components/AuthPageHeader.tsx
Normal 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;
|
||||
25
worklenz-frontend/src/components/CustomAvatar.tsx
Normal file
25
worklenz-frontend/src/components/CustomAvatar.tsx
Normal 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;
|
||||
37
worklenz-frontend/src/components/CustomSearchbar.tsx
Normal file
37
worklenz-frontend/src/components/CustomSearchbar.tsx
Normal 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;
|
||||
19
worklenz-frontend/src/components/CustomTableTitle.tsx
Normal file
19
worklenz-frontend/src/components/CustomTableTitle.tsx
Normal 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;
|
||||
30
worklenz-frontend/src/components/EmptyListPlaceholder.tsx
Normal file
30
worklenz-frontend/src/components/EmptyListPlaceholder.tsx
Normal 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;
|
||||
73
worklenz-frontend/src/components/ErrorBoundary.tsx
Normal file
73
worklenz-frontend/src/components/ErrorBoundary.tsx
Normal 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;
|
||||
62
worklenz-frontend/src/components/PinRouteToNavbarButton.tsx
Normal file
62
worklenz-frontend/src/components/PinRouteToNavbarButton.tsx
Normal 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;
|
||||
28
worklenz-frontend/src/components/PreferenceSelector.tsx
Normal file
28
worklenz-frontend/src/components/PreferenceSelector.tsx
Normal 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;
|
||||
50
worklenz-frontend/src/components/TawkTo.tsx
Normal file
50
worklenz-frontend/src/components/TawkTo.tsx
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
182
worklenz-frontend/src/components/account-setup/members-step.tsx
Normal file
182
worklenz-frontend/src/components/account-setup/members-step.tsx
Normal 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')} <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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
155
worklenz-frontend/src/components/account-setup/project-step.tsx
Normal file
155
worklenz-frontend/src/components/account-setup/project-step.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
132
worklenz-frontend/src/components/account-setup/tasks-step.tsx
Normal file
132
worklenz-frontend/src/components/account-setup/tasks-step.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
<Typography.Text>
|
||||
{billingInfo?.billing_type === 'year'
|
||||
? billingInfo.unit_price_per_month
|
||||
: billingInfo?.unit_price}
|
||||
|
||||
{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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
.upgrade-plans .ant-card-head-wrapper {
|
||||
padding: 16px 0;
|
||||
}
|
||||
@@ -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} />
|
||||
|
||||
<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;
|
||||
@@ -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} />
|
||||
<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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
.setting-team-table .ant-table-thead > tr > th,
|
||||
.setting-team-table .ant-table-tbody > tr > td {
|
||||
padding: 12px 10px !important;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
46
worklenz-frontend/src/components/avatars/avatars.tsx
Normal file
46
worklenz-frontend/src/components/avatars/avatars.tsx
Normal 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;
|
||||
@@ -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}
|
||||
{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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
58
worklenz-frontend/src/components/board/taskCard/TaskCard.css
Normal file
58
worklenz-frontend/src/components/board/taskCard/TaskCard.css
Normal 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;
|
||||
}
|
||||
318
worklenz-frontend/src/components/board/taskCard/TaskCard.tsx
Normal file
318
worklenz-frontend/src/components/board/taskCard/TaskCard.tsx
Normal 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;
|
||||
@@ -0,0 +1,3 @@
|
||||
.task-card .create-task-empty-date {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
.home-calendar .ant-picker-calendar-date-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.home-calendar .ant-picker-calendar-date-value {
|
||||
line-height: 44px !important;
|
||||
}
|
||||
26
worklenz-frontend/src/components/collapsible/collapsible.tsx
Normal file
26
worklenz-frontend/src/components/collapsible/collapsible.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
132
worklenz-frontend/src/components/project-list/TableColumns.tsx
Normal file
132
worklenz-frontend/src/components/project-list/TableColumns.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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')}
|
||||
<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;
|
||||
@@ -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')}
|
||||
<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')}
|
||||
<Tooltip title={formatDateTimeWithLocale(project?.updated_at || '')}>
|
||||
{calculateTimeDifference(project?.updated_at || '')}
|
||||
</Tooltip>
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
)}
|
||||
</Skeleton>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectDrawer;
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user