init
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
import { InputRef } from 'antd/es/input';
|
||||
import Card from 'antd/es/card';
|
||||
import Checkbox from 'antd/es/checkbox';
|
||||
import Divider from 'antd/es/divider';
|
||||
import Dropdown from 'antd/es/dropdown';
|
||||
import Empty from 'antd/es/empty';
|
||||
import Flex from 'antd/es/flex';
|
||||
import Input from 'antd/es/input';
|
||||
import List from 'antd/es/list';
|
||||
import Typography from 'antd/es/typography';
|
||||
import Button from 'antd/es/button';
|
||||
|
||||
import { useMemo, useRef, useState } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleProjectMemberDrawer } from '../../../features/projects/singleProject/members/projectMembersSlice';
|
||||
import { colors } from '../../../styles/colors';
|
||||
import { PlusOutlined, UsergroupAddOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
||||
import { sortByBooleanField, sortBySelection, sortTeamMembers } from '@/utils/sort-team-members';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { getTeamMembers } from '@/features/team-members/team-members.slice';
|
||||
|
||||
interface AssigneeSelectorProps {
|
||||
task: IProjectTask;
|
||||
groupId: string | null;
|
||||
}
|
||||
|
||||
const AssigneeSelector = ({ task, groupId = null }: AssigneeSelectorProps) => {
|
||||
const membersInputRef = useRef<InputRef>(null);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [teamMembers, setTeamMembers] = useState<ITeamMembersViewModel>({ data: [], total: 0 });
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const { socket } = useSocket();
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
|
||||
const {loadingAssignees} = useAppSelector(state => state.taskReducer);
|
||||
|
||||
const filteredMembersData = useMemo(() => {
|
||||
return teamMembers?.data?.filter(member =>
|
||||
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [teamMembers, searchQuery]);
|
||||
|
||||
const handleInviteProjectMemberDrawer = () => {
|
||||
dispatch(toggleProjectMemberDrawer());
|
||||
};
|
||||
|
||||
const handleMembersDropdownOpen = (open: boolean) => {
|
||||
if (open) {
|
||||
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
|
||||
const membersData = (members?.data || []).map(member => ({
|
||||
...member,
|
||||
selected: assignees?.includes(member.id),
|
||||
}));
|
||||
let sortedMembers = sortTeamMembers(membersData);
|
||||
|
||||
setTeamMembers({ data: sortedMembers });
|
||||
|
||||
setTimeout(() => {
|
||||
membersInputRef.current?.focus();
|
||||
}, 0);
|
||||
} else {
|
||||
setTeamMembers(members || { data: [] });
|
||||
}
|
||||
};
|
||||
|
||||
const handleMemberChange = (e: CheckboxChangeEvent | null, memberId: string) => {
|
||||
if (!memberId || !projectId || !task?.id || !currentSession?.id) return;
|
||||
const checked =
|
||||
e?.target.checked ||
|
||||
!task?.assignees?.some(assignee => assignee.team_member_id === memberId) ||
|
||||
false;
|
||||
|
||||
const body = {
|
||||
team_member_id: memberId,
|
||||
project_id: projectId,
|
||||
task_id: task.id,
|
||||
reporter_id: currentSession?.id,
|
||||
mode: checked ? 0 : 1,
|
||||
parent_task: task.parent_task_id,
|
||||
};
|
||||
|
||||
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body));
|
||||
};
|
||||
|
||||
const checkMemberSelected = (memberId: string) => {
|
||||
if (!memberId) return false;
|
||||
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
|
||||
return assignees?.includes(memberId);
|
||||
};
|
||||
|
||||
|
||||
const membersDropdownContent = (
|
||||
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
|
||||
<Flex vertical>
|
||||
<Input
|
||||
ref={membersInputRef}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.currentTarget.value)}
|
||||
placeholder={t('searchInputPlaceholder')}
|
||||
/>
|
||||
|
||||
<List style={{ padding: 0, height: 250, overflow: 'auto' }}>
|
||||
{filteredMembersData?.length ? (
|
||||
filteredMembersData.map(member => (
|
||||
<List.Item
|
||||
className={`${themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'} ${member.pending_invitation ? 'disabled cursor-not-allowed' : ''}`}
|
||||
key={member.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
justifyContent: 'flex-start',
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={e => handleMemberChange(null, member.id || '')}
|
||||
>
|
||||
<Checkbox
|
||||
id={member.id}
|
||||
checked={checkMemberSelected(member.id || '')}
|
||||
onChange={e => handleMemberChange(e, member.id || '')}
|
||||
disabled={member.pending_invitation}
|
||||
/>
|
||||
<div>
|
||||
<SingleAvatar
|
||||
avatarUrl={member.avatar_url}
|
||||
name={member.name}
|
||||
email={member.email}
|
||||
/>
|
||||
</div>
|
||||
<Flex vertical>
|
||||
<Typography.Text>{member.name}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{member.email}
|
||||
{member.pending_invitation && (
|
||||
<Typography.Text type="danger" style={{ fontSize: 10 }}>
|
||||
({t('pendingInvitation')})
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</List.Item>
|
||||
))
|
||||
) : (
|
||||
<Empty />
|
||||
)}
|
||||
</List>
|
||||
|
||||
<Divider style={{ marginBlock: 0 }} />
|
||||
|
||||
<Button
|
||||
icon={<UsergroupAddOutlined />}
|
||||
type="text"
|
||||
style={{
|
||||
color: colors.skyBlue,
|
||||
border: 'none',
|
||||
backgroundColor: colors.transparent,
|
||||
width: '100%',
|
||||
}}
|
||||
onClick={handleInviteProjectMemberDrawer}
|
||||
>
|
||||
{t('assigneeSelectorInviteButton')}
|
||||
</Button>
|
||||
|
||||
{/* <Divider style={{ marginBlock: 8 }} /> */}
|
||||
|
||||
{/* <Button
|
||||
type="primary"
|
||||
style={{ alignSelf: 'flex-end' }}
|
||||
size="small"
|
||||
onClick={handleAssignMembers}
|
||||
>
|
||||
{t('okButton')}
|
||||
</Button> */}
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="custom-dropdown"
|
||||
trigger={['click']}
|
||||
dropdownRender={() => membersDropdownContent}
|
||||
onOpenChange={handleMembersDropdownOpen}
|
||||
>
|
||||
<Button
|
||||
type="dashed"
|
||||
shape="circle"
|
||||
size="small"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
icon={
|
||||
<PlusOutlined
|
||||
style={{
|
||||
fontSize: 12,
|
||||
width: 22,
|
||||
height: 22,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssigneeSelector;
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Tag, Tooltip } from 'antd';
|
||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||
|
||||
interface ICustomColordLabelProps {
|
||||
label: ITaskLabel | null;
|
||||
}
|
||||
|
||||
const CustomColordLabel = ({ label }: ICustomColordLabelProps) => {
|
||||
if (!label) return null;
|
||||
|
||||
|
||||
return (
|
||||
<Tooltip title={label.name}>
|
||||
<Tag
|
||||
key={label.id}
|
||||
color={label.color_code}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: 18,
|
||||
width: 'fit-content',
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{label.name && label.name.length > 10 ? `${label.name.substring(0, 10)}...` : label.name}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomColordLabel;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Tag, Tooltip } from 'antd';
|
||||
|
||||
interface ICustomNumberLabelProps {
|
||||
labelList: string[];
|
||||
namesString: string;
|
||||
}
|
||||
|
||||
const CustomNumberLabel = ({ labelList, namesString }: ICustomNumberLabelProps) => {
|
||||
return (
|
||||
<Tooltip title={labelList.join(', ')}>
|
||||
<Tag
|
||||
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyItems: 'center',
|
||||
height: 18,
|
||||
fontSize: 11,
|
||||
}}
|
||||
>
|
||||
{namesString}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default CustomNumberLabel;
|
||||
@@ -0,0 +1,180 @@
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
Flex,
|
||||
Input,
|
||||
InputRef,
|
||||
List,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { ITaskLabel } from '@/types/label.type';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
|
||||
interface LabelsSelectorProps {
|
||||
task: IProjectTask;
|
||||
}
|
||||
|
||||
const LabelsSelector = ({ task }: LabelsSelectorProps) => {
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const { socket } = useSocket();
|
||||
|
||||
const labelInputRef = useRef<InputRef>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
|
||||
const { labels } = useAppSelector(state => state.taskLabelsReducer);
|
||||
const [labelList, setLabelList] = useState<ITaskLabel[]>([]);
|
||||
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const handleLabelChange = (label: ITaskLabel) => {
|
||||
const labelData = {
|
||||
task_id: task.id,
|
||||
label_id: label.id,
|
||||
parent_task: task.parent_task_id,
|
||||
team_id: currentSession?.team_id,
|
||||
};
|
||||
|
||||
socket?.emit(SocketEvents.TASK_LABELS_CHANGE.toString(), JSON.stringify(labelData));
|
||||
};
|
||||
|
||||
const handleCreateLabel = () => {
|
||||
if (!searchQuery.trim()) return;
|
||||
const labelData = {
|
||||
task_id: task.id,
|
||||
label: searchQuery.trim(),
|
||||
parent_task: task.parent_task_id,
|
||||
team_id: currentSession?.team_id,
|
||||
};
|
||||
socket?.emit(SocketEvents.CREATE_LABEL.toString(), JSON.stringify(labelData));
|
||||
setSearchQuery('');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setLabelList(labels as ITaskLabel[]);
|
||||
}, [labels, task.labels]);
|
||||
|
||||
// used useMemo hook for re render the list when searching
|
||||
const filteredLabelData = useMemo(() => {
|
||||
return labelList.filter(label => label.name?.toLowerCase().includes(searchQuery.toLowerCase()));
|
||||
}, [labelList, searchQuery]);
|
||||
|
||||
const labelDropdownContent = (
|
||||
<Card
|
||||
className="custom-card"
|
||||
styles={{ body: { padding: 8, overflow: 'hidden' } }}
|
||||
>
|
||||
<Flex vertical gap={8}>
|
||||
<Input
|
||||
ref={labelInputRef}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.currentTarget.value)}
|
||||
placeholder={t('labelInputPlaceholder')}
|
||||
onKeyDown={e => {
|
||||
const isLabel = filteredLabelData.findIndex(
|
||||
label => label.name?.toLowerCase() === searchQuery.toLowerCase()
|
||||
);
|
||||
if (isLabel === -1) {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreateLabel();
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<List
|
||||
style={{
|
||||
padding: 0,
|
||||
maxHeight: 150,
|
||||
overflow: 'scroll',
|
||||
}}
|
||||
>
|
||||
{filteredLabelData.length ? (
|
||||
filteredLabelData.map(label => (
|
||||
<List.Item
|
||||
className={themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'}
|
||||
key={label.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-start',
|
||||
gap: 8,
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
id={label.id}
|
||||
checked={
|
||||
task?.all_labels
|
||||
? task?.all_labels.some(existingLabel => existingLabel.id === label.id)
|
||||
: false
|
||||
}
|
||||
onChange={() => handleLabelChange(label)}
|
||||
>
|
||||
<Flex gap={8}>
|
||||
<Badge color={label.color_code} />
|
||||
{label.name}
|
||||
</Flex>
|
||||
</Checkbox>
|
||||
</List.Item>
|
||||
))
|
||||
) : (
|
||||
<Typography.Text
|
||||
style={{ color: colors.lightGray }}
|
||||
onClick={() => handleCreateLabel()}
|
||||
>
|
||||
{t('labelsSelectorInputTip')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</List>
|
||||
|
||||
{/* <Divider style={{ margin: 0 }} /> */}
|
||||
|
||||
{/* <Button
|
||||
type="primary"
|
||||
style={{ alignSelf: 'flex-end' }}
|
||||
onClick={() => handleCreateLabel()}
|
||||
>
|
||||
{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,51 @@
|
||||
import React from 'react';
|
||||
import { LabelType } from '../../../types/label.type';
|
||||
import { Select, Tag, Tooltip } from 'antd';
|
||||
import { PhaseColorCodes } from '../../../shared/constants';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const ColorChangedLabel = ({ label }: { label: LabelType | 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?.labelName}
|
||||
</Tag>
|
||||
),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Tooltip title={t('colorChangeTooltip')}>
|
||||
<Select
|
||||
key={label?.labelId}
|
||||
options={colorsOptions}
|
||||
variant="borderless"
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyItems: 'center',
|
||||
height: 22,
|
||||
maxWidth: 160,
|
||||
}}
|
||||
defaultValue={label?.labelColor}
|
||||
suffixIcon={null}
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default ColorChangedLabel;
|
||||
@@ -0,0 +1,31 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.custom-list-item:hover {
|
||||
background-color: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.custom-list-item.dark:hover {
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.custom-card {
|
||||
min-width: 200px;
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Badge, Flex, Select, Tooltip, Typography } from 'antd';
|
||||
|
||||
import './phase-dropdown.css';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface PhaseDropdownProps {
|
||||
task: IProjectTask;
|
||||
}
|
||||
|
||||
const PhaseDropdown = ({ task }: PhaseDropdownProps) => {
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const { socket, connected } = useSocket();
|
||||
const [currentPhase, setCurrentPhase] = useState<string | null>(task.phase_id || null);
|
||||
const { phaseList } = useAppSelector(state => state.phaseReducer);
|
||||
|
||||
// Handle phase select
|
||||
const handlePhaseOptionSelect = (value: string) => {
|
||||
if (!connected || !task.id || !value) return;
|
||||
try {
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_PHASE_CHANGE.toString(),
|
||||
{
|
||||
task_id: task.id,
|
||||
phase_id: value,
|
||||
parent_task: task.parent_task_id,
|
||||
},
|
||||
(error: Error | null) => {
|
||||
if (error) {
|
||||
logger.error('Phase change failed:', error);
|
||||
}
|
||||
}
|
||||
);
|
||||
setCurrentPhase(value);
|
||||
} catch (error) {
|
||||
logger.error('Error in handlePhaseOptionSelect:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePhaseOptionClear = () => {
|
||||
if (!connected || !task.id) return;
|
||||
try {
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_PHASE_CHANGE.toString(),
|
||||
{
|
||||
task_id: task.id,
|
||||
phase_id: null,
|
||||
parent_task: task.parent_task_id,
|
||||
},
|
||||
(error: Error | null) => {
|
||||
if (error) {
|
||||
logger.error('Phase clear failed:', error);
|
||||
}
|
||||
}
|
||||
);
|
||||
setCurrentPhase(null);
|
||||
} catch (error) {
|
||||
logger.error('Error in handlePhaseOptionClear:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Select
|
||||
className="phase-select"
|
||||
placeholder={
|
||||
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
|
||||
{t('selectText')}
|
||||
</Typography.Text>
|
||||
}
|
||||
value={currentPhase}
|
||||
onChange={handlePhaseOptionSelect}
|
||||
onClear={() => handlePhaseOptionClear()}
|
||||
variant="borderless"
|
||||
dropdownStyle={{ minWidth: 150 }}
|
||||
optionLabelProp="label"
|
||||
popupClassName="phase-select-dropdown"
|
||||
allowClear
|
||||
style={{
|
||||
backgroundColor: currentPhase ? task.phase_color + ALPHA_CHANNEL : undefined,
|
||||
borderRadius: 16,
|
||||
height: 22,
|
||||
width: 120,
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
{phaseList?.map(phase => (
|
||||
<Select.Option
|
||||
key={phase.id}
|
||||
value={phase.id}
|
||||
label={
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
<Flex
|
||||
gap={6}
|
||||
align="center"
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 8,
|
||||
height: 22,
|
||||
fontSize: 13,
|
||||
color: colors.darkGray,
|
||||
}}
|
||||
>
|
||||
<Tooltip title={phase.name}>
|
||||
<Typography.Text
|
||||
ellipsis
|
||||
style={{
|
||||
fontSize: 13,
|
||||
maxWidth: 90,
|
||||
}}
|
||||
>
|
||||
{phase.name}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<Flex gap={4} align="center">
|
||||
<Badge color={phase.color_code} />
|
||||
<Tooltip title={phase.name}>
|
||||
<Typography.Text ellipsis style={{ maxWidth: 100 }}>
|
||||
{phase.name}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
export default PhaseDropdown;
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Card, Dropdown, Flex, Menu, MenuProps, Typography } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { DoubleLeftOutlined, DownOutlined, MinusOutlined, PauseOutlined } from '@ant-design/icons';
|
||||
// custom css file
|
||||
import './priorityDropdown.css';
|
||||
import { colors } from '../../../styles/colors';
|
||||
import { TaskPriorityType } from '../../../types/task.types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { getPriorityColor } from '../../../utils/getPriorityColors';
|
||||
|
||||
type PriorityDropdownProps = {
|
||||
currentPriority: TaskPriorityType | string;
|
||||
};
|
||||
|
||||
const PriorityDropdown = ({ currentPriority }: PriorityDropdownProps) => {
|
||||
const [priority, setPriority] = useState<TaskPriorityType | string>(currentPriority);
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// menu type
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
// priority menu item
|
||||
const priorityMenuItems: MenuItem[] = [
|
||||
{
|
||||
key: 'low',
|
||||
label: (
|
||||
<Flex gap={4}>
|
||||
{t('lowSelectorText')}
|
||||
<MinusOutlined style={{ color: getPriorityColor('low', themeMode) }} />
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'medium',
|
||||
label: (
|
||||
<Flex gap={4}>
|
||||
{t('mediumSelectorText')}
|
||||
<PauseOutlined
|
||||
style={{
|
||||
color: getPriorityColor('medium', themeMode),
|
||||
rotate: '90deg',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'high',
|
||||
label: (
|
||||
<Flex gap={4}>
|
||||
{t('highSelectorText')}
|
||||
<DoubleLeftOutlined
|
||||
style={{
|
||||
color: getPriorityColor('high', themeMode),
|
||||
rotate: '90deg',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// handle priority select
|
||||
const onClick: MenuProps['onClick'] = e => {
|
||||
e.key === 'low'
|
||||
? setPriority('low')
|
||||
: e.key === 'medium'
|
||||
? setPriority('medium')
|
||||
: setPriority('high');
|
||||
};
|
||||
|
||||
//dropdown items
|
||||
const priorityDropdownItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<Card className="priority-dropdown-card" bordered={false}>
|
||||
<Menu
|
||||
className="priority-menu"
|
||||
items={priorityMenuItems}
|
||||
defaultValue={currentPriority}
|
||||
onClick={onClick}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="priority-dropdown"
|
||||
menu={{ items: priorityDropdownItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<Flex
|
||||
gap={6}
|
||||
align="center"
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 8,
|
||||
height: 22,
|
||||
backgroundColor: getPriorityColor(priority, themeMode),
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
style={{
|
||||
textTransform: 'capitalize',
|
||||
color: colors.darkGray,
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
{t(priority + 'SelectorText')}
|
||||
</Typography.Text>
|
||||
|
||||
<DownOutlined />
|
||||
</Flex>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriorityDropdown;
|
||||
@@ -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,114 @@
|
||||
import { Badge, Card, Dropdown, Flex, Menu, MenuProps, Typography } from 'antd';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { DownOutlined } from '@ant-design/icons';
|
||||
import './statusDropdown.css';
|
||||
import { colors } from '../../../styles/colors';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { getStatusColor } from '../../../utils/getStatusColor';
|
||||
import { themeWiseColor } from '../../../utils/themeWiseColor';
|
||||
|
||||
type StatusDropdownProps = {
|
||||
currentStatus: string;
|
||||
};
|
||||
|
||||
const StatusDropdown = ({ currentStatus }: StatusDropdownProps) => {
|
||||
const [status, setStatus] = useState<string>(currentStatus);
|
||||
const [statusName, setStatusName] = useState<string>('');
|
||||
|
||||
// localization
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const statusList = useAppSelector(state => state.statusReducer.status);
|
||||
|
||||
// this is trigger only on status list update
|
||||
useEffect(() => {
|
||||
const selectedStatus = statusList.find(el => el.category === status);
|
||||
setStatusName(selectedStatus?.name || '');
|
||||
}, [statusList]);
|
||||
|
||||
type MenuItem = Required<MenuProps>['items'][number];
|
||||
|
||||
const statusMenuItems: MenuItem[] = statusList
|
||||
? statusList.map(status => ({
|
||||
key: status.id,
|
||||
label: (
|
||||
<Flex gap={8} align="center">
|
||||
<Badge color={getStatusColor(status.category, themeMode)} />
|
||||
<Typography.Text>
|
||||
{status.name === 'To do' || status.name === 'Doing' || status.name === 'Done'
|
||||
? t(status.category + 'SelectorText')
|
||||
: status.name}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
),
|
||||
}))
|
||||
: [];
|
||||
|
||||
const handleStatusOptionSelect: MenuProps['onClick'] = e => {
|
||||
const selectedOption = statusList.find(el => el.id === e.key);
|
||||
if (selectedOption) {
|
||||
setStatusName(
|
||||
selectedOption.name === 'To do' ||
|
||||
selectedOption.name === 'Doing' ||
|
||||
selectedOption.name === 'Done'
|
||||
? t(selectedOption.category + 'SelectorText')
|
||||
: selectedOption.name
|
||||
);
|
||||
setStatus(selectedOption.category);
|
||||
}
|
||||
};
|
||||
|
||||
const statusDropdownItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<Card className="status-dropdown-card" bordered={false}>
|
||||
<Menu
|
||||
className="status-menu"
|
||||
items={statusMenuItems}
|
||||
onClick={handleStatusOptionSelect}
|
||||
/>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Dropdown
|
||||
overlayClassName="status-dropdown"
|
||||
menu={{ items: statusDropdownItems }}
|
||||
placement="bottomRight"
|
||||
trigger={['click']}
|
||||
>
|
||||
<Flex
|
||||
gap={6}
|
||||
align="center"
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
borderRadius: 24,
|
||||
paddingInline: 8,
|
||||
height: 22,
|
||||
backgroundColor: getStatusColor(status, themeMode),
|
||||
color: colors.darkGray,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
ellipsis={{ expanded: false }}
|
||||
style={{
|
||||
fontSize: 13,
|
||||
color: colors.darkGray,
|
||||
fontWeight: 400,
|
||||
}}
|
||||
>
|
||||
{statusName}
|
||||
</Typography.Text>
|
||||
<DownOutlined style={{ fontSize: 12 }} />
|
||||
</Flex>
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default StatusDropdown;
|
||||
@@ -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: 32px;
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Button, Card, Checkbox, Flex, List, Typography } from 'antd';
|
||||
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
interface AssigneesDropdownProps {
|
||||
members: ITeamMemberViewModel[];
|
||||
themeMode: string;
|
||||
onApply: (selectedAssignees: ITeamMemberViewModel[]) => void;
|
||||
onClose: () => void;
|
||||
t: (key: string) => string;
|
||||
}
|
||||
|
||||
const AssigneesDropdown = ({ members, themeMode, onApply, onClose, t }: AssigneesDropdownProps) => {
|
||||
const [selectedAssignees, setSelectedAssignees] = useState<ITeamMemberViewModel[]>([]);
|
||||
|
||||
const handleAssigneeChange = (e: CheckboxChangeEvent, member: ITeamMemberViewModel) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedAssignees(prev => [...prev, member]);
|
||||
} else {
|
||||
setSelectedAssignees(prev => prev.filter(m => m.id !== member.id));
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedAssignees([]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
|
||||
<Flex vertical>
|
||||
<List style={{ padding: 0, height: 250, overflow: 'auto' }}>
|
||||
{members?.map(member => (
|
||||
<List.Item
|
||||
className={`${themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'} ${member.pending_invitation ? 'cursor-not-allowed' : ''}`}
|
||||
key={member.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
justifyContent: 'flex-start',
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
disabled={member.pending_invitation}
|
||||
checked={selectedAssignees.some(a => a.id === member.id)}
|
||||
onChange={e => handleAssigneeChange(e, member)}
|
||||
>
|
||||
<Flex align="center">
|
||||
<SingleAvatar
|
||||
avatarUrl={member.avatar_url}
|
||||
name={member.name}
|
||||
email={member.email}
|
||||
/>
|
||||
<Flex vertical>
|
||||
<Typography.Text>{member.name}</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{member.email}
|
||||
{member.pending_invitation && (
|
||||
<Typography.Text type="danger" style={{ fontSize: 10 }}>
|
||||
({t('pendingInvitation')})
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Checkbox>
|
||||
</List.Item>
|
||||
))}
|
||||
</List>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
style={{ width: '100%' }}
|
||||
onClick={() => {
|
||||
handleClose();
|
||||
onApply(selectedAssignees);
|
||||
}}
|
||||
>
|
||||
{t('apply')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssigneesDropdown;
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Badge, Button, Card, Checkbox, Empty, Flex, Input, List, Typography } from 'antd';
|
||||
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||
import { InputRef } from 'antd/es/input';
|
||||
import { useEffect, useMemo } from 'react'; // Add useMemo for filtering
|
||||
|
||||
interface LabelsDropdownProps {
|
||||
labelsList: ITaskLabel[];
|
||||
themeMode: string;
|
||||
createLabelText: string;
|
||||
selectedLabels: ITaskLabel[];
|
||||
labelsInputRef: React.RefObject<InputRef>;
|
||||
onLabelChange: (e: CheckboxChangeEvent, label: ITaskLabel) => void;
|
||||
onCreateLabelTextChange: (value: string) => void;
|
||||
onApply: () => void;
|
||||
t: (key: string) => string;
|
||||
loading: boolean;
|
||||
}
|
||||
|
||||
const LabelsDropdown = ({
|
||||
labelsList,
|
||||
themeMode,
|
||||
createLabelText,
|
||||
selectedLabels,
|
||||
labelsInputRef,
|
||||
onLabelChange,
|
||||
onCreateLabelTextChange,
|
||||
onApply,
|
||||
loading,
|
||||
t,
|
||||
}: LabelsDropdownProps) => {
|
||||
useEffect(() => {
|
||||
if (labelsInputRef.current) {
|
||||
labelsInputRef.current.focus();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const isOnApply = () => {
|
||||
if (!createLabelText.trim() && selectedLabels.length === 0) return;
|
||||
onApply();
|
||||
};
|
||||
return (
|
||||
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
|
||||
<Flex vertical>
|
||||
{/* Always show the list, filtered by input */}
|
||||
{!createLabelText && (
|
||||
<List
|
||||
style={{
|
||||
padding: 0,
|
||||
overflow: 'auto',
|
||||
maxHeight: labelsList.length > 10 ? '200px' : 'auto', // Set max height if more than 10 labels
|
||||
maxWidth: 250,
|
||||
}}
|
||||
>
|
||||
{labelsList.length > 0 && (
|
||||
labelsList.map(label => (
|
||||
<List.Item
|
||||
className={themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'}
|
||||
key={label.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
justifyContent: 'flex-start',
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
id={label.id}
|
||||
checked={selectedLabels.some(l => l.id === label.id)}
|
||||
onChange={e => onLabelChange(e, label)}
|
||||
>
|
||||
<Badge color={label.color_code} text={label.name} />
|
||||
</Checkbox>
|
||||
</List.Item>
|
||||
))
|
||||
)}
|
||||
</List>
|
||||
)}
|
||||
|
||||
<Flex style={{ paddingTop: 8 }} vertical justify="space-between" gap={8}>
|
||||
<Input
|
||||
ref={labelsInputRef}
|
||||
value={createLabelText}
|
||||
onChange={e => onCreateLabelTextChange(e.currentTarget.value)}
|
||||
placeholder={t('createLabel')}
|
||||
onPressEnter={() => {
|
||||
isOnApply();
|
||||
}}
|
||||
/>
|
||||
{createLabelText && (
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{t('hitEnterToCreate')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
{!createLabelText && (
|
||||
<Button type="primary" size="small" onClick={isOnApply} style={{ width: '100%' }}>
|
||||
{t('apply')}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default LabelsDropdown;
|
||||
@@ -0,0 +1,74 @@
|
||||
.bulk-actions {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
background: #252628;
|
||||
bottom: 30px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
width: fit-content;
|
||||
margin: auto;
|
||||
height: 50px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 25px;
|
||||
padding: 0 25px;
|
||||
box-shadow:
|
||||
0 0 0 1px #edeae9,
|
||||
0 5px 20px 0 rgba(109, 110, 111, 0.08);
|
||||
transition: transform 0.2s ease-in;
|
||||
opacity: 0;
|
||||
transform: translateY(200%);
|
||||
|
||||
.bulk-actions-inner {
|
||||
&,
|
||||
button,
|
||||
nz-select {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
[nz-icon] {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
&.open {
|
||||
transition: transform 0.2s ease-in;
|
||||
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.bulk-actions-input-container {
|
||||
position: absolute;
|
||||
top: -40px;
|
||||
width: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.disable {
|
||||
position: relative;
|
||||
&:after {
|
||||
position: absolute;
|
||||
content: "";
|
||||
background: #e7e7e769;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.li-custom * {
|
||||
min-height: 32px !important;
|
||||
height: 32px !important;
|
||||
padding: 5px 12px;
|
||||
}
|
||||
|
||||
.disabled {
|
||||
opacity: 0.5;
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge, Dropdown, Flex, Tooltip, Button, InputRef, CheckboxChangeEvent } from 'antd/es';
|
||||
import {
|
||||
RetweetOutlined,
|
||||
TagsOutlined,
|
||||
UserAddOutlined,
|
||||
UsergroupAddOutlined,
|
||||
InboxOutlined,
|
||||
DeleteOutlined,
|
||||
MoreOutlined,
|
||||
CloseCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||
import { fetchTaskGroups } from '@/features/tasks/tasks.slice';
|
||||
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
|
||||
import {
|
||||
evt_project_task_list_bulk_archive,
|
||||
evt_project_task_list_bulk_assign_me,
|
||||
evt_project_task_list_bulk_assign_members,
|
||||
evt_project_task_list_bulk_change_phase,
|
||||
evt_project_task_list_bulk_change_priority,
|
||||
evt_project_task_list_bulk_change_status,
|
||||
evt_project_task_list_bulk_delete,
|
||||
evt_project_task_list_bulk_update_labels,
|
||||
} from '@/shared/worklenz-analytics-events';
|
||||
import {
|
||||
IBulkTasksLabelsRequest,
|
||||
IBulkTasksPhaseChangeRequest,
|
||||
IBulkTasksPriorityChangeRequest,
|
||||
IBulkTasksStatusChangeRequest,
|
||||
} from '@/types/tasks/bulk-action-bar.types';
|
||||
import { ITaskStatus } from '@/types/tasks/taskStatus.types';
|
||||
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
||||
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||
|
||||
import './task-list-bulk-actions-bar.css';
|
||||
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
||||
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
||||
import AssigneesDropdown from './components/AssigneesDropdown';
|
||||
import LabelsDropdown from './components/LabelsDropdown';
|
||||
import { sortTeamMembers } from '@/utils/sort-team-members';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
|
||||
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/custom-column-modal';
|
||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||
import alertService from '@/services/alerts/alertService';
|
||||
|
||||
interface ITaskAssignee {
|
||||
id: string;
|
||||
name?: string;
|
||||
email?: string;
|
||||
avatar_url?: string;
|
||||
team_member_id: string;
|
||||
project_member_id: string;
|
||||
}
|
||||
|
||||
const TaskListBulkActionsBar = () => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('tasks/task-table-bulk-actions');
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
// Add permission hooks near other hooks
|
||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||
|
||||
// loading state
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [updatingLabels, setUpdatingLabels] = useState(false);
|
||||
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
|
||||
const [updatingAssignees, setUpdatingAssignees] = useState(false);
|
||||
const [updatingArchive, setUpdatingArchive] = useState(false);
|
||||
const [updatingDelete, setUpdatingDelete] = useState(false);
|
||||
|
||||
// Selectors
|
||||
const { selectedTaskIdsList } = useAppSelector(state => state.bulkActionReducer);
|
||||
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
||||
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
|
||||
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
|
||||
const labelsList = useAppSelector(state => state.taskLabelsReducer.labels);
|
||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
|
||||
const archived = useAppSelector(state => state.taskReducer.archived);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const labelsInputRef = useRef<InputRef>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [createLabelText, setCreateLabelText] = useState<string>('');
|
||||
|
||||
const [teamMembersSorted, setTeamMembersSorted] = useState<ITeamMembersViewModel>({
|
||||
data: [],
|
||||
total: 0,
|
||||
});
|
||||
const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false);
|
||||
const [showDrawer, setShowDrawer] = useState(false);
|
||||
const [selectedLabels, setSelectedLabels] = useState<ITaskLabel[]>([]);
|
||||
|
||||
// Add refs for tooltip elements
|
||||
const changeStatusRef = useRef(null);
|
||||
const changeLabelRef = useRef(null);
|
||||
const assignToMeRef = useRef(null);
|
||||
const changeAssigneesRef = useRef(null);
|
||||
const archiveRef = useRef(null);
|
||||
const deleteRef = useRef(null);
|
||||
const moreOptionsRef = useRef(null);
|
||||
const deselectAllRef = useRef(null);
|
||||
|
||||
// Handlers
|
||||
const handleChangeStatus = async (status: ITaskStatus) => {
|
||||
if (!status.id || !projectId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const body: IBulkTasksStatusChangeRequest = {
|
||||
tasks: selectedTaskIdsList,
|
||||
status_id: status.id,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
}
|
||||
for (const it of selectedTaskIdsList) {
|
||||
const canContinue = await checkTaskDependencyStatus(it, status.id);
|
||||
if (!canContinue) {
|
||||
if (selectedTaskIdsList.length > 1) {
|
||||
alertService.warning(
|
||||
'Incomplete Dependencies!',
|
||||
'Some tasks were not updated. Please ensure all dependent tasks are completed before proceeding.'
|
||||
);
|
||||
} else {
|
||||
alertService.error(
|
||||
'Task is not completed',
|
||||
'Please complete the task dependencies before proceeding'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing status:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePriority = async (priority: ITaskPriority) => {
|
||||
if (!priority.id || !projectId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const body: IBulkTasksPriorityChangeRequest = {
|
||||
tasks: selectedTaskIdsList,
|
||||
priority_id: priority.id,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing priority:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePhase = async (phase: ITaskPhase) => {
|
||||
if (!phase.id || !projectId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const body: IBulkTasksPhaseChangeRequest = {
|
||||
tasks: selectedTaskIdsList,
|
||||
phase_id: phase.id,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing phase:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignToMe = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setUpdatingAssignToMe(true);
|
||||
const body = {
|
||||
tasks: selectedTaskIdsList,
|
||||
project_id: projectId,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.assignToMe(body);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_assign_me);
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error assigning to me:', error);
|
||||
} finally {
|
||||
setUpdatingAssignToMe(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setUpdatingArchive(true);
|
||||
const body = {
|
||||
tasks: selectedTaskIdsList,
|
||||
project_id: projectId,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.archiveTasks(body, archived);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_archive);
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error archiving tasks:', error);
|
||||
} finally {
|
||||
setUpdatingArchive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeAssignees = async (selectedAssignees: ITeamMemberViewModel[]) => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setUpdatingAssignees(true);
|
||||
const body = {
|
||||
tasks: selectedTaskIdsList,
|
||||
project_id: projectId,
|
||||
members: selectedAssignees.map(member => ({
|
||||
id: member.id,
|
||||
name: member.name,
|
||||
email: member.email,
|
||||
avatar_url: member.avatar_url,
|
||||
})) as ITaskAssignee[],
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.assignTasks(body);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error assigning tasks:', error);
|
||||
} finally {
|
||||
setUpdatingAssignees(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setUpdatingDelete(true);
|
||||
const body = {
|
||||
tasks: selectedTaskIdsList,
|
||||
project_id: projectId,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.deleteTasks(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_delete);
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting tasks:', error);
|
||||
} finally {
|
||||
setUpdatingDelete(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Menu Generators
|
||||
const getChangeOptionsMenu = () => [
|
||||
{
|
||||
key: '1',
|
||||
label: t('status'),
|
||||
children: statusList.map(status => ({
|
||||
key: status.id,
|
||||
onClick: () => handleChangeStatus(status),
|
||||
label: <Badge color={status.color_code} text={status.name} />,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: t('priority'),
|
||||
children: priorityList.map(priority => ({
|
||||
key: priority.id,
|
||||
onClick: () => handleChangePriority(priority),
|
||||
label: <Badge color={priority.color_code} text={priority.name} />,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: t('phase'),
|
||||
children: phaseList.map(phase => ({
|
||||
key: phase.id,
|
||||
onClick: () => handleChangePhase(phase),
|
||||
label: <Badge color={phase.color_code} text={phase.name} />,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
const getLabel = () => {
|
||||
const word = selectedTaskIdsList.length < 2 ? t('taskSelected') : t('tasksSelected');
|
||||
return `${selectedTaskIdsList.length} ${word}`;
|
||||
};
|
||||
useEffect(() => {
|
||||
if (members?.data && assigneeDropdownOpen) {
|
||||
let sortedMembers = sortTeamMembers(members.data);
|
||||
setTeamMembersSorted({ data: sortedMembers, total: members.total });
|
||||
}
|
||||
}, [assigneeDropdownOpen, members?.data]);
|
||||
|
||||
const getAssigneesMenu = () => {
|
||||
return (
|
||||
<AssigneesDropdown
|
||||
members={teamMembersSorted?.data || []}
|
||||
themeMode={themeMode}
|
||||
onApply={handleChangeAssignees}
|
||||
onClose={() => setAssigneeDropdownOpen(false)}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const buttonStyle = { background: colors.transparent, color: colors.white };
|
||||
|
||||
const handleLabelChange = (e: CheckboxChangeEvent, label: ITaskLabel) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedLabels(prev => [...prev, label]);
|
||||
} else {
|
||||
setSelectedLabels(prev => prev.filter(l => l.id !== label.id));
|
||||
}
|
||||
};
|
||||
|
||||
const applyLabels = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setUpdatingLabels(true);
|
||||
const body: IBulkTasksLabelsRequest = {
|
||||
tasks: selectedTaskIdsList,
|
||||
labels: selectedLabels,
|
||||
text:
|
||||
selectedLabels.length > 0
|
||||
? null
|
||||
: createLabelText.trim() !== ''
|
||||
? createLabelText.trim()
|
||||
: null,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
dispatch(fetchLabels()); // Fallback: refetch all labels
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
setCreateLabelText('');
|
||||
setSelectedLabels([]);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating labels:', error);
|
||||
} finally {
|
||||
setUpdatingLabels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const labelsDropdownContent = (
|
||||
<LabelsDropdown
|
||||
labelsList={labelsList}
|
||||
themeMode={themeMode}
|
||||
createLabelText={createLabelText}
|
||||
selectedLabels={selectedLabels}
|
||||
labelsInputRef={labelsInputRef as React.RefObject<InputRef>}
|
||||
onLabelChange={handleLabelChange}
|
||||
onCreateLabelTextChange={value => setCreateLabelText(value)}
|
||||
onApply={applyLabels}
|
||||
t={t}
|
||||
loading={updatingLabels}
|
||||
/>
|
||||
);
|
||||
|
||||
const onAssigneeDropdownOpenChange = (open: boolean) => {
|
||||
if (!open) {
|
||||
setAssigneeDropdownOpen(false);
|
||||
} else {
|
||||
setAssigneeDropdownOpen(true);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className={`bulk-actions ${selectedTaskIdsList.length > 0 ? 'open' : ''}`}>
|
||||
<Flex className="bulk-actions-inner" align="center" justify="center" gap={12}>
|
||||
<Flex>
|
||||
<span style={{ fontSize: 14, fontWeight: 500 }}>{getLabel()}</span>
|
||||
</Flex>
|
||||
|
||||
<Flex align="center">
|
||||
<Tooltip title={t('changeStatus')} getPopupContainer={() => changeStatusRef.current!}>
|
||||
<div ref={changeStatusRef}>
|
||||
<Dropdown
|
||||
menu={{ items: getChangeOptionsMenu() }}
|
||||
placement="bottom"
|
||||
arrow
|
||||
trigger={['click']}
|
||||
getPopupContainer={() => changeStatusRef.current!}
|
||||
>
|
||||
<Button
|
||||
icon={<RetweetOutlined />}
|
||||
className="borderless-icon-btn"
|
||||
style={buttonStyle}
|
||||
loading={loading}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={t('changeLabel')} getPopupContainer={() => changeLabelRef.current!}>
|
||||
<div ref={changeLabelRef}>
|
||||
<Dropdown
|
||||
dropdownRender={() => labelsDropdownContent}
|
||||
placement="top"
|
||||
arrow
|
||||
trigger={['click']}
|
||||
destroyPopupOnHide
|
||||
onOpenChange={value => {
|
||||
if (!value) {
|
||||
setSelectedLabels([]);
|
||||
}
|
||||
}}
|
||||
getPopupContainer={() => changeLabelRef.current!}
|
||||
>
|
||||
<Button
|
||||
icon={<TagsOutlined />}
|
||||
className="borderless-icon-btn"
|
||||
style={buttonStyle}
|
||||
loading={updatingLabels}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={t('assignToMe')} getPopupContainer={() => assignToMeRef.current!}>
|
||||
<div ref={assignToMeRef}>
|
||||
<Button
|
||||
icon={<UserAddOutlined />}
|
||||
className="borderless-icon-btn"
|
||||
style={buttonStyle}
|
||||
onClick={handleAssignToMe}
|
||||
loading={updatingAssignToMe}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
title={t('changeAssignees')}
|
||||
getPopupContainer={() => changeAssigneesRef.current!}
|
||||
>
|
||||
<div ref={changeAssigneesRef}>
|
||||
<Dropdown
|
||||
dropdownRender={getAssigneesMenu}
|
||||
open={assigneeDropdownOpen}
|
||||
onOpenChange={onAssigneeDropdownOpenChange}
|
||||
placement="top"
|
||||
arrow
|
||||
trigger={['click']}
|
||||
getPopupContainer={() => changeAssigneesRef.current!}
|
||||
>
|
||||
<Button
|
||||
icon={<UsergroupAddOutlined />}
|
||||
className="borderless-icon-btn"
|
||||
style={buttonStyle}
|
||||
loading={updatingAssignees}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip
|
||||
title={archived ? t('unarchive') : t('archive')}
|
||||
getPopupContainer={() => archiveRef.current!}
|
||||
>
|
||||
<div ref={archiveRef}>
|
||||
<Button
|
||||
icon={<InboxOutlined />}
|
||||
className="borderless-icon-btn"
|
||||
style={buttonStyle}
|
||||
onClick={handleArchive}
|
||||
loading={updatingArchive}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title={t('delete')} getPopupContainer={() => deleteRef.current!}>
|
||||
<div ref={deleteRef}>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
className="borderless-icon-btn"
|
||||
style={buttonStyle}
|
||||
onClick={handleDelete}
|
||||
loading={updatingDelete}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
|
||||
{isOwnerOrAdmin && (
|
||||
<Tooltip title={t('moreOptions')} getPopupContainer={() => moreOptionsRef.current!}>
|
||||
<div ref={moreOptionsRef}>
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: '1',
|
||||
label: t('createTaskTemplate'),
|
||||
onClick: () => setShowDrawer(true),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<MoreOutlined />}
|
||||
className="borderless-icon-btn"
|
||||
style={buttonStyle}
|
||||
/>
|
||||
</Dropdown>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip title={t('deselectAll')} getPopupContainer={() => deselectAllRef.current!}>
|
||||
<div ref={deselectAllRef}>
|
||||
<Button
|
||||
icon={<CloseCircleOutlined />}
|
||||
onClick={() => dispatch(deselectAll())}
|
||||
className="borderless-icon-btn"
|
||||
style={buttonStyle}
|
||||
/>
|
||||
</div>
|
||||
</Tooltip>
|
||||
{createPortal(
|
||||
<TaskTemplateDrawer
|
||||
showDrawer={showDrawer}
|
||||
selectedTemplateId={null}
|
||||
onClose={() => {
|
||||
setShowDrawer(false);
|
||||
dispatch(deselectAll());
|
||||
}}
|
||||
/>,
|
||||
document.body,
|
||||
'create-task-template'
|
||||
)}
|
||||
{createPortal(<ConvertToSubtaskDrawer />, document.body, 'convert-to-subtask-modal')}
|
||||
{createPortal(<CustomColumnModal />, document.body, 'custom-column-modal')}
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListBulkActionsBar;
|
||||
@@ -0,0 +1,153 @@
|
||||
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
|
||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
||||
import { calculateTimeGap } from '@/utils/calculate-time-gap';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
|
||||
import { formatDate } from '@/utils/timeUtils';
|
||||
import { PlayCircleFilled } from '@ant-design/icons';
|
||||
import { Flex, Button, Popover, Typography, Divider, Skeleton } from 'antd/es';
|
||||
import React from 'react';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface TaskTimerProps {
|
||||
started: boolean;
|
||||
handleStartTimer: () => void;
|
||||
handleStopTimer: () => void;
|
||||
timeString: string;
|
||||
taskId: string;
|
||||
}
|
||||
|
||||
const TaskTimer = ({
|
||||
started,
|
||||
handleStartTimer,
|
||||
handleStopTimer,
|
||||
timeString,
|
||||
taskId,
|
||||
}: TaskTimerProps) => {
|
||||
const [timeLogs, setTimeLogs] = useState<ITaskLogViewModel[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const renderStopIcon = () => {
|
||||
return (
|
||||
<span
|
||||
className="nz-icon"
|
||||
style={{ fontSize: 8, position: 'relative', top: -1, left: 0, right: 0, bottom: 0 }}
|
||||
>
|
||||
<svg viewBox="0 0 1024 1024" width="1em" height="1em" fill="currentColor">
|
||||
<path d="M864 64H160C107 64 64 107 64 160v704c0 53 43 96 96 96h704c53 0 96-43 96-96V160c0-53-43-96-96-96z"></path>
|
||||
</svg>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderLoggedByTimer = (log: ITaskLogViewModel) => {
|
||||
if (!log.logged_by_timer) return null;
|
||||
return (
|
||||
<>
|
||||
via Timer about{' '}
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||
{log.logged_by_timer}
|
||||
</Typography.Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
const formatTimeSpent = (seconds: number): string => {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
const remainingSeconds = seconds % 60;
|
||||
|
||||
// Pad numbers with leading zeros if needed
|
||||
const pad = (num: number) => num.toString().padStart(1, '0');
|
||||
|
||||
if (hours >= 1) {
|
||||
return `${pad(hours)}h ${pad(minutes)}m ${pad(remainingSeconds)}s`;
|
||||
} else {
|
||||
return `${pad(minutes)}m ${pad(remainingSeconds)}s`;
|
||||
}
|
||||
};
|
||||
|
||||
const timeTrackingLogCard = (
|
||||
<Flex vertical style={{ width: '100%', maxWidth: 400, maxHeight: 350, overflowY: 'scroll' }}>
|
||||
<Skeleton active loading={loading}>
|
||||
{timeLogs.map(log => (
|
||||
<React.Fragment key={log.id}>
|
||||
<Flex gap={12} align="center" wrap="wrap">
|
||||
<SingleAvatar avatarUrl={log.avatar_url} name={log.user_name} />
|
||||
<Flex vertical style={{ flex: 1, minWidth: 0 }}>
|
||||
<Typography style={{ fontSize: 15, wordBreak: 'break-word' }}>
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||
{log.user_name}
|
||||
</Typography.Text>
|
||||
logged
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||
{formatTimeSpent(log.time_spent || 0)}
|
||||
</Typography.Text>{' '}
|
||||
{renderLoggedByTimer(log)}
|
||||
{calculateTimeGap(log.created_at || '')}
|
||||
</Typography>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatDateTimeWithLocale(log.created_at || '')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Divider style={{ marginBlock: 12 }} />
|
||||
</React.Fragment>
|
||||
))}
|
||||
</Skeleton>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
const getTaskLogs = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await taskTimeLogsApiService.getByTask(taskId);
|
||||
if (response.done) {
|
||||
setTimeLogs(response.body || []);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching task logs', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenChange = (visible: boolean) => {
|
||||
if (visible) {
|
||||
getTaskLogs();
|
||||
} else {
|
||||
setTimeLogs([]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
{started ? (
|
||||
<Button type="text" icon={renderStopIcon()} onClick={handleStopTimer} />
|
||||
) : (
|
||||
<Button
|
||||
type="text"
|
||||
icon={<PlayCircleFilled style={{ color: colors.skyBlue, fontSize: 16 }} />}
|
||||
onClick={handleStartTimer}
|
||||
/>
|
||||
)}
|
||||
<Popover
|
||||
title={
|
||||
<Typography.Text style={{ fontWeight: 500 }}>
|
||||
Time Tracking Log
|
||||
<Divider style={{ marginBlockStart: 8, marginBlockEnd: 12 }} />
|
||||
</Typography.Text>
|
||||
}
|
||||
content={timeTrackingLogCard}
|
||||
trigger="click"
|
||||
placement="bottomRight"
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
<Typography.Text style={{ cursor: 'pointer' }}>{timeString}</Typography.Text>
|
||||
</Popover>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskTimer;
|
||||
Reference in New Issue
Block a user