init
This commit is contained in:
@@ -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;
|
||||
@@ -0,0 +1,31 @@
|
||||
.project-manager-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 4px;
|
||||
transition: border 0.3s;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.project-manager-container::before {
|
||||
border-width: 1px;
|
||||
border-style: none;
|
||||
border-color: transparent;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.project-manager-icon {
|
||||
display: none;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.project-manager-container:hover .project-manager-icon {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.project-manager-container:hover {
|
||||
border: 1px solid #40a9ff;
|
||||
border-radius: 6px;
|
||||
transition: 0.25s all;
|
||||
min-height: 32px;
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||
import { getTeamMembers } from '@/features/team-members/team-members.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
||||
import { CloseCircleFilled, PlusCircleOutlined } from '@ant-design/icons';
|
||||
import { Button, Dropdown, Flex, Input, InputRef, theme, Typography } from 'antd';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './project-manager-dropdown.css';
|
||||
|
||||
interface ProjectManagerDropdownProps {
|
||||
selectedProjectManager: ITeamMemberViewModel | null;
|
||||
setSelectedProjectManager: (member: ITeamMemberViewModel | null) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
const ProjectManagerDropdown: React.FC<ProjectManagerDropdownProps> = ({
|
||||
selectedProjectManager,
|
||||
setSelectedProjectManager,
|
||||
disabled = false,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('project-drawer');
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const labelInputRef = useRef<InputRef>(null);
|
||||
const { token } = theme.useToken();
|
||||
const { teamMembers } = useAppSelector(state => state.teamMembersReducer);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(getTeamMembers({ index: 1, size: 5, field: null, order: null, search: searchQuery }));
|
||||
}, [dispatch, searchQuery]);
|
||||
|
||||
const projectManagerOptions = useMemo(() => {
|
||||
return (
|
||||
teamMembers?.data?.map((member, index) => ({
|
||||
key: index,
|
||||
value: member.id,
|
||||
label: (
|
||||
<Flex
|
||||
align="center"
|
||||
gap="0px"
|
||||
onClick={() => setSelectedProjectManager(member)}
|
||||
key={member.id}
|
||||
>
|
||||
<SingleAvatar avatarUrl={member.avatar_url} name={member.name} email={member.email} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
<Typography.Text style={{ fontSize: '14px' }}>{member.name}</Typography.Text>
|
||||
<Typography.Text
|
||||
type="secondary"
|
||||
style={{ fontSize: '11.2px', maxWidth: '212px' }}
|
||||
ellipsis={{ tooltip: true }}
|
||||
>
|
||||
{member.email}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Flex>
|
||||
),
|
||||
})) || []
|
||||
);
|
||||
}, [teamMembers]);
|
||||
|
||||
const contentStyle: React.CSSProperties = {
|
||||
backgroundColor: token.colorBgElevated,
|
||||
borderRadius: token.borderRadiusLG,
|
||||
boxShadow: token.boxShadowSecondary,
|
||||
margin: '12px',
|
||||
maxHeight: '255px',
|
||||
overflow: 'auto',
|
||||
};
|
||||
|
||||
const projectManagerOptionsDropdownRender = (menu: any) => {
|
||||
return (
|
||||
<div style={contentStyle}>
|
||||
<Input
|
||||
ref={labelInputRef}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.currentTarget.value)}
|
||||
placeholder={t('searchInputPlaceholder')}
|
||||
style={{ width: 'auto', margin: '5px' }}
|
||||
autoComplete="off"
|
||||
/>
|
||||
{menu}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
menu={{ items: projectManagerOptions }}
|
||||
trigger={['click']}
|
||||
dropdownRender={projectManagerOptionsDropdownRender}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className={`project-manager-container ${selectedProjectManager ? 'selected' : ''}`}>
|
||||
{selectedProjectManager ? (
|
||||
<>
|
||||
<SingleAvatar
|
||||
avatarUrl={selectedProjectManager.avatar_url}
|
||||
name={selectedProjectManager.name}
|
||||
email={selectedProjectManager.email}
|
||||
/>
|
||||
<Typography.Text>{selectedProjectManager.name}</Typography.Text>
|
||||
{!disabled && (
|
||||
<CloseCircleFilled
|
||||
className="project-manager-icon"
|
||||
onClick={() => setSelectedProjectManager(null)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<Button type="dashed" shape="circle" icon={<PlusCircleOutlined />} />
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectManagerDropdown;
|
||||
@@ -0,0 +1,244 @@
|
||||
import { Drawer, Flex, Form, Select, Typography, List, Button } from 'antd/es';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
addProjectMember,
|
||||
createByEmail,
|
||||
deleteProjectMember,
|
||||
getAllProjectMembers,
|
||||
toggleProjectMemberDrawer,
|
||||
} from '@/features/projects/singleProject/members/projectMembersSlice';
|
||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||
import { DeleteOutlined, MailOutlined } from '@ant-design/icons';
|
||||
import { getTeamMembers } from '@/features/team-members/team-members.slice';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { validateEmail } from '@/utils/validateEmail';
|
||||
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
||||
import { teamMembersApiService } from '@/api/team-members/teamMembers.api.service';
|
||||
|
||||
const ProjectMemberDrawer = () => {
|
||||
const { t } = useTranslation('project-view/project-member-drawer');
|
||||
const { isDrawerOpen, currentMembersList, isLoading } = useAppSelector(
|
||||
state => state.projectMemberReducer
|
||||
);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const [form] = Form.useForm();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [isInviting, setIsInviting] = useState(false);
|
||||
const [members, setMembers] = useState<ITeamMembersViewModel>({ data: [], total: 0 });
|
||||
const [teamMembersLoading, setTeamMembersLoading] = useState(false);
|
||||
|
||||
const fetchProjectMembers = async () => {
|
||||
if (!projectId) return;
|
||||
dispatch(getAllProjectMembers(projectId));
|
||||
};
|
||||
|
||||
const handleSearch = (value: string) => {
|
||||
setSearchTerm(value);
|
||||
};
|
||||
|
||||
const fetchTeamMembers = async () => {
|
||||
if (!searchTerm.trim()) return;
|
||||
try {
|
||||
setTeamMembersLoading(true);
|
||||
const response = await teamMembersApiService.get(1, 10, null, null, searchTerm, true);
|
||||
if (response.done) {
|
||||
setMembers(response.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching team members:', error);
|
||||
} finally {
|
||||
setTeamMembersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(async () => {
|
||||
if (searchTerm.trim()) {
|
||||
fetchTeamMembers();
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler);
|
||||
};
|
||||
}, [searchTerm, dispatch]);
|
||||
|
||||
const handleSelectChange = async (memberId: string) => {
|
||||
if (!projectId || !memberId) return;
|
||||
|
||||
try {
|
||||
const res = await dispatch(addProjectMember({ memberId, projectId })).unwrap();
|
||||
if (res.done) {
|
||||
form.resetFields();
|
||||
dispatch(getTeamMembers({
|
||||
index: 1,
|
||||
size: 5,
|
||||
field: null,
|
||||
order: null,
|
||||
search: null,
|
||||
all: true,
|
||||
}));
|
||||
await fetchProjectMembers();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error adding member:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMember = async (memberId: string | undefined) => {
|
||||
if (!memberId || !projectId) return;
|
||||
|
||||
try {
|
||||
const res = await dispatch(deleteProjectMember({ memberId, projectId })).unwrap();
|
||||
if (res.done) {
|
||||
await fetchProjectMembers();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting member:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = () => {
|
||||
if (isDrawerOpen) {
|
||||
fetchProjectMembers();
|
||||
dispatch(
|
||||
getTeamMembers({
|
||||
index: 1,
|
||||
size: 5,
|
||||
field: null,
|
||||
order: null,
|
||||
search: null,
|
||||
all: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const sendInviteToProject = async () => {
|
||||
if (!validateEmail(searchTerm) || !projectId) return;
|
||||
if (typeof searchTerm !== 'string' || !searchTerm.length) return;
|
||||
|
||||
try {
|
||||
const email = searchTerm.trim().toLowerCase();
|
||||
const body = {
|
||||
email,
|
||||
project_id: projectId,
|
||||
};
|
||||
setIsInviting(true);
|
||||
const res = await dispatch(createByEmail(body)).unwrap();
|
||||
if (res.done) {
|
||||
form.resetFields();
|
||||
await fetchProjectMembers();
|
||||
dispatch(getTeamMembers({
|
||||
index: 1,
|
||||
size: 5,
|
||||
field: null,
|
||||
order: null,
|
||||
search: null,
|
||||
all: true,
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error sending invite:', error);
|
||||
} finally {
|
||||
setIsInviting(false);
|
||||
setSearchTerm('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||
if (event.key === 'Enter') {
|
||||
sendInviteToProject();
|
||||
}
|
||||
};
|
||||
|
||||
const renderMemberOption = (member: any) => (
|
||||
<Flex gap={4} align="center">
|
||||
<SingleAvatar avatarUrl={member.avatar_url} name={member.name} email={member.email} />
|
||||
<Flex vertical>
|
||||
<Typography.Text style={{ textTransform: 'capitalize' }}>{member.name}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{member.email}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
const renderNotFoundContent = () => (
|
||||
<Flex>
|
||||
<Button
|
||||
block
|
||||
type="primary"
|
||||
onClick={sendInviteToProject}
|
||||
loading={isInviting}
|
||||
disabled={!validateEmail(searchTerm)}
|
||||
>
|
||||
<span>
|
||||
<MailOutlined />
|
||||
{validateEmail(searchTerm) ? t('inviteAsAMember') : t('inviteNewMemberByEmail')}
|
||||
</span>
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>{t('title')}</Typography.Text>
|
||||
}
|
||||
open={isDrawerOpen}
|
||||
onClose={() => dispatch(toggleProjectMemberDrawer())}
|
||||
afterOpenChange={handleOpenChange}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSelectChange}>
|
||||
<Form.Item name="memberName" label={t('searchLabel')}>
|
||||
<Select
|
||||
loading={teamMembersLoading}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
showSearch
|
||||
onSearch={handleSearch}
|
||||
onChange={handleSelectChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
options={members?.data?.map(member => ({
|
||||
key: member.id,
|
||||
value: member.id,
|
||||
name: member.name,
|
||||
label: renderMemberOption(member),
|
||||
}))}
|
||||
filterOption={false}
|
||||
notFoundContent={renderNotFoundContent()}
|
||||
optionLabelProp="name"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<List
|
||||
loading={isLoading}
|
||||
bordered
|
||||
size="small"
|
||||
itemLayout="horizontal"
|
||||
dataSource={currentMembersList}
|
||||
renderItem={member => (
|
||||
<List.Item key={member.id}>
|
||||
<Flex gap={4} align="center" justify="space-between" style={{ width: '100%' }}>
|
||||
{renderMemberOption(member)}
|
||||
<Button
|
||||
onClick={() => handleDeleteMember(member.id)}
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
/>
|
||||
</Flex>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectMemberDrawer;
|
||||
@@ -0,0 +1,58 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { Card, Flex, Skeleton, Tooltip, Typography } from 'antd';
|
||||
import { ExclamationCircleOutlined } from '@ant-design/icons';
|
||||
import { colors } from '@/styles/colors';
|
||||
|
||||
type InsightCardProps = {
|
||||
icon: string;
|
||||
title: string;
|
||||
tooltip?: string;
|
||||
children: ReactNode;
|
||||
loading?: boolean;
|
||||
};
|
||||
|
||||
const ProjectStatsCard = ({ icon, title, tooltip, children, loading }: InsightCardProps) => {
|
||||
return (
|
||||
<Card
|
||||
className="custom-insights-card"
|
||||
style={{ width: '100%' }}
|
||||
styles={{ body: { paddingInline: 16 } }}
|
||||
>
|
||||
<Skeleton loading={loading} active paragraph={{ rows: 2 }}>
|
||||
<Flex gap={16} align="center">
|
||||
<img
|
||||
src={icon}
|
||||
alt={`${title.toLowerCase()} icon`}
|
||||
style={{
|
||||
width: '100%',
|
||||
maxWidth: 42,
|
||||
height: '100%',
|
||||
maxHeight: 42,
|
||||
}}
|
||||
/>
|
||||
<Flex vertical>
|
||||
<Typography.Text style={{ fontSize: 16 }}>
|
||||
{title}
|
||||
{tooltip && (
|
||||
<Tooltip title={tooltip}>
|
||||
<ExclamationCircleOutlined
|
||||
style={{
|
||||
color: colors.skyBlue,
|
||||
fontSize: 13,
|
||||
marginInlineStart: 4,
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Typography.Text>
|
||||
<Typography.Title level={2} style={{ marginBlock: 0 }}>
|
||||
{children}
|
||||
</Typography.Title>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Skeleton>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectStatsCard;
|
||||
Reference in New Issue
Block a user