init
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
import { DeleteOutlined, ExclamationCircleFilled, SearchOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Table,
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { CategoryType } from '@/types/categories.types';
|
||||
import CustomColorsCategoryTag from '@features/settings/categories/CustomColorsCategoryTag';
|
||||
import { deleteCategory } from '@features/settings/categories/categoriesSlice';
|
||||
import { categoriesApiService } from '@/api/settings/categories/categories.api.service';
|
||||
import { IProjectCategory, IProjectCategoryViewModel } from '@/types/project/projectCategory.types';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
|
||||
const CategoriesSettings = () => {
|
||||
// localization
|
||||
const { t } = useTranslation('settings/categories');
|
||||
|
||||
useDocumentTitle('Manage Categories');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
// get currently hover row
|
||||
const [hoverRow, setHoverRow] = useState<string | null>(null);
|
||||
const [categories, setCategories] = useState<IProjectCategoryViewModel[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
const filteredData = useMemo(
|
||||
() =>
|
||||
categories.filter(record =>
|
||||
Object.values(record).some(value =>
|
||||
value?.toString().toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
),
|
||||
[categories, searchQuery]
|
||||
);
|
||||
|
||||
const getCategories = useMemo(() => {
|
||||
setLoading(true);
|
||||
return async () => {
|
||||
const response = await categoriesApiService.getCategories();
|
||||
if (response.done) {
|
||||
setCategories(response.body);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getCategories();
|
||||
}, [getCategories]);
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'category',
|
||||
title: t('categoryColumn'),
|
||||
render: (record: IProjectCategoryViewModel) => <CustomColorsCategoryTag category={record} />,
|
||||
},
|
||||
{
|
||||
key: 'associatedTask',
|
||||
title: t('associatedTaskColumn'),
|
||||
render: (record: IProjectCategoryViewModel) => <Typography.Text>{record.usage}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'actionBtns',
|
||||
width: 60,
|
||||
render: (record: IProjectCategoryViewModel) =>
|
||||
hoverRow === record.id && (
|
||||
<Popconfirm
|
||||
title={t('deleteConfirmationTitle')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText={t('deleteConfirmationOk')}
|
||||
cancelText={t('deleteConfirmationCancel')}
|
||||
onConfirm={() => record.id && dispatch(deleteCategory(record.id))}
|
||||
>
|
||||
<Tooltip title="Delete">
|
||||
<Button shape="default" icon={<DeleteOutlined />} size="small" />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{ width: '100%' }}
|
||||
title={
|
||||
<Flex justify="flex-end">
|
||||
<Flex gap={8} align="center" justify="flex-end" style={{ width: '100%', maxWidth: 400 }}>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.currentTarget.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
style={{ maxWidth: 232 }}
|
||||
suffix={<SearchOutlined />}
|
||||
/>
|
||||
</Flex>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
locale={{
|
||||
emptyText: <Typography.Text>{t('emptyText')}</Typography.Text>,
|
||||
}}
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={filteredData}
|
||||
columns={columns}
|
||||
rowKey={record => record.categoryId}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 20,
|
||||
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
|
||||
size: 'small',
|
||||
}}
|
||||
onRow={record => {
|
||||
return {
|
||||
onMouseEnter: () => setHoverRow(record.categoryId),
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default CategoriesSettings;
|
||||
@@ -0,0 +1,131 @@
|
||||
import { EyeInvisibleOutlined, EyeOutlined } from '@ant-design/icons';
|
||||
import { Button, Card, Form, Input, notification, Row, Typography } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { profileSettingsApiService } from '@/api/settings/profile/profile-settings.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ChangePassword: React.FC = () => {
|
||||
const { t } = useTranslation('settings/change-password');
|
||||
useDocumentTitle(t('title'));
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
// Password validation regex
|
||||
const passwordRegex = /^(?=.*[A-Z])(?=.*\d)(?=.*[!@#$%^&*()_+\-=[\]{};':"\\|,.<>/?]).{8,}$/;
|
||||
|
||||
const validatePassword = (_: any, value: string) => {
|
||||
if (!value) {
|
||||
return Promise.reject(new Error(t('newPasswordRequired')));
|
||||
}
|
||||
if (!passwordRegex.test(value)) {
|
||||
return Promise.reject(
|
||||
new Error(t('passwordValidationError'))
|
||||
);
|
||||
}
|
||||
return Promise.resolve();
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (values: any) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const body = {
|
||||
new_password: values.newPassword,
|
||||
confirm_password: values.confirmPassword,
|
||||
password: values.currentPassword,
|
||||
};
|
||||
|
||||
const res = await profileSettingsApiService.changePassword(body);
|
||||
if (res.done) {
|
||||
form.resetFields();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing password', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Common password input props
|
||||
const getPasswordInputProps = (placeholder: string) => ({
|
||||
type: 'password',
|
||||
style: { width: '350px' },
|
||||
placeholder,
|
||||
iconRender: (visible: boolean) =>
|
||||
visible ? (
|
||||
<EyeInvisibleOutlined style={{ color: '#000000d9' }} />
|
||||
) : (
|
||||
<EyeOutlined style={{ color: '#000000d9' }} />
|
||||
),
|
||||
});
|
||||
|
||||
return (
|
||||
<Card style={{ width: '100%' }}>
|
||||
<Form layout="vertical" form={form} onFinish={handleFormSubmit}>
|
||||
<Row>
|
||||
<Form.Item
|
||||
name="currentPassword"
|
||||
label={t('currentPassword')}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('currentPasswordRequired'),
|
||||
},
|
||||
]}
|
||||
style={{ marginBottom: '24px' }}
|
||||
>
|
||||
<Input.Password {...getPasswordInputProps(t('currentPasswordPlaceholder'))} />
|
||||
</Form.Item>
|
||||
</Row>
|
||||
<Row>
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
label={t('newPassword')}
|
||||
rules={[{ validator: validatePassword }]}
|
||||
>
|
||||
<Input.Password {...getPasswordInputProps(t('newPasswordPlaceholder'))} />
|
||||
</Form.Item>
|
||||
</Row>
|
||||
<Row>
|
||||
<Form.Item
|
||||
name="confirmPassword"
|
||||
label={t('confirmPassword')}
|
||||
dependencies={['newPassword']}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('newPasswordRequired'),
|
||||
},
|
||||
({ getFieldValue }) => ({
|
||||
validator(_, value) {
|
||||
if (!value || getFieldValue('newPassword') === value) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return Promise.reject(new Error(t('passwordMismatch')));
|
||||
},
|
||||
}),
|
||||
]}
|
||||
style={{ marginBottom: '0px' }}
|
||||
>
|
||||
<Input.Password {...getPasswordInputProps(t('confirmPasswordPlaceholder'))} />
|
||||
</Form.Item>
|
||||
</Row>
|
||||
<Row style={{ width: '350px', margin: '0.5rem 0' }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: '12px' }}>
|
||||
{t('passwordRequirements')}
|
||||
</Typography.Text>
|
||||
</Row>
|
||||
<Row>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={loading}>
|
||||
{t('updateButton')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Row>
|
||||
</Form>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChangePassword;
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Button, Drawer, Form, Input, message, Typography } from 'antd';
|
||||
import { useEffect } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
createClient,
|
||||
toggleClientDrawer,
|
||||
updateClient,
|
||||
} from '@/features/settings/client/clientSlice';
|
||||
import { IClient } from '@/types/client.types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
type ClientDrawerProps = {
|
||||
client: IClient | null;
|
||||
drawerClosed: () => void;
|
||||
};
|
||||
|
||||
const ClientDrawer = ({ client, drawerClosed }: ClientDrawerProps) => {
|
||||
const { t } = useTranslation('settings/clients');
|
||||
const { isClientDrawerOpen } = useAppSelector(state => state.clientReducer);
|
||||
const dispatch = useAppDispatch();
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
if (client?.name) {
|
||||
form.setFieldsValue({ name: client.name });
|
||||
}
|
||||
}, [client, form]);
|
||||
|
||||
const handleFormSubmit = async (values: { name: string }) => {
|
||||
try {
|
||||
if (client && client.id) {
|
||||
await dispatch(updateClient({ id: client.id, body: { name: values.name } }));
|
||||
} else {
|
||||
await dispatch(createClient({ name: values.name }));
|
||||
}
|
||||
dispatch(toggleClientDrawer());
|
||||
drawerClosed();
|
||||
} catch (error) {
|
||||
message.error(t('updateClientErrorMessage'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
dispatch(toggleClientDrawer());
|
||||
drawerClosed();
|
||||
client = null;
|
||||
form.resetFields();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
||||
{client ? t('updateClientDrawerTitle') : t('createClientDrawerTitle')}
|
||||
</Typography.Text>
|
||||
}
|
||||
open={isClientDrawerOpen}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('nameLabel')}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('nameRequiredError'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('namePlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" style={{ width: '100%' }} htmlType="submit">
|
||||
{client ? t('updateButton') : t('createButton')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientDrawer;
|
||||
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ExclamationCircleFilled,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Table,
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
deleteClient,
|
||||
fetchClients,
|
||||
toggleClientDrawer,
|
||||
} from '@features/settings/client/clientSlice';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { IClientViewModel } from '@/types/client.types';
|
||||
import PinRouteToNavbarButton from '@components/PinRouteToNavbarButton';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
|
||||
import ClientDrawer from './client-drawer';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
const ClientsSettings: React.FC = () => {
|
||||
const { t } = useTranslation('settings/clients');
|
||||
const { clients } = useAppSelector(state => state.clientReducer);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useDocumentTitle('Manage Clients');
|
||||
|
||||
const [hoverRow, setHoverRow] = useState<string | null>(null);
|
||||
const [selectedClient, setSelectedClient] = useState<IClientViewModel | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
field: 'name',
|
||||
order: 'desc',
|
||||
});
|
||||
|
||||
const getClients = useMemo(() => {
|
||||
return () => {
|
||||
const params = {
|
||||
index: pagination.current,
|
||||
size: pagination.pageSize,
|
||||
field: pagination.field,
|
||||
order: pagination.order,
|
||||
search: searchQuery,
|
||||
};
|
||||
dispatch(fetchClients(params));
|
||||
};
|
||||
}, [pagination, searchQuery, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
getClients();
|
||||
}, [searchQuery]);
|
||||
|
||||
const handleClientSelect = (record: IClientViewModel) => {
|
||||
setSelectedClient(record);
|
||||
dispatch(toggleClientDrawer());
|
||||
};
|
||||
|
||||
const deleteClientHandler = async (id: string | undefined) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await dispatch(deleteClient(id)).unwrap();
|
||||
getClients();
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete client:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableProps['columns'] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'name',
|
||||
sorter: true,
|
||||
title: t('nameColumn'),
|
||||
onCell: record => ({
|
||||
onClick: () => handleClientSelect(record),
|
||||
}),
|
||||
render: (record: IClientViewModel) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'project',
|
||||
title: t('projectColumn'),
|
||||
onCell: record => ({
|
||||
onClick: () => handleClientSelect(record),
|
||||
}),
|
||||
render: (record: IClientViewModel) =>
|
||||
record.projects_count ? (
|
||||
<Typography.Text>{record.projects_count}</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">{t('noProjectsAvailable')}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actionBtns',
|
||||
width: 80,
|
||||
render: (record: IClientViewModel) =>
|
||||
hoverRow === record.id && (
|
||||
<Flex gap={8} style={{ padding: 0 }}>
|
||||
<Tooltip title="Edit">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => handleClientSelect(record)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title={t('deleteConfirmationTitle')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText={t('deleteConfirmationOk')}
|
||||
cancelText={t('deleteConfirmationCancel')}
|
||||
onConfirm={() => deleteClientHandler(record.id)}
|
||||
>
|
||||
<Tooltip title="Delete">
|
||||
<Button shape="default" icon={<DeleteOutlined />} size="small" onClick={() => deleteClientHandler(record.id)} />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
],
|
||||
[hoverRow, t, dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{ width: '100%' }}
|
||||
title={
|
||||
<Flex justify="flex-end">
|
||||
<Flex gap={8} align="center" justify="flex-end" style={{ width: '100%', maxWidth: 400 }}>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.currentTarget.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
style={{ maxWidth: 232 }}
|
||||
suffix={<SearchOutlined />}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
dispatch(toggleClientDrawer());
|
||||
setSelectedClient(null);
|
||||
}}
|
||||
>
|
||||
{t('createClient')}
|
||||
</Button>
|
||||
<Tooltip title={t('pinTooltip')} trigger={'hover'}>
|
||||
<PinRouteToNavbarButton
|
||||
name="clients"
|
||||
path="/worklenz/settings/clients"
|
||||
adminOnly={true}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={clients.data}
|
||||
columns={columns}
|
||||
rowKey={record => record.id}
|
||||
onRow={record => ({
|
||||
onMouseEnter: () => setHoverRow(record.id),
|
||||
})}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE,
|
||||
}}
|
||||
/>
|
||||
<ClientDrawer
|
||||
client={selectedClient}
|
||||
drawerClosed={() => {
|
||||
setSelectedClient(null);
|
||||
getClients();
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientsSettings;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Button, Drawer, Form, Input, message, Typography } from 'antd';
|
||||
import { useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
|
||||
|
||||
type JobTitleDrawerProps = {
|
||||
drawerOpen: boolean;
|
||||
jobTitleId: string | null;
|
||||
drawerClosed: () => void;
|
||||
};
|
||||
|
||||
const JobTitleDrawer = ({
|
||||
drawerOpen = false,
|
||||
jobTitleId = null,
|
||||
drawerClosed,
|
||||
}: JobTitleDrawerProps) => {
|
||||
const { t } = useTranslation('settings/job-titles');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
if (jobTitleId) {
|
||||
getJobTitleById(jobTitleId);
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [jobTitleId, form]);
|
||||
|
||||
const getJobTitleById = async (id: string) => {
|
||||
try {
|
||||
const response = await jobTitlesApiService.getJobTitleById(id);
|
||||
if (response.done) {
|
||||
form.setFieldsValue({ name: response.body.name });
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(t('fetchJobTitleErrorMessage'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleFormSubmit = async (values: { name: string }) => {
|
||||
try {
|
||||
if (jobTitleId) {
|
||||
const response = await jobTitlesApiService.updateJobTitle(jobTitleId, {
|
||||
name: values.name,
|
||||
});
|
||||
if (response.done) {
|
||||
drawerClosed();
|
||||
}
|
||||
} else {
|
||||
const response = await jobTitlesApiService.createJobTitle({ name: values.name });
|
||||
if (response.done) {
|
||||
drawerClosed();
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
message.error(jobTitleId ? t('updateJobTitleErrorMessage') : t('createJobTitleErrorMessage'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
form.resetFields();
|
||||
drawerClosed();
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={
|
||||
<Typography.Text style={{ fontWeight: 500, fontSize: 16 }}>
|
||||
{jobTitleId ? t('updateJobTitleDrawerTitle') : t('createJobTitleDrawerTitle')}
|
||||
</Typography.Text>
|
||||
}
|
||||
open={drawerOpen}
|
||||
onClose={handleClose}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleFormSubmit}>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('nameLabel')}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('nameRequiredError'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input placeholder={t('namePlaceholder')} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item>
|
||||
<Button type="primary" style={{ width: '100%' }} htmlType="submit">
|
||||
{jobTitleId ? t('updateButton') : t('createButton')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobTitleDrawer;
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ExclamationCircleFilled,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
|
||||
import { DEFAULT_PAGE_SIZE } from '@/shared/constants';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { IJobTitle, IJobTitlesViewModel } from '@/types/job.types';
|
||||
import { deleteJobTitle } from '@features/settings/job/jobSlice';
|
||||
import PinRouteToNavbarButton from '@components/PinRouteToNavbarButton';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Table,
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import JobTitleDrawer from './job-titles-drawer';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
interface PaginationType {
|
||||
current: number;
|
||||
pageSize: number;
|
||||
field: string;
|
||||
order: string;
|
||||
total: number;
|
||||
pageSizeOptions: string[];
|
||||
size: 'small' | 'default';
|
||||
}
|
||||
|
||||
const JobTitlesSettings = () => {
|
||||
const { t } = useTranslation('settings/job-titles');
|
||||
const dispatch = useAppDispatch();
|
||||
useDocumentTitle('Manage Job Titles');
|
||||
|
||||
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
|
||||
const [showDrawer, setShowDrawer] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [jobTitles, setJobTitles] = useState<IJobTitlesViewModel>({});
|
||||
const [pagination, setPagination] = useState<PaginationType>({
|
||||
current: 1,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
field: 'name',
|
||||
order: 'desc',
|
||||
total: 0,
|
||||
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
|
||||
size: 'small',
|
||||
});
|
||||
|
||||
const getJobTitles = useMemo(() => {
|
||||
return async () => {
|
||||
const response = await jobTitlesApiService.getJobTitles(
|
||||
pagination.current,
|
||||
pagination.pageSize,
|
||||
pagination.field,
|
||||
pagination.order,
|
||||
searchQuery
|
||||
);
|
||||
if (response.done) {
|
||||
setJobTitles(response.body);
|
||||
setPagination(prev => ({ ...prev, total: response.body.total || 0 }));
|
||||
}
|
||||
};
|
||||
}, [pagination.current, pagination.pageSize, pagination.field, pagination.order, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
getJobTitles();
|
||||
}, [getJobTitles]);
|
||||
|
||||
const handleEditClick = (id: string) => {
|
||||
setSelectedJobId(id);
|
||||
setShowDrawer(true);
|
||||
};
|
||||
|
||||
const handleCreateClick = () => {
|
||||
setSelectedJobId(null);
|
||||
setShowDrawer(true);
|
||||
};
|
||||
|
||||
const handleDrawerClose = () => {
|
||||
setSelectedJobId(null);
|
||||
setShowDrawer(false);
|
||||
getJobTitles();
|
||||
};
|
||||
|
||||
const deleteJobTitle = async (id: string) => {
|
||||
if (!id) return;
|
||||
try {
|
||||
const res = await jobTitlesApiService.deleteJobTitle(id);
|
||||
if (res.done) {
|
||||
getJobTitles();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete job title:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableProps['columns'] = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'jobTitle',
|
||||
title: t('nameColumn'),
|
||||
sorter: true,
|
||||
onCell: record => ({
|
||||
onClick: () => handleEditClick(record.id),
|
||||
}),
|
||||
render: (record: IJobTitle) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'actionBtns',
|
||||
width: 80,
|
||||
render: (record: IJobTitle) => (
|
||||
<Flex gap={8} style={{ padding: 0 }}>
|
||||
<Tooltip title="Edit">
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => record.id && handleEditClick(record.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Popconfirm
|
||||
title={t('deleteConfirmationTitle')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText={t('deleteConfirmationOk')}
|
||||
cancelText={t('deleteConfirmationCancel')}
|
||||
onConfirm={() => record.id && deleteJobTitle(record.id)}
|
||||
>
|
||||
<Tooltip title="Delete">
|
||||
<Button shape="default" icon={<DeleteOutlined />} size="small" />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
],
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleTableChange = (newPagination: any, _filters: any, sorter: any) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: newPagination.current,
|
||||
pageSize: newPagination.pageSize,
|
||||
field: sorter.field || 'name',
|
||||
order: sorter.order === 'ascend' ? 'asc' : 'desc',
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{ width: '100%' }}
|
||||
title={
|
||||
<Flex justify="flex-end">
|
||||
<Flex gap={8} align="center" justify="flex-end" style={{ width: '100%', maxWidth: 400 }}>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.currentTarget.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
style={{ maxWidth: 232 }}
|
||||
suffix={<SearchOutlined />}
|
||||
/>
|
||||
<Button type="primary" onClick={handleCreateClick}>
|
||||
{t('createJobTitleButton')}
|
||||
</Button>
|
||||
|
||||
<Tooltip title={t('pinTooltip')} trigger={'hover'}>
|
||||
<PinRouteToNavbarButton
|
||||
name="jobTitles"
|
||||
path="/worklenz/settings/job-titles"
|
||||
adminOnly
|
||||
/>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
dataSource={jobTitles.data}
|
||||
size="small"
|
||||
columns={columns}
|
||||
rowKey={(record: IJobTitle) => record.id!}
|
||||
pagination={pagination}
|
||||
onChange={handleTableChange}
|
||||
/>
|
||||
<JobTitleDrawer
|
||||
drawerOpen={showDrawer}
|
||||
jobTitleId={selectedJobId}
|
||||
drawerClosed={handleDrawerClose}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default JobTitlesSettings;
|
||||
140
worklenz-frontend/src/pages/settings/labels/labels-settings.tsx
Normal file
140
worklenz-frontend/src/pages/settings/labels/labels-settings.tsx
Normal file
@@ -0,0 +1,140 @@
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Table,
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
|
||||
import PinRouteToNavbarButton from '../../../components/PinRouteToNavbarButton';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DeleteOutlined, ExclamationCircleFilled, SearchOutlined } from '@ant-design/icons';
|
||||
import { ITaskLabel } from '@/types/label.type';
|
||||
import { labelsApiService } from '@/api/taskAttributes/labels/labels.api.service';
|
||||
import CustomColorLabel from '@components/task-list-common/labelsSelector/custom-color-label';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
const LabelsSettings = () => {
|
||||
const { t } = useTranslation('settings/labels');
|
||||
useDocumentTitle('Manage Labels');
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [labels, setLabels] = useState<ITaskLabel[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const filteredData = useMemo(
|
||||
() =>
|
||||
labels.filter(record =>
|
||||
Object.values(record).some(value =>
|
||||
value?.toString().toLowerCase().includes(searchQuery.toLowerCase())
|
||||
)
|
||||
),
|
||||
[labels, searchQuery]
|
||||
);
|
||||
|
||||
const getLabels = useMemo(() => {
|
||||
setLoading(true);
|
||||
return async () => {
|
||||
const response = await labelsApiService.getLabels();
|
||||
if (response.done) {
|
||||
setLabels(response.body as ITaskLabel[]);
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
getLabels();
|
||||
}, [getLabels]);
|
||||
|
||||
const deleteLabel = async (id: string) => {
|
||||
try {
|
||||
const response = await labelsApiService.deleteById(id);
|
||||
if (response.done) {
|
||||
getLabels();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete label:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// table columns
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'label',
|
||||
title: t('labelColumn'),
|
||||
render: (record: ITaskLabel) => <CustomColorLabel label={record} />,
|
||||
},
|
||||
{
|
||||
key: 'associatedTask',
|
||||
title: t('associatedTaskColumn'),
|
||||
render: (record: ITaskLabel) => <Typography.Text>{record.usage}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'actionBtns',
|
||||
width: 60,
|
||||
render: (record: ITaskLabel) => (
|
||||
<div className="action-button opacity-0 transition-opacity duration-200">
|
||||
<Popconfirm
|
||||
title="Are you sure you want to delete this?"
|
||||
icon={<ExclamationCircleFilled style={{ color: '#ff9800' }} />}
|
||||
okText="Delete"
|
||||
cancelText="Cancel"
|
||||
onConfirm={() => deleteLabel(record.id!)}
|
||||
>
|
||||
<Button shape="default" icon={<DeleteOutlined />} size="small" />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Card
|
||||
style={{ width: '100%' }}
|
||||
title={
|
||||
<Flex justify="flex-end">
|
||||
<Flex gap={8} align="center" justify="flex-end" style={{ width: '100%', maxWidth: 400 }}>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
style={{ maxWidth: 232 }}
|
||||
suffix={<SearchOutlined />}
|
||||
/>
|
||||
|
||||
<Tooltip title={t('pinTooltip')} trigger={'hover'}>
|
||||
{/* this button pin this route to navbar */}
|
||||
<PinRouteToNavbarButton name="labels" path="/worklenz/settings/labels" />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Flex>
|
||||
}
|
||||
>
|
||||
<Table
|
||||
locale={{
|
||||
emptyText: <Typography.Text>{t('emptyText')}</Typography.Text>,
|
||||
}}
|
||||
loading={loading}
|
||||
className="custom-two-colors-row-table"
|
||||
dataSource={filteredData}
|
||||
columns={columns}
|
||||
rowKey={record => record.id!}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 20,
|
||||
pageSizeOptions: ['5', '10', '15', '20', '50', '100'],
|
||||
size: 'small',
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelsSettings;
|
||||
@@ -0,0 +1,152 @@
|
||||
import { Button, Card, Flex, Form, Select, Skeleton, Typography } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { ILanguageType, Language, setLanguage } from '@/features/i18n/localesSlice';
|
||||
import {
|
||||
evt_settings_language_and_region_visit,
|
||||
evt_settings_language_changed,
|
||||
} from '@/shared/worklenz-analytics-events';
|
||||
import { profileSettingsApiService } from '@/api/settings/profile/profile-settings.api.service';
|
||||
import { timezonesApiService } from '@/api/settings/language-timezones/language-timezones-api.service';
|
||||
import { ITimezone } from '@/types/settings/timezone.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { authApiService } from '@/api/auth/auth.api.service';
|
||||
import { setSession } from '@/utils/session-helper';
|
||||
import { setUser } from '@/features/user/userSlice';
|
||||
|
||||
const LanguageAndRegionSettings = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('settings/language');
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
const { lng } = useAppSelector(state => state.localesReducer);
|
||||
const [timezones, setTimezones] = useState<ITimezone[]>([]);
|
||||
const [loadingTimezones, setLoadingTimezones] = useState(false);
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
useDocumentTitle('Language & Region');
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_settings_language_and_region_visit);
|
||||
}, [trackMixpanelEvent]);
|
||||
|
||||
const languageOptions: { value: ILanguageType; label: string }[] = [
|
||||
{
|
||||
value: Language.EN,
|
||||
label: 'English',
|
||||
},
|
||||
{
|
||||
value: Language.ES,
|
||||
label: 'Español',
|
||||
},
|
||||
{
|
||||
value: Language.PT,
|
||||
label: 'Português',
|
||||
},
|
||||
];
|
||||
|
||||
const handleLanguageChange = async (values: { language?: ILanguageType; timezone?: string }) => {
|
||||
if (!values.language) return;
|
||||
dispatch(setLanguage(values.language));
|
||||
const res = await timezonesApiService.update(values);
|
||||
if (res.done) {
|
||||
const authorizeResponse = await authApiService.verify();
|
||||
if (authorizeResponse.authenticated) {
|
||||
setSession(authorizeResponse.user);
|
||||
dispatch(setUser(authorizeResponse.user));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onFinish = (values: { language?: ILanguageType; timezone?: string }) => {
|
||||
if (values.language && values.timezone) {
|
||||
handleLanguageChange(values);
|
||||
trackMixpanelEvent(evt_settings_language_changed, { language: values.language });
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTimezones = async () => {
|
||||
try {
|
||||
setLoadingTimezones(true);
|
||||
const res = await timezonesApiService.get();
|
||||
if (res.done) {
|
||||
setTimezones(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching timezones', error);
|
||||
} finally {
|
||||
setLoadingTimezones(false);
|
||||
}
|
||||
};
|
||||
|
||||
const timeZoneOptions = timezones.map(timezone => ({
|
||||
value: timezone.id,
|
||||
label: (
|
||||
<Flex align="center" justify="space-between">
|
||||
<span>{timezone.name}</span>
|
||||
<Typography.Text type="secondary">{timezone.abbrev}</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
}));
|
||||
|
||||
useEffect(() => {
|
||||
fetchTimezones();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card style={{ width: '100%' }}>
|
||||
{!loadingTimezones ? (<Form
|
||||
layout="vertical"
|
||||
style={{ width: '100%', maxWidth: 350 }}
|
||||
initialValues={{
|
||||
language: lng || Language.EN,
|
||||
timezone: currentSession?.timezone,
|
||||
}}
|
||||
onFinish={onFinish}
|
||||
>
|
||||
<Form.Item
|
||||
name="language"
|
||||
label={t('language')}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('language_required'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select options={languageOptions} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="timezone"
|
||||
label={t('time_zone')}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('time_zone_required'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={timeZoneOptions}
|
||||
loading={loadingTimezones}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit">
|
||||
{t('save_changes')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>): (
|
||||
<Skeleton />
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default LanguageAndRegionSettings;
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Card, Checkbox, Divider, Flex, Form, Typography } from 'antd/es';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { INotificationSettings } from '@/types/settings/notifications.types';
|
||||
import { profileSettingsApiService } from '@/api/settings/profile/profile-settings.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
const NotificationsSettings = () => {
|
||||
const { t } = useTranslation('settings/notifications');
|
||||
const [form] = Form.useForm();
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const [notificationsSettings, setNotificationsSettings] = useState<INotificationSettings>({});
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
useDocumentTitle(t('title'));
|
||||
|
||||
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 updateNotificationSettings = async (settings: INotificationSettings) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const res = await profileSettingsApiService.updateNotificationSettings(settings);
|
||||
if (res.done) {
|
||||
fetchNotificationsSettings();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating notifications settings', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleNotificationSetting = async (key: keyof INotificationSettings) => {
|
||||
const newSettings = { ...notificationsSettings, [key]: !notificationsSettings[key] };
|
||||
await updateNotificationSettings(newSettings);
|
||||
if (key === 'popup_notifications_enabled') {
|
||||
askPushPermission();
|
||||
}
|
||||
};
|
||||
|
||||
const askPushPermission = () => {
|
||||
if ('Notification' in window && 'serviceWorker' in navigator && 'PushManager' in window) {
|
||||
if (Notification.permission !== 'granted') {
|
||||
Notification.requestPermission().then(permission => {
|
||||
if (permission === 'granted') {
|
||||
logger.info('Permission granted');
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
logger.error('This browser does not support notification permission.');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchNotificationsSettings();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card style={{ width: '100%' }}>
|
||||
<Flex vertical gap={4}>
|
||||
<Flex gap={8} align="center">
|
||||
<Checkbox
|
||||
disabled={isLoading}
|
||||
checked={notificationsSettings.email_notifications_enabled}
|
||||
onChange={() => toggleNotificationSetting('email_notifications_enabled')}
|
||||
>
|
||||
<Typography.Title level={4} style={{ marginBlockEnd: 0 }}>
|
||||
{t('emailTitle')}
|
||||
</Typography.Title>
|
||||
</Checkbox>
|
||||
</Flex>
|
||||
<Typography.Text
|
||||
style={{ fontSize: 14, color: themeMode === 'dark' ? '#9CA3AF' : '#00000073' }}
|
||||
>
|
||||
{t('emailDescription')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<Divider />
|
||||
<Flex vertical gap={4}>
|
||||
<Flex gap={8} align="center">
|
||||
<Checkbox
|
||||
disabled={isLoading}
|
||||
checked={notificationsSettings.daily_digest_enabled}
|
||||
onChange={() => {
|
||||
toggleNotificationSetting('daily_digest_enabled');
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ marginBlockEnd: 0 }}>
|
||||
{t('dailyDigestTitle')}
|
||||
</Typography.Title>
|
||||
</Checkbox>
|
||||
</Flex>
|
||||
<Typography.Text
|
||||
style={{ fontSize: 14, color: themeMode === 'dark' ? '#9CA3AF' : '#00000073' }}
|
||||
>
|
||||
{t('dailyDigestDescription')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<Divider />
|
||||
<Flex vertical gap={4}>
|
||||
<Flex gap={8} align="center">
|
||||
<Checkbox
|
||||
disabled={isLoading}
|
||||
checked={notificationsSettings.popup_notifications_enabled}
|
||||
onChange={() => {
|
||||
toggleNotificationSetting('popup_notifications_enabled');
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ marginBlockEnd: 0 }}>
|
||||
{t('popupTitle')}
|
||||
</Typography.Title>
|
||||
</Checkbox>
|
||||
</Flex>
|
||||
<Typography.Text
|
||||
style={{ fontSize: 14, color: themeMode === 'dark' ? '#9CA3AF' : '#00000073' }}
|
||||
>
|
||||
{t('popupDescription')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
<Divider />
|
||||
<Flex vertical gap={4}>
|
||||
<Flex gap={8} align="center">
|
||||
<Checkbox
|
||||
disabled={isLoading}
|
||||
checked={notificationsSettings.show_unread_items_count}
|
||||
onChange={() => {
|
||||
toggleNotificationSetting('show_unread_items_count');
|
||||
}}
|
||||
>
|
||||
<Typography.Title level={4} style={{ marginBlockEnd: 0 }}>
|
||||
{t('unreadItemsTitle')}
|
||||
</Typography.Title>
|
||||
</Checkbox>
|
||||
</Flex>
|
||||
<Typography.Text
|
||||
style={{ fontSize: 14, color: themeMode === 'dark' ? '#9CA3AF' : '#00000073' }}
|
||||
>
|
||||
{t('unreadItemsDescription')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotificationsSettings;
|
||||
@@ -0,0 +1,11 @@
|
||||
.ant-upload-select-picture-card {
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
margin-right: 8px;
|
||||
margin-bottom: 8px;
|
||||
text-align: center;
|
||||
vertical-align: top;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
import { LoadingOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Form,
|
||||
GetProp,
|
||||
Input,
|
||||
Tooltip,
|
||||
Typography,
|
||||
UploadProps,
|
||||
Spin,
|
||||
Skeleton,
|
||||
} from 'antd';
|
||||
import { useEffect, useState, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { changeUserName, setUser } from '@features/user/userSlice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import {
|
||||
evt_settings_profile_visit,
|
||||
evt_settings_profile_avatar_upload,
|
||||
evt_settings_profile_name_change,
|
||||
evt_settings_profile_picture_update,
|
||||
} from '@/shared/worklenz-analytics-events';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { getBase64 } from '@/utils/file-utils';
|
||||
|
||||
import './profile-settings.css';
|
||||
import { profileSettingsApiService } from '@/api/settings/profile/profile-settings.api.service';
|
||||
import taskAttachmentsApiService from '@/api/tasks/task-attachments.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { setSession } from '@/utils/session-helper';
|
||||
import { authApiService } from '@/api/auth/auth.api.service';
|
||||
|
||||
const ProfileSettings = () => {
|
||||
const { t } = useTranslation('settings/profile');
|
||||
const dispatch = useAppDispatch();
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [updating, setUpdating] = useState(false);
|
||||
|
||||
const [imageUrl, setImageUrl] = useState<string>();
|
||||
const [form] = Form.useForm();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useDocumentTitle(t('title') || 'Profile Settings');
|
||||
|
||||
useEffect(() => {
|
||||
trackMixpanelEvent(evt_settings_profile_visit);
|
||||
}, [trackMixpanelEvent]);
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (uploading || !event.target.files || event.target.files.length === 0) return;
|
||||
|
||||
const file = event.target.files[0];
|
||||
setUploading(true);
|
||||
|
||||
try {
|
||||
const base64 = await getBase64(file);
|
||||
const res = await taskAttachmentsApiService.createAvatarAttachment({
|
||||
file: base64 as string,
|
||||
file_name: file.name,
|
||||
size: file.size,
|
||||
});
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_settings_profile_picture_update);
|
||||
const authorizeResponse = await authApiService.verify();
|
||||
if (authorizeResponse.authenticated) {
|
||||
setSession(authorizeResponse.user);
|
||||
dispatch(setUser(authorizeResponse.user));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error uploading avatar', e);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
|
||||
// Reset file input
|
||||
const dt = new DataTransfer();
|
||||
event.target.files = dt.files;
|
||||
};
|
||||
|
||||
const triggerFileInput = () => {
|
||||
if (!uploading) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
};
|
||||
|
||||
const avatarPreview = (
|
||||
<div
|
||||
className="avatar-uploader ant-upload-select-picture-card"
|
||||
onClick={triggerFileInput}
|
||||
style={{
|
||||
width: '104px',
|
||||
height: '104px',
|
||||
cursor: uploading ? 'wait' : 'pointer',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{uploading && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
zIndex: 1,
|
||||
borderRadius: '4px',
|
||||
}}
|
||||
>
|
||||
<Spin indicator={<LoadingOutlined style={{ fontSize: 24, color: 'white' }} spin />} />
|
||||
</div>
|
||||
)}
|
||||
{loading ? (
|
||||
<LoadingOutlined />
|
||||
) : imageUrl || currentSession?.avatar_url ? (
|
||||
<img
|
||||
src={imageUrl || currentSession?.avatar_url}
|
||||
alt="avatar"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: '4px' }}
|
||||
/>
|
||||
) : (
|
||||
<Flex align="center" justify="center" vertical gap={8} style={{ height: '100%' }}>
|
||||
<PlusOutlined />
|
||||
<Typography.Text>{t('upload')}</Typography.Text>
|
||||
</Flex>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
const handleFormSubmit = async ({ name }: { name: string }) => {
|
||||
if (name === currentSession?.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUpdating(true);
|
||||
try {
|
||||
const res = await profileSettingsApiService.updateProfile({ name });
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_settings_profile_name_change, { newName: name });
|
||||
dispatch(changeUserName(name));
|
||||
// Refresh user session to get updated data
|
||||
const authorizeResponse = await authApiService.verify();
|
||||
if (authorizeResponse.authenticated) {
|
||||
setSession(authorizeResponse.user);
|
||||
dispatch(setUser(authorizeResponse.user));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing name', error);
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={{ width: '100%' }}>
|
||||
{updating ? (
|
||||
<Skeleton />
|
||||
) : (
|
||||
<Form
|
||||
form={form}
|
||||
onFinish={handleFormSubmit}
|
||||
layout="vertical"
|
||||
initialValues={{
|
||||
name: currentSession?.name,
|
||||
email: currentSession?.email,
|
||||
}}
|
||||
style={{ width: '100%', maxWidth: 350 }}
|
||||
>
|
||||
<Form.Item>
|
||||
<Tooltip title={t('avatarTooltip') || 'Click to upload an avatar'} placement="topLeft">
|
||||
{avatarPreview}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png, image/jpg, image/jpeg"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="name"
|
||||
label={t('nameLabel')}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('nameRequiredError'),
|
||||
},
|
||||
{
|
||||
min: 2,
|
||||
message: t('nameMinLengthError') || 'Name must be at least 2 characters',
|
||||
},
|
||||
{
|
||||
max: 50,
|
||||
message: t('nameMaxLengthError') || 'Name cannot exceed 50 characters',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input style={{ borderRadius: 4 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="email"
|
||||
label={t('emailLabel')}
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: t('emailRequiredError'),
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input style={{ borderRadius: 4 }} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={updating}>
|
||||
{t('saveChanges')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<Flex vertical gap={4} style={{ marginTop: 16 }}>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('profileJoinedText', {
|
||||
date: currentSession?.created_at
|
||||
? new Date(currentSession.created_at).toLocaleDateString()
|
||||
: '',
|
||||
})}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('profileLastUpdatedText', {
|
||||
date: currentSession?.updated_at
|
||||
? new Date(currentSession.updated_at).toLocaleDateString()
|
||||
: '',
|
||||
})}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProfileSettings;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Button, Card, Popconfirm, Table, TableProps, Tooltip, Typography } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { projectTemplatesApiService } from '@/api/project-templates/project-templates.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { ICustomTemplate } from '@/types/project-templates/project-templates.types';
|
||||
|
||||
const ProjectTemplatesSettings = () => {
|
||||
const { t } = useTranslation('settings/project-templates');
|
||||
|
||||
const [projectTemplates, setProjectTemplates] = useState<ICustomTemplate[]>([]);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useDocumentTitle('Project Templates');
|
||||
|
||||
const fetchProjectTemplates = async () => {
|
||||
try {
|
||||
const response = await projectTemplatesApiService.getCustomTemplates();
|
||||
setProjectTemplates(response.body);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch project templates:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteProjectTemplate = async (id: string) => {
|
||||
try {
|
||||
const res = await projectTemplatesApiService.deleteCustomTemplate(id);
|
||||
if (res.done) {
|
||||
fetchProjectTemplates();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete project template:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableProps<ICustomTemplate>['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: t('nameColumn'),
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
key: 'button',
|
||||
render: record => (
|
||||
<div
|
||||
style={{ display: 'flex', gap: '10px', justifyContent: 'right' }}
|
||||
className="button-visibilty"
|
||||
>
|
||||
<Tooltip title={t('editToolTip')}>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() =>
|
||||
navigate(`/worklenz/settings/project-templates/edit/${record.id}/${record.name}`)
|
||||
}
|
||||
>
|
||||
<EditOutlined />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('deleteToolTip')}>
|
||||
<Popconfirm
|
||||
title={
|
||||
<Typography.Text style={{ fontWeight: 400 }}>{t('confirmText')}</Typography.Text>
|
||||
}
|
||||
okText={t('okText')}
|
||||
cancelText={t('cancelText')}
|
||||
onConfirm={() => deleteProjectTemplate(record.id)}
|
||||
>
|
||||
<Button size="small">
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
fetchProjectTemplates();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card style={{ width: '100%' }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={projectTemplates}
|
||||
size="small"
|
||||
pagination={{ size: 'small' }}
|
||||
rowClassName={(_, index) =>
|
||||
`no-border-row ${index % 2 === 0 ? '' : themeMode === 'dark' ? 'dark-alternate-row-color' : 'alternate-row-color'}`
|
||||
}
|
||||
rowKey="id"
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectTemplatesSettings;
|
||||
@@ -0,0 +1,104 @@
|
||||
import { Button, Flex, Select, Typography } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import StatusGroupTables from '../../../projects/project-view-1/taskList/statusTables/StatusGroupTables';
|
||||
import { TaskType } from '../../../../types/task.types';
|
||||
import { useAppSelector } from '../../../../hooks/useAppSelector';
|
||||
import { PageHeader } from '@ant-design/pro-components';
|
||||
import { ArrowLeftOutlined, CaretDownFilled } from '@ant-design/icons';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import SearchDropdown from '../../../projects/project-view-1/taskList/taskListFilters/SearchDropdown';
|
||||
import { useSelectedProject } from '../../../../hooks/useSelectedProject';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toggleDrawer as togglePhaseDrawer } from '../../../../features/projects/singleProject/phase/phases.slice';
|
||||
import { toggleDrawer } from '../../../../features/projects/status/StatusSlice';
|
||||
import { useAppDispatch } from '../../../../hooks/useAppDispatch';
|
||||
import React from 'react';
|
||||
|
||||
const PhaseDrawer = React.lazy(() => import('@features/projects/singleProject/phase/PhaseDrawer'));
|
||||
const StatusDrawer = React.lazy(
|
||||
() => import('@/components/project-task-filters/create-status-drawer/create-status-drawer')
|
||||
);
|
||||
|
||||
const ProjectTemplateEditView = () => {
|
||||
const dataSource: TaskType[] = useAppSelector(state => state.taskReducer.tasks);
|
||||
const dispatch = useAppDispatch();
|
||||
const navigate = useNavigate();
|
||||
const { templateId, templateName } = useParams();
|
||||
type GroupTypes = 'status' | 'priority' | 'phase';
|
||||
|
||||
const [activeGroup, setActiveGroup] = useState<GroupTypes>('status');
|
||||
|
||||
const handleChange = (value: string) => {
|
||||
setActiveGroup(value as GroupTypes);
|
||||
};
|
||||
|
||||
const { t } = useTranslation('task-list-filters');
|
||||
|
||||
// get selected project from useSelectedPro
|
||||
const selectedProject = useSelectedProject();
|
||||
|
||||
//get phases details from phases slice
|
||||
const phase =
|
||||
useAppSelector(state => state.phaseReducer.phaseList).find(
|
||||
phase => phase.projectId === selectedProject?.id
|
||||
) || null;
|
||||
|
||||
const groupDropdownMenuItems = [
|
||||
{ key: 'status', value: 'status', label: t('statusText') },
|
||||
{ key: 'priority', value: 'priority', label: t('priorityText') },
|
||||
{
|
||||
key: 'phase',
|
||||
value: 'phase',
|
||||
label: phase ? phase?.phase : t('phaseText'),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div style={{ marginBlock: 80, minHeight: '80vh' }}>
|
||||
<PageHeader
|
||||
className="site-page-header"
|
||||
title={
|
||||
<Flex gap={8} align="center">
|
||||
<ArrowLeftOutlined style={{ fontSize: 16 }} onClick={() => navigate(-1)} />
|
||||
<Typography.Title level={4} style={{ marginBlockEnd: 0, marginInlineStart: 12 }}>
|
||||
{templateName}
|
||||
</Typography.Title>
|
||||
</Flex>
|
||||
}
|
||||
style={{ padding: 0, marginBlockEnd: 24 }}
|
||||
/>
|
||||
<Flex vertical gap={16}>
|
||||
<Flex gap={8} wrap={'wrap'}>
|
||||
<SearchDropdown />
|
||||
<Flex align="center" gap={4} style={{ marginInlineStart: 12 }}>
|
||||
{t('groupByText')}:
|
||||
<Select
|
||||
defaultValue={'status'}
|
||||
options={groupDropdownMenuItems}
|
||||
onChange={handleChange}
|
||||
suffixIcon={<CaretDownFilled />}
|
||||
/>
|
||||
</Flex>
|
||||
{activeGroup === 'phase' ? (
|
||||
<Button type="primary" onClick={() => dispatch(togglePhaseDrawer())}>
|
||||
{t('addPhaseButton')}
|
||||
</Button>
|
||||
) : activeGroup === 'status' ? (
|
||||
<Button type="primary" onClick={() => dispatch(toggleDrawer())}>
|
||||
{t('addStatusButton')}
|
||||
</Button>
|
||||
) : (
|
||||
''
|
||||
)}
|
||||
</Flex>
|
||||
|
||||
<StatusGroupTables datasource={dataSource} />
|
||||
{/* <PriorityGroupTables datasource={dataSource} /> */}
|
||||
</Flex>
|
||||
{/* phase drawer */}
|
||||
<PhaseDrawer />
|
||||
{/* status drawer */}
|
||||
<StatusDrawer />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default ProjectTemplateEditView;
|
||||
@@ -0,0 +1,70 @@
|
||||
import { RightOutlined } from '@ant-design/icons';
|
||||
import { ConfigProvider, Flex, Menu, MenuProps } from 'antd';
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { settingsItems, getAccessibleSettings } from '@/lib/settings/settings-constants';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
|
||||
const SettingSidebar: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const { t } = useTranslation('settings/sidebar');
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||
|
||||
const getCurrentActiveKey = () => {
|
||||
const pathParts = location.pathname.split('/worklenz/settings/');
|
||||
if (pathParts.length < 2) return '';
|
||||
return pathParts[1].split('/')[0];
|
||||
};
|
||||
|
||||
// Get accessible settings based on user role
|
||||
const accessibleSettings = getAccessibleSettings(isOwnerOrAdmin);
|
||||
|
||||
const items: Required<MenuProps>['items'] = accessibleSettings
|
||||
.map(item => {
|
||||
if (currentSession?.is_google && item.key === 'change-password') {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
key: item.key,
|
||||
label: (
|
||||
<Flex gap={8} justify="space-between" align="center">
|
||||
<Flex gap={8} align="center">
|
||||
{item.icon}
|
||||
<Link to={`/worklenz/settings/${item.endpoint}`}>{t(item.name)}</Link>
|
||||
</Flex>
|
||||
<RightOutlined style={{ fontSize: 12 }} />
|
||||
</Flex>
|
||||
),
|
||||
};
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Menu: {
|
||||
itemHoverBg: colors.transparent,
|
||||
itemHoverColor: colors.skyBlue,
|
||||
borderRadius: 12,
|
||||
itemMarginBlock: 4,
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
items={items}
|
||||
selectedKeys={[getCurrentActiveKey()]}
|
||||
mode="vertical"
|
||||
style={{
|
||||
border: 'none',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default SettingSidebar;
|
||||
@@ -0,0 +1,24 @@
|
||||
.no-border-row td {
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.alternate-row-color td {
|
||||
background-color: #f8f7f9;
|
||||
}
|
||||
|
||||
.no-border-row .button-visibilty {
|
||||
visibility: hidden;
|
||||
transition:
|
||||
visibility 0s,
|
||||
opacity ease-in-out;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.no-border-row:hover .button-visibilty {
|
||||
visibility: visible;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dark-alternate-row-color {
|
||||
background-color: #4e4e4e10;
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Button, Card, Popconfirm, Table, TableProps, Tooltip, Typography } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import './task-templates-settings.css';
|
||||
import { DeleteOutlined, EditOutlined } from '@ant-design/icons';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { ITaskTemplatesGetResponse } from '@/types/settings/task-templates.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { taskTemplatesApiService } from '@/api/task-templates/task-templates.api.service';
|
||||
import { calculateTimeGap } from '@/utils/calculate-time-gap';
|
||||
|
||||
const TaskTemplatesSettings = () => {
|
||||
const { t } = useTranslation('settings/task-templates');
|
||||
const dispatch = useAppDispatch();
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const [taskTemplates, setTaskTemplates] = useState<ITaskTemplatesGetResponse[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [templateId, setTemplateId] = useState<string | null>(null);
|
||||
const [showDrawer, setShowDrawer] = useState(false);
|
||||
useDocumentTitle('Task Templates');
|
||||
|
||||
const fetchTaskTemplates = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await taskTemplatesApiService.getTemplates();
|
||||
setTaskTemplates(res.body);
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch task templates:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTaskTemplates();
|
||||
}, []);
|
||||
|
||||
const handleDeleteTemplate = async (id: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await taskTemplatesApiService.deleteTemplate(id);
|
||||
await fetchTaskTemplates();
|
||||
} catch (error) {
|
||||
logger.error('Failed to delete task template:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableProps<ITaskTemplatesGetResponse>['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: t('nameColumn'),
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
key: 'created',
|
||||
title: t('createdColumn'),
|
||||
dataIndex: 'created_at',
|
||||
render: (date: string) => calculateTimeGap(date),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
width: 120,
|
||||
render: record => (
|
||||
<div
|
||||
style={{ display: 'flex', gap: '10px', justifyContent: 'right' }}
|
||||
className="button-visibilty"
|
||||
>
|
||||
<Tooltip title={t('editToolTip')}>
|
||||
<Button size="small" onClick={() => setTemplateId(record.id)}>
|
||||
<EditOutlined />
|
||||
</Button>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('deleteToolTip')}>
|
||||
<Popconfirm
|
||||
title={
|
||||
<Typography.Text style={{ fontWeight: 400 }}>{t('confirmText')}</Typography.Text>
|
||||
}
|
||||
okText={t('okText')}
|
||||
cancelText={t('cancelText')}
|
||||
onConfirm={() => handleDeleteTemplate(record.id)}
|
||||
>
|
||||
<Button size="small">
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (templateId) {
|
||||
setShowDrawer(true);
|
||||
}
|
||||
}, [templateId]);
|
||||
|
||||
const handleCloseDrawer = () => {
|
||||
setTemplateId(null);
|
||||
setShowDrawer(false);
|
||||
fetchTaskTemplates();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card style={{ width: '100%' }}>
|
||||
<Table
|
||||
loading={isLoading}
|
||||
size="small"
|
||||
pagination={{
|
||||
size: 'small',
|
||||
showSizeChanger: true,
|
||||
showTotal: (total) => t('totalItems', { total })
|
||||
}}
|
||||
columns={columns}
|
||||
dataSource={taskTemplates}
|
||||
rowKey="id"
|
||||
rowClassName={(_, index) =>
|
||||
`no-border-row ${index % 2 === 0 ? '' : themeMode === 'dark' ? 'dark-alternate-row-color' : 'alternate-row-color'}`
|
||||
}
|
||||
/>
|
||||
<TaskTemplateDrawer
|
||||
showDrawer={showDrawer}
|
||||
selectedTemplateId={templateId}
|
||||
onClose={handleCloseDrawer}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskTemplatesSettings;
|
||||
@@ -0,0 +1,353 @@
|
||||
import {
|
||||
DeleteOutlined,
|
||||
EditOutlined,
|
||||
ExclamationCircleFilled,
|
||||
SearchOutlined,
|
||||
SyncOutlined,
|
||||
UserSwitchOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Flex,
|
||||
Input,
|
||||
Popconfirm,
|
||||
Table,
|
||||
TableProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import UpdateMemberDrawer from '@/components/settings/update-member-drawer';
|
||||
import {
|
||||
toggleInviteMemberDrawer,
|
||||
toggleUpdateMemberDrawer,
|
||||
} from '@features/settings/member/memberSlice';
|
||||
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
||||
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
||||
import { DEFAULT_PAGE_SIZE, PAGE_SIZE_OPTIONS } from '@/shared/constants';
|
||||
import { teamMembersApiService } from '@/api/team-members/teamMembers.api.service';
|
||||
import { colors } from '@/styles/colors';
|
||||
|
||||
const TeamMembersSettings = () => {
|
||||
const { t } = useTranslation('settings/team-members');
|
||||
const dispatch = useAppDispatch();
|
||||
const { socket } = useSocket();
|
||||
const refreshTeamMembers = useAppSelector(state => state.memberReducer.refreshTeamMembers); // Listen to refresh flag
|
||||
|
||||
const [model, setModel] = useState<ITeamMembersViewModel>({ total: 0, data: [] });
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [selectedMemberId, setSelectedMemberId] = useState<string | null>(null);
|
||||
const [pagination, setPagination] = useState({
|
||||
current: 1,
|
||||
pageSize: DEFAULT_PAGE_SIZE,
|
||||
field: 'name',
|
||||
order: 'asc',
|
||||
});
|
||||
|
||||
const getTeamMembers = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await teamMembersApiService.get(
|
||||
pagination.current,
|
||||
pagination.pageSize,
|
||||
pagination.field,
|
||||
pagination.order,
|
||||
searchQuery
|
||||
);
|
||||
if (res.done) {
|
||||
setModel(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching team members:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [pagination, searchQuery]);
|
||||
|
||||
const handleStatusChange = async (record: ITeamMemberViewModel) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await teamMembersApiService.toggleMemberActiveStatus(
|
||||
record.id || '',
|
||||
record.active as boolean,
|
||||
record.email || ''
|
||||
);
|
||||
if (res.done) {
|
||||
await getTeamMembers();
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteMember = async (record: ITeamMemberViewModel) => {
|
||||
if (!record.id) return;
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const res = await teamMembersApiService.delete(record.id);
|
||||
if (res.done) {
|
||||
await getTeamMembers();
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRoleUpdate = useCallback((memberId: string, newRoleName: string) => {
|
||||
setModel(prevModel => ({
|
||||
...prevModel,
|
||||
data: prevModel.data?.map(member =>
|
||||
member.id === memberId ? { ...member, role_name: newRoleName } : member
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
setIsLoading(true);
|
||||
getTeamMembers().finally(() => setIsLoading(false));
|
||||
}, [getTeamMembers]);
|
||||
|
||||
const handleMemberClick = useCallback(
|
||||
(memberId: string) => {
|
||||
setSelectedMemberId(memberId);
|
||||
dispatch(toggleUpdateMemberDrawer());
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleTableChange = useCallback((newPagination: any, filters: any, sorter: any) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
current: newPagination.current,
|
||||
pageSize: newPagination.pageSize,
|
||||
field: sorter.field || 'name',
|
||||
order: sorter.order === 'ascend' ? 'asc' : 'desc',
|
||||
}));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (socket) {
|
||||
const handleRoleChange = (data: { memberId: string; role_name: string }) => {
|
||||
handleRoleUpdate(data.memberId, data.role_name);
|
||||
};
|
||||
socket.on(SocketEvents.TEAM_MEMBER_ROLE_CHANGE.toString(), handleRoleChange);
|
||||
return () => {
|
||||
socket.off(SocketEvents.TEAM_MEMBER_ROLE_CHANGE.toString(), handleRoleChange);
|
||||
};
|
||||
}
|
||||
}, [socket, handleRoleUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
handleRefresh();
|
||||
}, [refreshTeamMembers, handleRefresh]);
|
||||
|
||||
useEffect(() => {
|
||||
getTeamMembers();
|
||||
}, [getTeamMembers]);
|
||||
|
||||
const getColor = useCallback((role: string | undefined) => {
|
||||
switch (role?.toLowerCase()) {
|
||||
case 'owner':
|
||||
return colors.skyBlue;
|
||||
case 'member':
|
||||
return colors.lightGray;
|
||||
case 'admin':
|
||||
return colors.yellow;
|
||||
default:
|
||||
return colors.darkGray;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
dataIndex: 'name',
|
||||
title: t('nameColumn'),
|
||||
defaultSortOrder: 'ascend',
|
||||
sorter: true,
|
||||
onCell: (record: ITeamMemberViewModel) => ({
|
||||
onClick: () => handleMemberClick(record.id || ''),
|
||||
style: { cursor: 'pointer' },
|
||||
}),
|
||||
render: (_, record: ITeamMemberViewModel) => (
|
||||
<Typography.Text
|
||||
style={{
|
||||
textTransform: 'capitalize',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Avatar size={28} src={record.avatar_url} style={{ backgroundColor: record.color_code }}>
|
||||
{record.name?.charAt(0)}
|
||||
</Avatar>
|
||||
{record.name}
|
||||
{record.is_online && <Badge color={colors.limeGreen} />}
|
||||
{!record.active && (
|
||||
<Typography.Text style={{ color: colors.yellow }}>
|
||||
{t('deactivatedText')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'projects_count',
|
||||
dataIndex: 'projects_count',
|
||||
title: t('projectsColumn'),
|
||||
sorter: true,
|
||||
onCell: (record: ITeamMemberViewModel) => ({
|
||||
onClick: () => handleMemberClick(record.id || ''),
|
||||
style: { cursor: 'pointer' },
|
||||
}),
|
||||
render: (_, record: ITeamMemberViewModel) => (
|
||||
<Typography.Text>{record.projects_count}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
dataIndex: 'email',
|
||||
title: t('emailColumn'),
|
||||
sorter: true,
|
||||
onCell: (record: ITeamMemberViewModel) => ({
|
||||
onClick: () => handleMemberClick(record.id || ''),
|
||||
style: { cursor: 'pointer' },
|
||||
}),
|
||||
render: (_, record: ITeamMemberViewModel) => (
|
||||
<div>
|
||||
<Typography.Text>{record.email}</Typography.Text>
|
||||
{record.pending_invitation && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12, marginLeft: 8 }}>
|
||||
{t('pendingInvitationText')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'role_name',
|
||||
dataIndex: 'role_name',
|
||||
title: t('teamAccessColumn'),
|
||||
sorter: true,
|
||||
onCell: (record: ITeamMemberViewModel) => ({
|
||||
onClick: () => handleMemberClick(record.id || ''),
|
||||
style: { cursor: 'pointer' },
|
||||
}),
|
||||
render: (_, record: ITeamMemberViewModel) => (
|
||||
<Flex gap={16} align="center">
|
||||
<Typography.Text
|
||||
style={{
|
||||
color: getColor(record.role_name),
|
||||
textTransform: 'capitalize',
|
||||
}}
|
||||
>
|
||||
{record.role_name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actionBtns',
|
||||
width: 120,
|
||||
render: (record: ITeamMemberViewModel) =>
|
||||
record.role_name !== 'owner' && (
|
||||
<Flex gap={8} style={{ padding: 0 }}>
|
||||
<Tooltip title={t('editTooltip')}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => record.id && handleMemberClick(record.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Tooltip title={record.active ? t('deactivateTooltip') : t('activateTooltip')}>
|
||||
<Popconfirm
|
||||
title={t('confirmActivateTitle')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText={t('okText')}
|
||||
cancelText={t('cancelText')}
|
||||
onConfirm={() => handleStatusChange(record)}
|
||||
>
|
||||
<Button size="small" icon={<UserSwitchOutlined />} />
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
<Tooltip title={t('deleteTooltip')}>
|
||||
<Popconfirm
|
||||
title={t('confirmDeleteTitle')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText={t('okText')}
|
||||
cancelText={t('cancelText')}
|
||||
onConfirm={() => record.id && handleDeleteMember(record)}
|
||||
>
|
||||
<Button size="small" icon={<DeleteOutlined />} />
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<Flex align="center" justify="space-between" style={{ marginBlockEnd: 24 }}>
|
||||
<Typography.Title level={4} style={{ marginBlockEnd: 0 }}>
|
||||
{model.total} {model.total !== 1 ? t('membersCountPlural') : t('memberCount')}
|
||||
</Typography.Title>
|
||||
<Flex gap={8} align="center" justify="flex-end" style={{ width: '100%', maxWidth: 400 }}>
|
||||
<Tooltip title={t('pinTooltip')}>
|
||||
<Button shape="circle" icon={<SyncOutlined />} onClick={handleRefresh} />
|
||||
</Tooltip>
|
||||
<Input
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder={t('searchPlaceholder')}
|
||||
style={{ maxWidth: 232 }}
|
||||
suffix={<SearchOutlined />}
|
||||
/>
|
||||
<Button type="primary" onClick={() => dispatch(toggleInviteMemberDrawer())}>
|
||||
{t('addMemberButton')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Card style={{ width: '100%' }}>
|
||||
<Table
|
||||
columns={columns}
|
||||
size="small"
|
||||
dataSource={model.data}
|
||||
rowKey={record => record.id}
|
||||
onChange={handleTableChange}
|
||||
loading={isLoading}
|
||||
pagination={{
|
||||
current: pagination.current,
|
||||
pageSize: pagination.pageSize,
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: DEFAULT_PAGE_SIZE,
|
||||
pageSizeOptions: PAGE_SIZE_OPTIONS,
|
||||
size: 'small',
|
||||
total: model.total,
|
||||
showTotal: (total, range) => `${range[0]}-${range[1]} of ${total} items`,
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
</Card>
|
||||
{createPortal(
|
||||
<UpdateMemberDrawer
|
||||
selectedMemberId={selectedMemberId}
|
||||
onRoleUpdate={handleRoleUpdate}
|
||||
/>,
|
||||
document.body
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamMembersSettings;
|
||||
101
worklenz-frontend/src/pages/settings/teams/teams-settings.tsx
Normal file
101
worklenz-frontend/src/pages/settings/teams/teams-settings.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Button, Card, Flex, Table, TableProps, Tooltip, Typography } from 'antd';
|
||||
import PinRouteToNavbarButton from '@components/PinRouteToNavbarButton';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { durationDateFormat } from '@utils/durationDateFormat';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import EditTeamModal from '@/components/settings/edit-team-name-modal';
|
||||
|
||||
import { fetchTeams } from '@features/teams/teamSlice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useDocumentTitle } from '@/hooks/useDoumentTItle';
|
||||
import { ITeamGetResponse } from '@/types/teams/team.type';
|
||||
|
||||
const TeamsSettings = () => {
|
||||
useDocumentTitle('Teams');
|
||||
|
||||
const [selectedTeam, setSelectedTeam] = useState<ITeamGetResponse | null>(null);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const { teamsList } = useAppSelector(state => state.teamReducer);
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchTeams());
|
||||
}, [dispatch]);
|
||||
|
||||
const columns: TableProps['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
title: 'Name',
|
||||
render: (record: ITeamGetResponse) => <Typography.Text>{record.name}</Typography.Text>,
|
||||
},
|
||||
|
||||
{
|
||||
key: 'created',
|
||||
title: 'Created',
|
||||
render: (record: ITeamGetResponse) => (
|
||||
<Typography.Text>{durationDateFormat(record.created_at)}</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'ownsBy',
|
||||
title: 'Owns By',
|
||||
render: (record: ITeamGetResponse) => <Typography.Text>{record.owns_by}</Typography.Text>,
|
||||
},
|
||||
{
|
||||
key: 'actionBtns',
|
||||
width: 60,
|
||||
render: (record: ITeamGetResponse) => (
|
||||
<Tooltip title="Edit" trigger={'hover'}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => {
|
||||
setSelectedTeam(record);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleCancel = () => {
|
||||
setSelectedTeam(null);
|
||||
setIsModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ width: '100%' }}>
|
||||
<Flex align="center" justify="space-between" style={{ marginBlockEnd: 24 }}>
|
||||
<Typography.Title level={4} style={{ marginBlockEnd: 0 }}>
|
||||
{teamsList.length} Team
|
||||
{teamsList.length !== 1 && 's'}
|
||||
</Typography.Title>
|
||||
|
||||
<Tooltip title={'Click to pin this into the main menu'} trigger={'hover'}>
|
||||
{/* this button pin this route to navbar */}
|
||||
<PinRouteToNavbarButton name="teams" path="/worklenz/settings/teams" />
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
<Card style={{ width: '100%' }}>
|
||||
<Table<ITeamGetResponse>
|
||||
className="custom-two-colors-row-table"
|
||||
columns={columns}
|
||||
dataSource={teamsList}
|
||||
rowKey={record => record.id ?? ''}
|
||||
pagination={{
|
||||
showSizeChanger: true,
|
||||
defaultPageSize: 20,
|
||||
}}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* edit team name modal */}
|
||||
<EditTeamModal team={selectedTeam} isModalOpen={isModalOpen} onCancel={handleCancel} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TeamsSettings;
|
||||
Reference in New Issue
Block a user