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,168 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {
Button,
Card,
Checkbox,
Divider,
Dropdown,
Empty,
Flex,
Input,
InputRef,
List,
Typography,
} from 'antd';
import React, { useMemo, useRef, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleProjectMemberDrawer } from '../../../features/projects/singleProject/members/projectMembersSlice';
import CustomAvatar from '../../CustomAvatar';
import { colors } from '../../../styles/colors';
import { PlusOutlined, UsergroupAddOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { ITaskAssignee } from '@/types/tasks/task.types';
interface AssigneeSelectorProps {
taskId: string | undefined | null;
currentAssignees: ITaskAssignee[] | string[];
}
const AssigneeSelector = ({ taskId, currentAssignees }: AssigneeSelectorProps) => {
const membersInputRef = useRef<InputRef>(null);
// this is for get the current string that type on search bar
const [searchQuery, setSearchQuery] = useState<string>('');
// localization
const { t } = useTranslation('task-list-table');
const dispatch = useAppDispatch();
// get members list from members reducer
const membersList = [
...useAppSelector(state => state.memberReducer.membersList),
useAppSelector(state => state.memberReducer.owner),
];
// used useMemo hook for re render the list when searching
const filteredMembersData = useMemo(() => {
return membersList.filter(member =>
member.memberName.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [membersList, searchQuery]);
// function to handle invite project member drawer
const handleInviteProjectMemberDrawer = () => {
dispatch(toggleProjectMemberDrawer());
};
// function to focus members input
const handleMembersDropdownOpen = (open: boolean) => {
if (open) {
setTimeout(() => {
membersInputRef.current?.focus();
}, 0);
}
};
// custom dropdown content
const membersDropdownContent = (
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
<Flex vertical gap={8}>
<Input
ref={membersInputRef}
value={searchQuery}
onChange={e => setSearchQuery(e.currentTarget.value)}
placeholder={t('searchInputPlaceholder')}
/>
<List style={{ padding: 0 }}>
{filteredMembersData.length ? (
filteredMembersData.map(member => (
<List.Item
className="custom-list-item"
key={member.memberId}
style={{
display: 'flex',
gap: 8,
justifyContent: 'flex-start',
padding: '4px 8px',
border: 'none',
}}
>
<Checkbox id={member.memberId} onChange={() => {}} />
<div>
<CustomAvatar avatarName={member.memberName} />
</div>
<Flex vertical>
{member.memberName}
<Typography.Text
style={{
fontSize: 12,
color: colors.lightGray,
}}
>
{member.memberEmail}
</Typography.Text>
</Flex>
</List.Item>
))
) : (
<Empty />
)}
</List>
<Divider style={{ marginBlock: 0 }} />
<Button
icon={<UsergroupAddOutlined />}
type="text"
style={{
color: colors.skyBlue,
border: 'none',
backgroundColor: colors.transparent,
width: '100%',
}}
onClick={handleInviteProjectMemberDrawer}
>
{t('assigneeSelectorInviteButton')}
</Button>
{/* <Divider style={{ marginBlock: 8 }} />
<Button type="primary" style={{ alignSelf: 'flex-end' }}>
{t('okButton')}
</Button> */}
</Flex>
</Card>
);
return (
<Dropdown
overlayClassName="custom-dropdown"
trigger={['click']}
dropdownRender={() => membersDropdownContent}
onOpenChange={handleMembersDropdownOpen}
>
<Button
type="dashed"
shape="circle"
size="small"
icon={
<PlusOutlined
style={{
fontSize: 12,
width: 22,
height: 22,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
/>
}
/>
</Dropdown>
);
};
export default AssigneeSelector;

View File

@@ -0,0 +1,252 @@
import { Drawer, Tag, Typography, Flex, Table, Button, Tooltip, Skeleton } from 'antd/es';
import { useTranslation } from 'react-i18next';
import { useMemo, useState } from 'react';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useAppSelector } from '@/hooks/useAppSelector';
import {
fetchTaskGroups,
IGroupBy,
setConvertToSubtaskDrawerOpen,
updateTaskStatus,
} from '@/features/tasks/tasks.slice';
import { RightOutlined } from '@ant-design/icons';
import CustomSearchbar from '@/components/CustomSearchbar';
import { ITaskListConfigV2, tasksApiService } from '@/api/tasks/tasks.api.service';
import { SocketEvents } from '@/shared/socket-events';
import { useSocket } from '@/socket/socketContext';
import { useAuthService } from '@/hooks/useAuth';
import logger from '@/utils/errorLogger';
import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
const ConvertToSubtaskDrawer = () => {
const { t } = useTranslation('task-list-table');
const { socket, connected } = useSocket();
const currentSession = useAuthService().getCurrentSession();
const dispatch = useAppDispatch();
const { convertToSubtaskDrawerOpen, groupBy } = useAppSelector(state => state.taskReducer);
const selectedTask = useAppSelector(state => state.bulkActionReducer.selectedTasks[0]);
const [searchText, setSearchText] = useState('');
const [expandedGroups, setExpandedGroups] = useState<boolean[]>([]);
const [converting, setConverting] = useState(false);
const [taskGroups, setTaskGroups] = useState<ITaskListGroup[]>([]);
const [loading, setLoading] = useState(false);
const { projectId } = useAppSelector(state => state.projectReducer);
const fetchTasks = async () => {
if (!projectId) return;
try {
setLoading(true);
const config: ITaskListConfigV2 = {
id: projectId,
group: groupBy,
field: null,
order: null,
search: null,
statuses: null,
members: null,
projects: null,
isSubtasksInclude: false,
};
const response = await tasksApiService.getTaskList(config);
if (response.done) {
setTaskGroups(response.body);
}
} catch (error) {
logger.error('Error fetching tasks:', error);
} finally {
setLoading(false);
}
};
const toggleGroup = (index: number) => {
const newExpanded = [...expandedGroups];
newExpanded[index] = !newExpanded[index];
setExpandedGroups(newExpanded);
};
const handleStatusChange = (statusId: string) => {
if (!selectedTask?.id || !statusId) return;
socket?.emit(
SocketEvents.TASK_STATUS_CHANGE.toString(),
JSON.stringify({
task_id: selectedTask?.id,
status_id: statusId,
team_id: currentSession?.team_id,
})
);
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), selectedTask?.id);
};
const handlePriorityChange = (priorityId: string) => {
if (!selectedTask?.id || !priorityId) return;
socket?.emit(
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
JSON.stringify({
task_id: selectedTask?.id,
priority_id: priorityId,
team_id: currentSession?.team_id,
})
);
};
const convertToSubTask = async (
toGroupId: string | undefined,
parentTaskId: string | undefined
) => {
if (!toGroupId || !parentTaskId || !selectedTask?.id || !projectId) return;
try {
// setConverting(true);
// if (groupBy === IGroupBy.STATUS) {
// handleStatusChange(toGroupId);
// }
// if (groupBy === IGroupBy.PRIORITY) {
// handlePriorityChange(toGroupId);
// }
const res = await tasksApiService.convertToSubtask(
selectedTask?.id,
projectId,
parentTaskId,
groupBy,
toGroupId
);
if (res.done) {
dispatch(deselectAll());
dispatch(fetchTaskGroups(projectId));
fetchTasks();
dispatch(setConvertToSubtaskDrawerOpen(false));
}
setConverting(false);
} catch (error) {
logger.error('Error converting to subtask:', error);
} finally {
setConverting(false);
}
};
const filteredTasks = useMemo(
() =>
taskGroups
.map(group => ({
...group,
tasks: group.tasks.filter(task =>
task?.name?.toLowerCase().includes(searchText.toLowerCase())
),
}))
.filter(group => group.tasks.length > 0),
[searchText, taskGroups]
);
return (
<Drawer
open={convertToSubtaskDrawerOpen}
onClose={() => {
dispatch(setConvertToSubtaskDrawerOpen(false));
setSearchText('');
setTaskGroups([]);
}}
title={t('contextMenu.convertToSubTask')}
width={700}
afterOpenChange={() => fetchTasks()}
>
<Flex vertical gap={12}>
<CustomSearchbar
searchQuery={searchText}
setSearchQuery={setSearchText}
placeholderText={t('contextMenu.searchByNameInputPlaceholder')}
/>
</Flex>
{loading ? (
<Skeleton active className="mt-4" />
) : (
filteredTasks.map((item, index) => (
<div key={`group-${item.id}`}>
<Button
key={`group-button-${item.id}`}
className="w-full"
style={{
backgroundColor: item.color_code,
border: 'none',
borderBottomLeftRadius: expandedGroups[index] ? 0 : 4,
borderBottomRightRadius: expandedGroups[index] ? 0 : 4,
color: '#000',
marginTop: 8,
justifyContent: 'flex-start',
width: 'auto',
}}
onClick={() => toggleGroup(index)}
>
<Flex key={`group-flex-${item.id}`} align="center" gap={8}>
<RightOutlined rotate={expandedGroups[index] ? 90 : 0} />
<Typography.Text strong>{item.name}</Typography.Text>
</Flex>
</Button>
<div
key={`group-content-${item.id}`}
style={{
borderLeft: `3px solid ${item.color_code}`,
transition: 'all 0.3s ease-in-out',
maxHeight: expandedGroups[index] ? '2000px' : '0',
opacity: expandedGroups[index] ? 1 : 0,
overflow: expandedGroups[index] ? 'visible' : 'hidden',
}}
>
<Table
key={`group-table-${item.id}`}
size="small"
columns={[
{
title: '',
dataIndex: 'task_key',
key: 'task_key',
width: 100,
className: 'text-center',
render: (text: string) => <Tag key={`tag-${text}`}>{text}</Tag>,
},
{
title: 'Task',
dataIndex: 'name',
key: 'name',
render: (text: string) => (
<Tooltip title={text}>
<Typography.Text
style={{
width: 520,
}}
ellipsis={{ tooltip: text }}
>
{text}
</Typography.Text>
</Tooltip>
),
},
]}
dataSource={item.tasks.filter(
task => !task.parent_task_id && selectedTask?.id !== task.id
)}
pagination={false}
scroll={{ x: 'max-content' }}
onRow={record => {
return {
onClick: () => convertToSubTask(item.id, record.id),
style: { height: 38, cursor: 'pointer' },
className: 'group even:bg-[#4e4e4e10]',
key: `task-row-${record.id}`,
};
}}
/>
</div>
</div>
))
)}
</Drawer>
);
};
export default ConvertToSubtaskDrawer;

View File

@@ -0,0 +1,50 @@
import { Select, Tag, Tooltip } from 'antd';
import { PhaseColorCodes } from '../../../shared/constants';
import { useTranslation } from 'react-i18next';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
const ColorChangedLabel = ({ label }: { label: ITaskLabel | null }) => {
// localization
const { t } = useTranslation('labelsSettings');
// color options for the labels
const colorsOptions = PhaseColorCodes.map(color => ({
key: color,
value: color,
label: (
<Tag
color={color}
style={{
display: 'flex',
alignItems: 'center',
justifyItems: 'center',
height: 22,
width: 'fit-content',
}}
>
{label?.name}
</Tag>
),
}));
return (
<Tooltip title={t('colorChangeTooltip')}>
<Select
key={label?.id}
options={colorsOptions}
variant="borderless"
style={{
display: 'flex',
alignItems: 'center',
justifyItems: 'center',
height: 22,
maxWidth: 160,
}}
defaultValue={label?.color_code}
suffixIcon={null}
/>
</Tooltip>
);
};
export default ColorChangedLabel;

View File

@@ -0,0 +1,27 @@
import { Tag, Typography } from 'antd';
import { colors } from '@/styles/colors';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
import { ALPHA_CHANNEL } from '@/shared/constants';
const CustomColorLabel = ({ label }: { label: ITaskLabel | null }) => {
return (
<Tag
key={label?.id}
color={label?.color_code + ALPHA_CHANNEL}
style={{
display: 'flex',
alignItems: 'center',
justifyItems: 'center',
height: 18,
width: 'fit-content',
fontSize: 11,
}}
>
<Typography.Text style={{ fontSize: 11, color: colors.darkGray }}>
{label?.name}
</Typography.Text>
</Tag>
);
};
export default CustomColorLabel;

View File

@@ -0,0 +1,26 @@
import { Tag, Tooltip } from 'antd';
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
const CustomNumberLabel = ({ labelList }: { labelList: ITaskLabel[] | null }) => {
const list = labelList?.slice(2);
const labelNamesStirng = list?.map(label => label.names).join(', ');
return (
<Tooltip title={labelNamesStirng}>
<Tag
style={{
display: 'flex',
alignItems: 'center',
justifyItems: 'center',
height: 18,
fontSize: 11,
}}
>
+{list?.length}
</Tag>
</Tooltip>
);
};
export default CustomNumberLabel;

View File

@@ -0,0 +1,170 @@
/* eslint-disable react-hooks/exhaustive-deps */
import { PlusOutlined } from '@ant-design/icons';
import {
Badge,
Button,
Card,
Checkbox,
Divider,
Dropdown,
Flex,
Input,
InputRef,
List,
Typography,
} from 'antd';
import React, { useMemo, useRef, useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { colors } from '@/styles/colors';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { nanoid } from '@reduxjs/toolkit';
import { addLabel } from '@features/settings/label/labelSlice';
import { useTranslation } from 'react-i18next';
import { ITaskLabel } from '@/types/label.type';
interface LabelsSelectorProps {
taskId: string | null;
labels: ITaskLabel[];
}
const LabelsSelector = ({ taskId, labels }: LabelsSelectorProps) => {
const labelInputRef = useRef<InputRef>(null);
// this is for get the current string that type on search bar
const [searchQuery, setSearchQuery] = useState<string>('');
// localization
const { t } = useTranslation('task-list-table');
const dispatch = useAppDispatch();
// get task list from redux and find the selected task
const selectedTask = useAppSelector(state => state.taskReducer.tasks).find(
task => task.id === taskId
);
// used useMemo hook for re-render the list when searching
const filteredLabelData = useMemo(() => {
return labels.filter(label =>
label.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
}, [labels, searchQuery]);
const handleCreateLabel = (name: string) => {
if (name.length > 0) {
const newLabel: ITaskLabel = {
id: nanoid(),
name,
color_code: '#1E90FF',
};
dispatch(addLabel(newLabel));
setSearchQuery('');
}
};
// custom dropdown content
const labelDropdownContent = (
<Card
className="custom-card"
styles={{ body: { padding: 8, overflow: 'hidden', overflowY: 'auto', maxHeight: '255px' } }}
>
<Flex vertical gap={8}>
<Input
ref={labelInputRef}
value={searchQuery}
onChange={e => setSearchQuery(e.currentTarget.value)}
placeholder={t('searchInputPlaceholder')}
onKeyDown={e => {
const isLabel = filteredLabelData.findIndex(
label => label.name?.toLowerCase() === searchQuery.toLowerCase()
);
if (isLabel === -1) {
if (e.key === 'Enter') {
handleCreateLabel(searchQuery);
}
}
}}
/>
<List style={{ padding: 0 }}>
{filteredLabelData.length ? (
filteredLabelData.map(label => (
<List.Item
className="custom-list-item"
key={label.id}
style={{
display: 'flex',
justifyContent: 'flex-start',
gap: 8,
padding: '4px 8px',
border: 'none',
}}
>
<Checkbox
id={label.id}
checked={
selectedTask?.labels
? selectedTask?.labels.some(
existingLabel => existingLabel.id === label.id
)
: false
}
onChange={() => console.log(123)}
/>
<Flex gap={8}>
<Badge color={label.color_code} />
{label.name}
</Flex>
</List.Item>
))
) : (
<Typography.Text
style={{ color: colors.lightGray }}
onClick={() => handleCreateLabel(searchQuery)}
>
{t('labelSelectorInputTip')}
</Typography.Text>
)}
</List>
<Divider style={{ margin: 0 }} />
<Button
type="primary"
style={{ alignSelf: 'flex-end' }}
onClick={() => handleCreateLabel(searchQuery)}
>
{t('okButton')}
</Button>
</Flex>
</Card>
);
// function to focus label input
const handleLabelDropdownOpen = (open: boolean) => {
if (open) {
setTimeout(() => {
labelInputRef.current?.focus();
}, 0);
}
};
return (
<Dropdown
trigger={['click']}
dropdownRender={() => labelDropdownContent}
onOpenChange={handleLabelDropdownOpen}
>
<Button
type="dashed"
icon={<PlusOutlined style={{ fontSize: 11 }} />}
style={{ height: 18 }}
size="small"
/>
</Dropdown>
);
};
export default LabelsSelector;

View File

@@ -0,0 +1,66 @@
import { Badge, Flex, Select, Typography } from 'antd';
import React, { useState } from 'react';
// custom css file
import './phaseDropdown.css';
import { useAppSelector } from '@/hooks/useAppSelector';
import { PhaseOption } from '../../../types/phase.types';
import { colors } from '../../../styles/colors';
import { useTranslation } from 'react-i18next';
const PhaseDropdown = ({ projectId }: { projectId: string }) => {
const [currentPhaseOption, setCurrentPhaseOption] = useState<PhaseOption | null>(null);
// localization
const { t } = useTranslation('task-list-table');
// get phase data from redux
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
//get phases details from phases slice
const phase = phaseList.find(el => el.projectId === projectId);
const handlePhaseOptionSelect = (value: string) => {
const selectedOption = phase?.phaseOptions.find(option => option.optionId === value);
if (selectedOption) {
setCurrentPhaseOption(selectedOption);
}
};
return (
<Select
value={currentPhaseOption?.optionId}
placeholder={
<Typography.Text type="secondary" style={{ fontSize: 13 }}>
{t('selectText')}
</Typography.Text>
}
onChange={handlePhaseOptionSelect}
style={{
width: 'fit-content',
minWidth: 120,
}}
dropdownStyle={{
padding: 0,
}}
variant={'borderless'}
>
{phase?.phaseOptions.map(option => (
<Select.Option key={option.optionId} value={option.optionId}>
<Flex gap={4} align="center">
<Badge color={option.optionColor} />
<Typography.Text
style={{
fontSize: 13,
color: colors.darkGray,
}}
>
{option.optionName}
</Typography.Text>
</Flex>
</Select.Option>
))}
</Select>
);
};
export default PhaseDropdown;

View File

@@ -0,0 +1,19 @@
.phase-dropdown .ant-dropdown-menu {
padding: 0 !important;
margin-top: 8px !important;
overflow: hidden;
}
.phase-dropdown .ant-dropdown-menu-item {
padding: 0 !important;
}
.phase-dropdown-card .ant-card-body {
padding: 0 !important;
}
.phase-menu .ant-menu-item {
display: flex;
align-items: center;
height: 32px;
}

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,110 @@
import { Flex, Select, Typography } from 'antd';
import './priority-dropdown.css';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useState, useEffect, useMemo } from 'react';
import { ALPHA_CHANNEL } from '@/shared/constants';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
import { DoubleLeftOutlined, MinusOutlined, PauseOutlined } from '@ant-design/icons';
type PriorityDropdownProps = {
task: IProjectTask;
teamId: string;
};
const PriorityDropdown = ({ task, teamId }: PriorityDropdownProps) => {
const { socket } = useSocket();
const [selectedPriority, setSelectedPriority] = useState<ITaskPriority | undefined>(undefined);
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const handlePriorityChange = (priorityId: string) => {
if (!task.id || !priorityId) return;
socket?.emit(
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
priority_id: priorityId,
team_id: teamId,
})
);
};
useEffect(() => {
const foundPriority = priorityList.find(priority => priority.id === task.priority);
setSelectedPriority(foundPriority);
}, [task.priority, priorityList]);
const options = useMemo(
() =>
priorityList.map(priority => ({
value: priority.id,
label: (
<Flex gap={8} align="center" justify="space-between">
{priority.name}
{priority.name === 'Low' && (
<MinusOutlined
style={{
color: themeMode === 'dark' ? priority.color_code_dark : priority.color_code,
}}
/>
)}
{priority.name === 'Medium' && (
<PauseOutlined
style={{
color: themeMode === 'dark' ? priority.color_code_dark : priority.color_code,
transform: 'rotate(90deg)',
}}
/>
)}
{priority.name === 'High' && (
<DoubleLeftOutlined
style={{
color: themeMode === 'dark' ? priority.color_code_dark : priority.color_code,
transform: 'rotate(90deg)',
}}
/>
)}
</Flex>
),
})),
[priorityList, themeMode]
);
return (
<>
{task.priority && (
<Select
variant="borderless"
value={task.priority}
onChange={handlePriorityChange}
dropdownStyle={{ borderRadius: 8, minWidth: 150, maxWidth: 200 }}
style={{
backgroundColor:
themeMode === 'dark'
? selectedPriority?.color_code_dark
: selectedPriority?.color_code + ALPHA_CHANNEL,
borderRadius: 16,
height: 22,
}}
labelRender={value => {
const priority = priorityList.find(priority => priority.id === value.value);
return priority ? (
<Typography.Text style={{ fontSize: 13, color: '#383838' }}>
{priority.name}
</Typography.Text>
) : (
''
);
}}
options={options}
/>
)}
</>
);
};
export default PriorityDropdown;

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: 41px !important;
}

View File

@@ -0,0 +1,77 @@
import { Flex, Select } from 'antd';
import './status-dropdown.css';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useMemo } from 'react';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { getCurrentGroup, GROUP_BY_STATUS_VALUE } from '@/features/tasks/tasks.slice';
type StatusDropdownProps = {
task: IProjectTask;
teamId: string;
};
const StatusDropdown = ({ task, teamId }: StatusDropdownProps) => {
const { socket } = useSocket();
const statusList = useAppSelector(state => state.taskStatusReducer.status);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const handleStatusChange = (statusId: string) => {
if (!task.id || !statusId) return;
socket?.emit(
SocketEvents.TASK_STATUS_CHANGE.toString(),
JSON.stringify({
task_id: task.id,
status_id: statusId,
parent_task: task.parent_task_id || null,
team_id: teamId,
})
);
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
};
const isGroupByStatus = () => {
return getCurrentGroup().value === GROUP_BY_STATUS_VALUE;
};
const options = useMemo(
() =>
statusList.map(status => ({
value: status.id,
label: status.name,
color: themeMode === 'dark' ? status.color_code_dark : status.color_code,
})),
[statusList, themeMode]
);
return (
<>
{task.status && (
<Select
variant="borderless"
value={task.status}
onChange={handleStatusChange}
dropdownStyle={{ borderRadius: 8, minWidth: 150, maxWidth: 200 }}
style={{
backgroundColor: themeMode === 'dark' ? task.status_color_dark : task.status_color,
borderRadius: 16,
height: 22,
}}
labelRender={status => {
return status ? <span style={{ fontSize: 13 }}>{status.label}</span> : '';
}}
options={options}
optionRender={(option) => (
<Flex align="center">
{option.label}
</Flex>
)}
/>
)}
</>
);
};
export default StatusDropdown;

View File

@@ -0,0 +1,23 @@
import { TimePicker, TimePickerProps } from 'antd';
type TaskRowDueTimeProps = {
dueTime: string;
};
const TaskRowDueTime = ({ dueTime }: TaskRowDueTimeProps) => {
// function to trigger time change
const onTimeChange: TimePickerProps['onChange'] = (time, timeString) => {
console.log(time, timeString);
};
return (
<TimePicker
format="HH:mm"
changeOnScroll
onChange={onTimeChange}
style={{ border: 'none', background: 'transparent' }}
/>
);
};
export default TaskRowDueTime;

View File

@@ -0,0 +1,19 @@
import { Typography } from 'antd';
const TaskRowDescription = ({ description }: { description: string }) => {
return (
<div
style={{
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
display: 'block',
maxHeight: '24px', // Enforce single line height
lineHeight: '24px',
}}
dangerouslySetInnerHTML={{ __html: description }}
/>
);
};
export default TaskRowDescription;

View File

@@ -0,0 +1,125 @@
// TaskNameCell.tsx
import React, { useCallback } from 'react';
import { Flex, Typography, Button } from 'antd';
import {
DoubleRightOutlined,
DownOutlined,
RightOutlined,
ExpandAltOutlined,
} from '@ant-design/icons';
import { colors } from '@/styles/colors';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
import { useTranslation } from 'react-i18next';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
type TaskRowNameProps = {
task: IProjectTask;
isSubTask?: boolean;
expandedTasks: string[];
setSelectedTaskId: (taskId: string) => void;
toggleTaskExpansion: (taskId: string) => void;
};
const TaskRowName = React.memo(
({
task,
isSubTask = false,
expandedTasks,
setSelectedTaskId,
toggleTaskExpansion,
}: TaskRowNameProps) => {
// localization
const { t } = useTranslation('task-list-table');
const dispatch = useAppDispatch();
const handleToggleExpansion = useCallback(
(taskId: string) => {
toggleTaskExpansion(taskId);
},
[toggleTaskExpansion]
);
const handleSelectTask = useCallback(() => {
if (!task.id) return;
setSelectedTaskId(task.id);
dispatch(setShowTaskDrawer(true));
}, [dispatch, setSelectedTaskId, task.id]);
// render the toggle arrow icon for tasks with subtasks
const renderToggleButton = (taskId: string, hasSubtasks: boolean) => {
if (!hasSubtasks) return null;
return (
<button
onClick={() => handleToggleExpansion(taskId)}
className="hover flex h-4 w-4 items-center justify-center rounded text-[12px] hover:border hover:border-[#5587f5] hover:bg-[#d0eefa54] transition duration-150"
>
{expandedTasks.includes(taskId) ? <DownOutlined /> : <RightOutlined />}
</button>
);
};
// render the double arrow icon and count label for tasks with subtasks
const renderSubtasksCountLabel = (
taskId: string,
isSubTask: boolean,
subTasksCount: number
) => {
return (
!isSubTask && (
<Button
onClick={() => handleToggleExpansion(taskId)}
size="small"
style={{
display: 'flex',
gap: 2,
paddingInline: 4,
alignItems: 'center',
justifyItems: 'center',
border: 'none',
}}
>
<Typography.Text style={{ fontSize: 12, lineHeight: 1 }}>
{subTasksCount}
</Typography.Text>
<DoubleRightOutlined style={{ fontSize: 10 }} />
</Button>
)
);
};
return (
<Flex align="center" justify="space-between" className="relative group">
<Flex gap={8} align="center">
{task?.sub_tasks?.length && task.id ? (
renderToggleButton(task.id, !!task?.sub_tasks?.length)
) : (
<div className="h-4 w-4"></div>
)}
{isSubTask && <DoubleRightOutlined style={{ fontSize: 12 }} />}
<Typography.Text ellipsis={{ expanded: false }}>{task.name}</Typography.Text>
{renderSubtasksCountLabel(task.id || '', isSubTask, task?.sub_tasks?.length || 0)}
</Flex>
<Button
type="text"
icon={<ExpandAltOutlined />}
onClick={handleSelectTask}
className="invisible group-hover:visible"
style={{
backgroundColor: colors.transparent,
padding: 0,
height: 'fit-content',
}}
>
{t('openButton')}
</Button>
</Flex>
);
}
);
export default TaskRowName;

View File

@@ -0,0 +1,27 @@
/* Set the stroke width to 9px for the progress circle */
.task-progress.ant-progress-circle .ant-progress-circle-path {
stroke-width: 9px !important;
/* Adjust the stroke width */
}
/* Adjust the inner check mark for better alignment and visibility */
.task-progress.ant-progress-circle.ant-progress-status-success .ant-progress-inner .anticon-check {
font-size: 8px;
/* Adjust font size for the check mark */
color: green;
/* Optional: Set a color */
transform: translate(-50%, -50%);
/* Center align */
position: absolute;
top: 50%;
left: 50%;
padding: 0;
width: 8px;
}
/* Adjust the text inside the progress circle */
.task-progress.ant-progress-circle .ant-progress-text {
font-size: 10px;
/* Ensure the text size fits well */
line-height: 1;
}

View File

@@ -0,0 +1,31 @@
import { Progress, Tooltip } from 'antd';
import './task-row-progress.css';
import React from 'react';
type TaskRowProgressProps = {
progress: number;
numberOfSubTasks: number;
};
const TaskRowProgress = React.memo(
({ progress = 0, numberOfSubTasks = 0 }: TaskRowProgressProps) => {
const totalTasks = numberOfSubTasks + 1;
const completedTasks = 0;
const size = progress === 100 ? 21 : 26;
return (
<Tooltip title={`${completedTasks} / ${totalTasks}`}>
<Progress
percent={progress}
type="circle"
size={size}
style={{ cursor: 'default' }}
className="task-progress"
/>
</Tooltip>
);
}
);
export default TaskRowProgress;

View File

@@ -0,0 +1,69 @@
import React, { useMemo } from 'react';
import { Divider, Empty, Flex, Popover, Typography } from 'antd';
import { PlayCircleFilled } from '@ant-design/icons';
import { colors } from '@/styles/colors';
import CustomAvatar from '@components/CustomAvatar';
import { mockTimeLogs } from '@/pages/projects/project-view-1/taskList/taskListTable/taskListTableCells/mockTimeLogs';
type TaskListTimeTrackerCellProps = {
taskId: string | null;
initialTime?: number;
};
const TaskListTimeTrackerCell = ({ taskId, initialTime = 0 }: TaskListTimeTrackerCellProps) => {
const minutes = Math.floor(initialTime / 60);
const seconds = initialTime % 60;
const formattedTime = `${minutes}m ${seconds}s`;
const timeTrackingLogCard = useMemo(() => {
if (initialTime > 0) {
return (
<Flex vertical style={{ width: 400, height: 300, overflowY: 'scroll' }}>
{mockTimeLogs.map(log => (
<React.Fragment key={log.logId}>
<Flex gap={8} align="center">
<CustomAvatar avatarName={log.username} />
<Flex vertical>
<Typography>
<Typography.Text strong>{log.username}</Typography.Text>
<Typography.Text>{` logged ${log.duration} ${
log.via ? `via ${log.via}` : ''
}`}</Typography.Text>
</Typography>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{log.date}
</Typography.Text>
</Flex>
</Flex>
<Divider style={{ marginBlock: 12 }} />
</React.Fragment>
))}
</Flex>
);
} else {
return <Empty style={{ width: 400 }} />;
}
}, [initialTime]);
return (
<Flex gap={4} align="center">
<PlayCircleFilled style={{ color: colors.skyBlue, fontSize: 16 }} />
<Popover
title={
<Typography.Text style={{ fontWeight: 500 }}>
Time Tracking Log
<Divider style={{ marginBlockStart: 8, marginBlockEnd: 12 }} />
</Typography.Text>
}
content={timeTrackingLogCard}
trigger="click"
placement="bottomRight"
>
<Typography.Text style={{ cursor: 'pointer' }}>{formattedTime}</Typography.Text>
</Popover>
</Flex>
);
};
export default TaskListTimeTrackerCell;