init
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Flex,
|
||||
Input,
|
||||
InputRef,
|
||||
List,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleProjectMemberDrawer } from '../../../features/projects/singleProject/members/projectMembersSlice';
|
||||
import CustomAvatar from '../../CustomAvatar';
|
||||
import { colors } from '../../../styles/colors';
|
||||
import { PlusOutlined, UsergroupAddOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ITaskAssignee } from '@/types/tasks/task.types';
|
||||
|
||||
interface AssigneeSelectorProps {
|
||||
taskId: string | undefined | null;
|
||||
currentAssignees: ITaskAssignee[] | string[];
|
||||
}
|
||||
|
||||
const AssigneeSelector = ({ taskId, currentAssignees }: AssigneeSelectorProps) => {
|
||||
const membersInputRef = useRef<InputRef>(null);
|
||||
// this is for get the current string that type on search bar
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// get members list from members reducer
|
||||
const membersList = [
|
||||
...useAppSelector(state => state.memberReducer.membersList),
|
||||
useAppSelector(state => state.memberReducer.owner),
|
||||
];
|
||||
|
||||
// used useMemo hook for re render the list when searching
|
||||
const filteredMembersData = useMemo(() => {
|
||||
return membersList.filter(member =>
|
||||
member.memberName.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [membersList, searchQuery]);
|
||||
|
||||
// function to handle invite project member drawer
|
||||
const handleInviteProjectMemberDrawer = () => {
|
||||
dispatch(toggleProjectMemberDrawer());
|
||||
};
|
||||
|
||||
// function to focus members input
|
||||
const handleMembersDropdownOpen = (open: boolean) => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
membersInputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
// custom dropdown content
|
||||
const membersDropdownContent = (
|
||||
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
|
||||
<Flex vertical gap={8}>
|
||||
<Input
|
||||
ref={membersInputRef}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.currentTarget.value)}
|
||||
placeholder={t('searchInputPlaceholder')}
|
||||
/>
|
||||
|
||||
<List style={{ padding: 0 }}>
|
||||
{filteredMembersData.length ? (
|
||||
filteredMembersData.map(member => (
|
||||
<List.Item
|
||||
className="custom-list-item"
|
||||
key={member.memberId}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
justifyContent: 'flex-start',
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Checkbox id={member.memberId} onChange={() => {}} />
|
||||
<div>
|
||||
<CustomAvatar avatarName={member.memberName} />
|
||||
</div>
|
||||
<Flex vertical>
|
||||
{member.memberName}
|
||||
|
||||
<Typography.Text
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: colors.lightGray,
|
||||
}}
|
||||
>
|
||||
{member.memberEmail}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</List.Item>
|
||||
))
|
||||
) : (
|
||||
<Empty />
|
||||
)}
|
||||
</List>
|
||||
|
||||
<Divider style={{ marginBlock: 0 }} />
|
||||
|
||||
<Button
|
||||
icon={<UsergroupAddOutlined />}
|
||||
type="text"
|
||||
style={{
|
||||
color: colors.skyBlue,
|
||||
border: 'none',
|
||||
backgroundColor: colors.transparent,
|
||||
width: '100%',
|
||||
}}
|
||||
onClick={handleInviteProjectMemberDrawer}
|
||||
>
|
||||
{t('assigneeSelectorInviteButton')}
|
||||
</Button>
|
||||
|
||||
{/* <Divider style={{ marginBlock: 8 }} />
|
||||
|
||||
<Button type="primary" style={{ alignSelf: 'flex-end' }}>
|
||||
{t('okButton')}
|
||||
</Button> */}
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="custom-dropdown"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => membersDropdownContent}
|
||||
onOpenChange={handleMembersDropdownOpen}
|
||||
>
|
||||
<Button
|
||||
type="dashed"
|
||||
shape="circle"
|
||||
size="small"
|
||||
icon={
|
||||
<PlusOutlined
|
||||
style={{
|
||||
fontSize: 12,
|
||||
width: 22,
|
||||
height: 22,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssigneeSelector;
|
||||
@@ -0,0 +1,252 @@
|
||||
import { Drawer, Tag, Typography, Flex, Table, Button, Tooltip, Skeleton } from 'antd/es';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import {
|
||||
fetchTaskGroups,
|
||||
IGroupBy,
|
||||
setConvertToSubtaskDrawerOpen,
|
||||
updateTaskStatus,
|
||||
} from '@/features/tasks/tasks.slice';
|
||||
import { RightOutlined } from '@ant-design/icons';
|
||||
import CustomSearchbar from '@/components/CustomSearchbar';
|
||||
import { ITaskListConfigV2, tasksApiService } from '@/api/tasks/tasks.api.service';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||
|
||||
const ConvertToSubtaskDrawer = () => {
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const { socket, connected } = useSocket();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const { convertToSubtaskDrawerOpen, groupBy } = useAppSelector(state => state.taskReducer);
|
||||
const selectedTask = useAppSelector(state => state.bulkActionReducer.selectedTasks[0]);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [expandedGroups, setExpandedGroups] = useState<boolean[]>([]);
|
||||
const [converting, setConverting] = useState(false);
|
||||
const [taskGroups, setTaskGroups] = useState<ITaskListGroup[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const fetchTasks = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const config: ITaskListConfigV2 = {
|
||||
id: projectId,
|
||||
group: groupBy,
|
||||
field: null,
|
||||
order: null,
|
||||
search: null,
|
||||
statuses: null,
|
||||
members: null,
|
||||
projects: null,
|
||||
isSubtasksInclude: false,
|
||||
};
|
||||
|
||||
const response = await tasksApiService.getTaskList(config);
|
||||
if (response.done) {
|
||||
setTaskGroups(response.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching tasks:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleGroup = (index: number) => {
|
||||
const newExpanded = [...expandedGroups];
|
||||
newExpanded[index] = !newExpanded[index];
|
||||
setExpandedGroups(newExpanded);
|
||||
};
|
||||
|
||||
const handleStatusChange = (statusId: string) => {
|
||||
if (!selectedTask?.id || !statusId) return;
|
||||
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: selectedTask?.id,
|
||||
status_id: statusId,
|
||||
team_id: currentSession?.team_id,
|
||||
})
|
||||
);
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask?.id);
|
||||
};
|
||||
|
||||
const handlePriorityChange = (priorityId: string) => {
|
||||
if (!selectedTask?.id || !priorityId) return;
|
||||
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: selectedTask?.id,
|
||||
priority_id: priorityId,
|
||||
team_id: currentSession?.team_id,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const convertToSubTask = async (
|
||||
toGroupId: string | undefined,
|
||||
parentTaskId: string | undefined
|
||||
) => {
|
||||
if (!toGroupId || !parentTaskId || !selectedTask?.id || !projectId) return;
|
||||
try {
|
||||
// setConverting(true);
|
||||
// if (groupBy === IGroupBy.STATUS) {
|
||||
// handleStatusChange(toGroupId);
|
||||
// }
|
||||
// if (groupBy === IGroupBy.PRIORITY) {
|
||||
// handlePriorityChange(toGroupId);
|
||||
// }
|
||||
|
||||
const res = await tasksApiService.convertToSubtask(
|
||||
selectedTask?.id,
|
||||
projectId,
|
||||
parentTaskId,
|
||||
groupBy,
|
||||
toGroupId
|
||||
);
|
||||
if (res.done) {
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
fetchTasks();
|
||||
dispatch(setConvertToSubtaskDrawerOpen(false));
|
||||
}
|
||||
setConverting(false);
|
||||
} catch (error) {
|
||||
logger.error('Error converting to subtask:', error);
|
||||
} finally {
|
||||
setConverting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredTasks = useMemo(
|
||||
() =>
|
||||
taskGroups
|
||||
.map(group => ({
|
||||
...group,
|
||||
tasks: group.tasks.filter(task =>
|
||||
task?.name?.toLowerCase().includes(searchText.toLowerCase())
|
||||
),
|
||||
}))
|
||||
.filter(group => group.tasks.length > 0),
|
||||
[searchText, taskGroups]
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={convertToSubtaskDrawerOpen}
|
||||
onClose={() => {
|
||||
dispatch(setConvertToSubtaskDrawerOpen(false));
|
||||
setSearchText('');
|
||||
setTaskGroups([]);
|
||||
}}
|
||||
title={t('contextMenu.convertToSubTask')}
|
||||
width={700}
|
||||
afterOpenChange={() => fetchTasks()}
|
||||
>
|
||||
<Flex vertical gap={12}>
|
||||
<CustomSearchbar
|
||||
searchQuery={searchText}
|
||||
setSearchQuery={setSearchText}
|
||||
placeholderText={t('contextMenu.searchByNameInputPlaceholder')}
|
||||
/>
|
||||
</Flex>
|
||||
{loading ? (
|
||||
<Skeleton active className="mt-4" />
|
||||
) : (
|
||||
filteredTasks.map((item, index) => (
|
||||
<div key={`group-${item.id}`}>
|
||||
<Button
|
||||
key={`group-button-${item.id}`}
|
||||
className="w-full"
|
||||
style={{
|
||||
backgroundColor: item.color_code,
|
||||
border: 'none',
|
||||
borderBottomLeftRadius: expandedGroups[index] ? 0 : 4,
|
||||
borderBottomRightRadius: expandedGroups[index] ? 0 : 4,
|
||||
color: '#000',
|
||||
marginTop: 8,
|
||||
justifyContent: 'flex-start',
|
||||
width: 'auto',
|
||||
}}
|
||||
onClick={() => toggleGroup(index)}
|
||||
>
|
||||
<Flex key={`group-flex-${item.id}`} align="center" gap={8}>
|
||||
<RightOutlined rotate={expandedGroups[index] ? 90 : 0} />
|
||||
<Typography.Text strong>{item.name}</Typography.Text>
|
||||
</Flex>
|
||||
</Button>
|
||||
<div
|
||||
key={`group-content-${item.id}`}
|
||||
style={{
|
||||
borderLeft: `3px solid ${item.color_code}`,
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
maxHeight: expandedGroups[index] ? '2000px' : '0',
|
||||
opacity: expandedGroups[index] ? 1 : 0,
|
||||
overflow: expandedGroups[index] ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<Table
|
||||
key={`group-table-${item.id}`}
|
||||
size="small"
|
||||
columns={[
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'task_key',
|
||||
key: 'task_key',
|
||||
width: 100,
|
||||
className: 'text-center',
|
||||
render: (text: string) => <Tag key={`tag-${text}`}>{text}</Tag>,
|
||||
},
|
||||
{
|
||||
title: 'Task',
|
||||
dataIndex: 'name',
|
||||
key: 'name',
|
||||
render: (text: string) => (
|
||||
<Tooltip title={text}>
|
||||
<Typography.Text
|
||||
style={{
|
||||
width: 520,
|
||||
}}
|
||||
ellipsis={{ tooltip: text }}
|
||||
>
|
||||
{text}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
]}
|
||||
dataSource={item.tasks.filter(
|
||||
task => !task.parent_task_id && selectedTask?.id !== task.id
|
||||
)}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
onRow={record => {
|
||||
return {
|
||||
onClick: () => convertToSubTask(item.id, record.id),
|
||||
style: { height: 38, cursor: 'pointer' },
|
||||
className: 'group even:bg-[#4e4e4e10]',
|
||||
key: `task-row-${record.id}`,
|
||||
};
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConvertToSubtaskDrawer;
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Select, Tag, Tooltip } from 'antd';
|
||||
import { PhaseColorCodes } from '../../../shared/constants';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||
|
||||
const ColorChangedLabel = ({ label }: { label: ITaskLabel | null }) => {
|
||||
// localization
|
||||
const { t } = useTranslation('labelsSettings');
|
||||
|
||||
// color options for the labels
|
||||
const colorsOptions = PhaseColorCodes.map(color => ({
|
||||
key: color,
|
||||
value: color,
|
||||
label: (
|
||||
<Tag
|
||||
color={color}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyItems: 'center',
|
||||
height: 22,
|
||||
width: 'fit-content',
|
||||
}}
|
||||
>
|
||||
{label?.name}
|
||||
</Tag>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Tooltip title={t('colorChangeTooltip')}>
|
||||
<Select
|
||||
key={label?.id}
|
||||
options={colorsOptions}
|
||||
variant="borderless"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyItems: 'center',
|
||||
height: 22,
|
||||
maxWidth: 160,
|
||||
}}
|
||||
defaultValue={label?.color_code}
|
||||
suffixIcon={null}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorChangedLabel;
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Tag, Typography } from 'antd';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||
|
||||
const CustomColorLabel = ({ label }: { label: ITaskLabel | null }) => {
|
||||
return (
|
||||
<Tag
|
||||
key={label?.id}
|
||||
color={label?.color_code + ALPHA_CHANNEL}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyItems: 'center',
|
||||
height: 18,
|
||||
width: 'fit-content',
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
<Typography.Text style={{ fontSize: 11, color: colors.darkGray }}>
|
||||
{label?.name}
|
||||
</Typography.Text>
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomColorLabel;
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Tag, Tooltip } from 'antd';
|
||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||
|
||||
const CustomNumberLabel = ({ labelList }: { labelList: ITaskLabel[] | null }) => {
|
||||
const list = labelList?.slice(2);
|
||||
|
||||
const labelNamesStirng = list?.map(label => label.names).join(', ');
|
||||
|
||||
return (
|
||||
<Tooltip title={labelNamesStirng}>
|
||||
<Tag
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyItems: 'center',
|
||||
height: 18,
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
+{list?.length}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomNumberLabel;
|
||||
@@ -0,0 +1,170 @@
|
||||
/* eslint-disable react-hooks/exhaustive-deps */
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Dropdown,
|
||||
Flex,
|
||||
Input,
|
||||
InputRef,
|
||||
List,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { nanoid } from '@reduxjs/toolkit';
|
||||
import { addLabel } from '@features/settings/label/labelSlice';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ITaskLabel } from '@/types/label.type';
|
||||
|
||||
interface LabelsSelectorProps {
|
||||
taskId: string | null;
|
||||
labels: ITaskLabel[];
|
||||
}
|
||||
|
||||
const LabelsSelector = ({ taskId, labels }: LabelsSelectorProps) => {
|
||||
const labelInputRef = useRef<InputRef>(null);
|
||||
// this is for get the current string that type on search bar
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
// get task list from redux and find the selected task
|
||||
const selectedTask = useAppSelector(state => state.taskReducer.tasks).find(
|
||||
task => task.id === taskId
|
||||
);
|
||||
|
||||
// used useMemo hook for re-render the list when searching
|
||||
const filteredLabelData = useMemo(() => {
|
||||
return labels.filter(label =>
|
||||
label.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [labels, searchQuery]);
|
||||
|
||||
const handleCreateLabel = (name: string) => {
|
||||
if (name.length > 0) {
|
||||
const newLabel: ITaskLabel = {
|
||||
id: nanoid(),
|
||||
name,
|
||||
color_code: '#1E90FF',
|
||||
};
|
||||
|
||||
dispatch(addLabel(newLabel));
|
||||
setSearchQuery('');
|
||||
}
|
||||
};
|
||||
|
||||
// custom dropdown content
|
||||
const labelDropdownContent = (
|
||||
<Card
|
||||
className="custom-card"
|
||||
styles={{ body: { padding: 8, overflow: 'hidden', overflowY: 'auto', maxHeight: '255px' } }}
|
||||
>
|
||||
<Flex vertical gap={8}>
|
||||
<Input
|
||||
ref={labelInputRef}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.currentTarget.value)}
|
||||
placeholder={t('searchInputPlaceholder')}
|
||||
onKeyDown={e => {
|
||||
const isLabel = filteredLabelData.findIndex(
|
||||
label => label.name?.toLowerCase() === searchQuery.toLowerCase()
|
||||
);
|
||||
|
||||
if (isLabel === -1) {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreateLabel(searchQuery);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<List style={{ padding: 0 }}>
|
||||
{filteredLabelData.length ? (
|
||||
filteredLabelData.map(label => (
|
||||
<List.Item
|
||||
className="custom-list-item"
|
||||
key={label.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 8,
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
id={label.id}
|
||||
checked={
|
||||
selectedTask?.labels
|
||||
? selectedTask?.labels.some(
|
||||
existingLabel => existingLabel.id === label.id
|
||||
)
|
||||
: false
|
||||
}
|
||||
onChange={() => console.log(123)}
|
||||
/>
|
||||
|
||||
<Flex gap={8}>
|
||||
<Badge color={label.color_code} />
|
||||
{label.name}
|
||||
</Flex>
|
||||
</List.Item>
|
||||
))
|
||||
) : (
|
||||
<Typography.Text
|
||||
style={{ color: colors.lightGray }}
|
||||
onClick={() => handleCreateLabel(searchQuery)}
|
||||
>
|
||||
{t('labelSelectorInputTip')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</List>
|
||||
|
||||
<Divider style={{ margin: 0 }} />
|
||||
|
||||
<Button
|
||||
type="primary"
|
||||
style={{ alignSelf: 'flex-end' }}
|
||||
onClick={() => handleCreateLabel(searchQuery)}
|
||||
>
|
||||
{t('okButton')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// function to focus label input
|
||||
const handleLabelDropdownOpen = (open: boolean) => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
labelInputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
dropdownRender={() => labelDropdownContent}
|
||||
onOpenChange={handleLabelDropdownOpen}
|
||||
>
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<PlusOutlined style={{ fontSize: 11 }} />}
|
||||
style={{ height: 18 }}
|
||||
size="small"
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelsSelector;
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Badge, Flex, Select, Typography } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
// custom css file
|
||||
import './phaseDropdown.css';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { PhaseOption } from '../../../types/phase.types';
|
||||
import { colors } from '../../../styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const PhaseDropdown = ({ projectId }: { projectId: string }) => {
|
||||
const [currentPhaseOption, setCurrentPhaseOption] = useState<PhaseOption | null>(null);
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
// get phase data from redux
|
||||
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
|
||||
|
||||
//get phases details from phases slice
|
||||
const phase = phaseList.find(el => el.projectId === projectId);
|
||||
|
||||
const handlePhaseOptionSelect = (value: string) => {
|
||||
const selectedOption = phase?.phaseOptions.find(option => option.optionId === value);
|
||||
if (selectedOption) {
|
||||
setCurrentPhaseOption(selectedOption);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={currentPhaseOption?.optionId}
|
||||
placeholder={
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{t('selectText')}
|
||||
</Typography.Text>
|
||||
}
|
||||
onChange={handlePhaseOptionSelect}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
minWidth: 120,
|
||||
}}
|
||||
dropdownStyle={{
|
||||
padding: 0,
|
||||
}}
|
||||
variant={'borderless'}
|
||||
>
|
||||
{phase?.phaseOptions.map(option => (
|
||||
<Select.Option key={option.optionId} value={option.optionId}>
|
||||
<Flex gap={4} align="center">
|
||||
<Badge color={option.optionColor} />
|
||||
<Typography.Text
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: colors.darkGray,
|
||||
}}
|
||||
>
|
||||
{option.optionName}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhaseDropdown;
|
||||
@@ -0,0 +1,19 @@
|
||||
.phase-dropdown .ant-dropdown-menu {
|
||||
padding: 0 !important;
|
||||
margin-top: 8px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.phase-dropdown .ant-dropdown-menu-item {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.phase-dropdown-card .ant-card-body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.phase-menu .ant-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
.priority-dropdown .ant-dropdown-menu {
|
||||
padding: 0 !important;
|
||||
margin-top: 8px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.priority-dropdown .ant-dropdown-menu-item {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.priority-dropdown-card .ant-card-body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.priority-menu .ant-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Flex, Select, Typography } from 'antd';
|
||||
import './priority-dropdown.css';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useState, useEffect, useMemo } from 'react';
|
||||
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
||||
import { DoubleLeftOutlined, MinusOutlined, PauseOutlined } from '@ant-design/icons';
|
||||
|
||||
type PriorityDropdownProps = {
|
||||
task: IProjectTask;
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
const PriorityDropdown = ({ task, teamId }: PriorityDropdownProps) => {
|
||||
const { socket } = useSocket();
|
||||
const [selectedPriority, setSelectedPriority] = useState<ITaskPriority | undefined>(undefined);
|
||||
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const handlePriorityChange = (priorityId: string) => {
|
||||
if (!task.id || !priorityId) return;
|
||||
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
priority_id: priorityId,
|
||||
team_id: teamId,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const foundPriority = priorityList.find(priority => priority.id === task.priority);
|
||||
setSelectedPriority(foundPriority);
|
||||
}, [task.priority, priorityList]);
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
priorityList.map(priority => ({
|
||||
value: priority.id,
|
||||
label: (
|
||||
<Flex gap={8} align="center" justify="space-between">
|
||||
{priority.name}
|
||||
{priority.name === 'Low' && (
|
||||
<MinusOutlined
|
||||
style={{
|
||||
color: themeMode === 'dark' ? priority.color_code_dark : priority.color_code,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{priority.name === 'Medium' && (
|
||||
<PauseOutlined
|
||||
style={{
|
||||
color: themeMode === 'dark' ? priority.color_code_dark : priority.color_code,
|
||||
transform: 'rotate(90deg)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{priority.name === 'High' && (
|
||||
<DoubleLeftOutlined
|
||||
style={{
|
||||
color: themeMode === 'dark' ? priority.color_code_dark : priority.color_code,
|
||||
transform: 'rotate(90deg)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Flex>
|
||||
),
|
||||
})),
|
||||
[priorityList, themeMode]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{task.priority && (
|
||||
<Select
|
||||
variant="borderless"
|
||||
value={task.priority}
|
||||
onChange={handlePriorityChange}
|
||||
dropdownStyle={{ borderRadius: 8, minWidth: 150, maxWidth: 200 }}
|
||||
style={{
|
||||
backgroundColor:
|
||||
themeMode === 'dark'
|
||||
? selectedPriority?.color_code_dark
|
||||
: selectedPriority?.color_code + ALPHA_CHANNEL,
|
||||
borderRadius: 16,
|
||||
height: 22,
|
||||
}}
|
||||
labelRender={value => {
|
||||
const priority = priorityList.find(priority => priority.id === value.value);
|
||||
return priority ? (
|
||||
<Typography.Text style={{ fontSize: 13, color: '#383838' }}>
|
||||
{priority.name}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
''
|
||||
);
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriorityDropdown;
|
||||
@@ -0,0 +1,19 @@
|
||||
.status-dropdown .ant-dropdown-menu {
|
||||
padding: 0 !important;
|
||||
margin-top: 8px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-dropdown .ant-dropdown-menu-item {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.status-dropdown-card .ant-card-body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.status-menu .ant-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 41px !important;
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { Flex, Select } from 'antd';
|
||||
import './status-dropdown.css';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useMemo } from 'react';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { getCurrentGroup, GROUP_BY_STATUS_VALUE } from '@/features/tasks/tasks.slice';
|
||||
|
||||
type StatusDropdownProps = {
|
||||
task: IProjectTask;
|
||||
teamId: string;
|
||||
};
|
||||
|
||||
const StatusDropdown = ({ task, teamId }: StatusDropdownProps) => {
|
||||
const { socket } = useSocket();
|
||||
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const handleStatusChange = (statusId: string) => {
|
||||
if (!task.id || !statusId) return;
|
||||
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
status_id: statusId,
|
||||
parent_task: task.parent_task_id || null,
|
||||
team_id: teamId,
|
||||
})
|
||||
);
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||
};
|
||||
|
||||
const isGroupByStatus = () => {
|
||||
return getCurrentGroup().value === GROUP_BY_STATUS_VALUE;
|
||||
};
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
statusList.map(status => ({
|
||||
value: status.id,
|
||||
label: status.name,
|
||||
color: themeMode === 'dark' ? status.color_code_dark : status.color_code,
|
||||
})),
|
||||
[statusList, themeMode]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{task.status && (
|
||||
<Select
|
||||
variant="borderless"
|
||||
value={task.status}
|
||||
onChange={handleStatusChange}
|
||||
dropdownStyle={{ borderRadius: 8, minWidth: 150, maxWidth: 200 }}
|
||||
style={{
|
||||
backgroundColor: themeMode === 'dark' ? task.status_color_dark : task.status_color,
|
||||
borderRadius: 16,
|
||||
height: 22,
|
||||
}}
|
||||
labelRender={status => {
|
||||
return status ? <span style={{ fontSize: 13 }}>{status.label}</span> : '';
|
||||
}}
|
||||
options={options}
|
||||
optionRender={(option) => (
|
||||
<Flex align="center">
|
||||
{option.label}
|
||||
</Flex>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusDropdown;
|
||||
@@ -0,0 +1,23 @@
|
||||
import { TimePicker, TimePickerProps } from 'antd';
|
||||
|
||||
type TaskRowDueTimeProps = {
|
||||
dueTime: string;
|
||||
};
|
||||
|
||||
const TaskRowDueTime = ({ dueTime }: TaskRowDueTimeProps) => {
|
||||
// function to trigger time change
|
||||
const onTimeChange: TimePickerProps['onChange'] = (time, timeString) => {
|
||||
console.log(time, timeString);
|
||||
};
|
||||
|
||||
return (
|
||||
<TimePicker
|
||||
format="HH:mm"
|
||||
changeOnScroll
|
||||
onChange={onTimeChange}
|
||||
style={{ border: 'none', background: 'transparent' }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskRowDueTime;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Typography } from 'antd';
|
||||
|
||||
const TaskRowDescription = ({ description }: { description: string }) => {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
whiteSpace: 'nowrap',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
display: 'block',
|
||||
maxHeight: '24px', // Enforce single line height
|
||||
lineHeight: '24px',
|
||||
}}
|
||||
dangerouslySetInnerHTML={{ __html: description }}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskRowDescription;
|
||||
@@ -0,0 +1,125 @@
|
||||
// TaskNameCell.tsx
|
||||
import React, { useCallback } from 'react';
|
||||
import { Flex, Typography, Button } from 'antd';
|
||||
import {
|
||||
DoubleRightOutlined,
|
||||
DownOutlined,
|
||||
RightOutlined,
|
||||
ExpandAltOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
|
||||
type TaskRowNameProps = {
|
||||
task: IProjectTask;
|
||||
isSubTask?: boolean;
|
||||
expandedTasks: string[];
|
||||
setSelectedTaskId: (taskId: string) => void;
|
||||
toggleTaskExpansion: (taskId: string) => void;
|
||||
};
|
||||
|
||||
const TaskRowName = React.memo(
|
||||
({
|
||||
task,
|
||||
isSubTask = false,
|
||||
expandedTasks,
|
||||
setSelectedTaskId,
|
||||
toggleTaskExpansion,
|
||||
}: TaskRowNameProps) => {
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleToggleExpansion = useCallback(
|
||||
(taskId: string) => {
|
||||
toggleTaskExpansion(taskId);
|
||||
},
|
||||
[toggleTaskExpansion]
|
||||
);
|
||||
|
||||
const handleSelectTask = useCallback(() => {
|
||||
if (!task.id) return;
|
||||
setSelectedTaskId(task.id);
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
}, [dispatch, setSelectedTaskId, task.id]);
|
||||
|
||||
// render the toggle arrow icon for tasks with subtasks
|
||||
const renderToggleButton = (taskId: string, hasSubtasks: boolean) => {
|
||||
if (!hasSubtasks) return null;
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleToggleExpansion(taskId)}
|
||||
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54] transition duration-150"
|
||||
>
|
||||
{expandedTasks.includes(taskId) ? <DownOutlined /> : <RightOutlined />}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
// render the double arrow icon and count label for tasks with subtasks
|
||||
const renderSubtasksCountLabel = (
|
||||
taskId: string,
|
||||
isSubTask: boolean,
|
||||
subTasksCount: number
|
||||
) => {
|
||||
return (
|
||||
!isSubTask && (
|
||||
<Button
|
||||
onClick={() => handleToggleExpansion(taskId)}
|
||||
size="small"
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 2,
|
||||
paddingInline: 4,
|
||||
alignItems: 'center',
|
||||
justifyItems: 'center',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Typography.Text style={{ fontSize: 12, lineHeight: 1 }}>
|
||||
{subTasksCount}
|
||||
</Typography.Text>
|
||||
<DoubleRightOutlined style={{ fontSize: 10 }} />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="space-between" className="relative group">
|
||||
<Flex gap={8} align="center">
|
||||
{task?.sub_tasks?.length && task.id ? (
|
||||
renderToggleButton(task.id, !!task?.sub_tasks?.length)
|
||||
) : (
|
||||
<div className="h-4 w-4"></div>
|
||||
)}
|
||||
|
||||
{isSubTask && <DoubleRightOutlined style={{ fontSize: 12 }} />}
|
||||
|
||||
<Typography.Text ellipsis={{ expanded: false }}>{task.name}</Typography.Text>
|
||||
|
||||
{renderSubtasksCountLabel(task.id || '', isSubTask, task?.sub_tasks?.length || 0)}
|
||||
</Flex>
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
icon={<ExpandAltOutlined />}
|
||||
onClick={handleSelectTask}
|
||||
className="invisible group-hover:visible"
|
||||
style={{
|
||||
backgroundColor: colors.transparent,
|
||||
padding: 0,
|
||||
height: 'fit-content',
|
||||
}}
|
||||
>
|
||||
{t('openButton')}
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default TaskRowName;
|
||||
@@ -0,0 +1,27 @@
|
||||
/* Set the stroke width to 9px for the progress circle */
|
||||
.task-progress.ant-progress-circle .ant-progress-circle-path {
|
||||
stroke-width: 9px !important;
|
||||
/* Adjust the stroke width */
|
||||
}
|
||||
|
||||
/* Adjust the inner check mark for better alignment and visibility */
|
||||
.task-progress.ant-progress-circle.ant-progress-status-success .ant-progress-inner .anticon-check {
|
||||
font-size: 8px;
|
||||
/* Adjust font size for the check mark */
|
||||
color: green;
|
||||
/* Optional: Set a color */
|
||||
transform: translate(-50%, -50%);
|
||||
/* Center align */
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
padding: 0;
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
/* Adjust the text inside the progress circle */
|
||||
.task-progress.ant-progress-circle .ant-progress-text {
|
||||
font-size: 10px;
|
||||
/* Ensure the text size fits well */
|
||||
line-height: 1;
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Progress, Tooltip } from 'antd';
|
||||
import './task-row-progress.css';
|
||||
import React from 'react';
|
||||
|
||||
type TaskRowProgressProps = {
|
||||
progress: number;
|
||||
numberOfSubTasks: number;
|
||||
};
|
||||
|
||||
const TaskRowProgress = React.memo(
|
||||
({ progress = 0, numberOfSubTasks = 0 }: TaskRowProgressProps) => {
|
||||
const totalTasks = numberOfSubTasks + 1;
|
||||
const completedTasks = 0;
|
||||
|
||||
const size = progress === 100 ? 21 : 26;
|
||||
|
||||
return (
|
||||
<Tooltip title={`${completedTasks} / ${totalTasks}`}>
|
||||
<Progress
|
||||
percent={progress}
|
||||
type="circle"
|
||||
size={size}
|
||||
style={{ cursor: 'default' }}
|
||||
className="task-progress"
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default TaskRowProgress;
|
||||
@@ -0,0 +1,69 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Divider, Empty, Flex, Popover, Typography } from 'antd';
|
||||
import { PlayCircleFilled } from '@ant-design/icons';
|
||||
import { colors } from '@/styles/colors';
|
||||
import CustomAvatar from '@components/CustomAvatar';
|
||||
import { mockTimeLogs } from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/mockTimeLogs';
|
||||
|
||||
type TaskListTimeTrackerCellProps = {
|
||||
taskId: string | null;
|
||||
initialTime?: number;
|
||||
};
|
||||
|
||||
const TaskListTimeTrackerCell = ({ taskId, initialTime = 0 }: TaskListTimeTrackerCellProps) => {
|
||||
const minutes = Math.floor(initialTime / 60);
|
||||
const seconds = initialTime % 60;
|
||||
const formattedTime = `${minutes}m ${seconds}s`;
|
||||
|
||||
const timeTrackingLogCard = useMemo(() => {
|
||||
if (initialTime > 0) {
|
||||
return (
|
||||
<Flex vertical style={{ width: 400, height: 300, overflowY: 'scroll' }}>
|
||||
{mockTimeLogs.map(log => (
|
||||
<React.Fragment key={log.logId}>
|
||||
<Flex gap={8} align="center">
|
||||
<CustomAvatar avatarName={log.username} />
|
||||
|
||||
<Flex vertical>
|
||||
<Typography>
|
||||
<Typography.Text strong>{log.username}</Typography.Text>
|
||||
<Typography.Text>{` logged ${log.duration} ${
|
||||
log.via ? `via ${log.via}` : ''
|
||||
}`}</Typography.Text>
|
||||
</Typography>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{log.date}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Divider style={{ marginBlock: 12 }} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
} else {
|
||||
return <Empty style={{ width: 400 }} />;
|
||||
}
|
||||
}, [initialTime]);
|
||||
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
<PlayCircleFilled style={{ color: colors.skyBlue, fontSize: 16 }} />
|
||||
<Popover
|
||||
title={
|
||||
<Typography.Text style={{ fontWeight: 500 }}>
|
||||
Time Tracking Log
|
||||
<Divider style={{ marginBlockStart: 8, marginBlockEnd: 12 }} />
|
||||
</Typography.Text>
|
||||
}
|
||||
content={timeTrackingLogCard}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
>
|
||||
<Typography.Text style={{ cursor: 'pointer' }}>{formattedTime}</Typography.Text>
|
||||
</Popover>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListTimeTrackerCell;
|
||||
Reference in New Issue
Block a user