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

View File

@@ -0,0 +1,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}&nbsp;
{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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}&nbsp;
{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;

View File

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

View File

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

View File

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

View File

@@ -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}&nbsp;
</Typography.Text>
logged&nbsp;
<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;