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

View File

@@ -0,0 +1,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;

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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