init
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
import { Timeline, Typography, Flex, ConfigProvider, Tag, Tooltip, Skeleton } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowRightOutlined } from '@ant-design/icons';
|
||||
|
||||
import {
|
||||
IActivityLog,
|
||||
IActivityLogAttributeTypes,
|
||||
IActivityLogsResponse,
|
||||
} from '@/types/tasks/task-activity-logs-get-request';
|
||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||
import { taskActivityLogsApiService } from '@/api/tasks/task-activity-logs.api.service';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { calculateTimeGap } from '@/utils/calculate-time-gap';
|
||||
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
const TaskDrawerActivityLog = () => {
|
||||
const [activityLogs, setActivityLogs] = useState<IActivityLogsResponse>({});
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const { selectedTaskId, taskFormViewModel } = useAppSelector(state => state.taskDrawerReducer);
|
||||
const { mode: themeMode } = useAppSelector(state => state.themeReducer);
|
||||
const { t } = useTranslation();
|
||||
|
||||
useEffect(()=>{
|
||||
fetchActivityLogs();
|
||||
},[taskFormViewModel]);
|
||||
|
||||
const fetchActivityLogs = async () => {
|
||||
if (!selectedTaskId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await taskActivityLogsApiService.getActivityLogsByTaskId(selectedTaskId);
|
||||
if (res.done) {
|
||||
setActivityLogs(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching activity logs', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderAttributeType = (activity: IActivityLog) => {
|
||||
const truncateText = (text?: string) => {
|
||||
if (!text) return text;
|
||||
const div = document.createElement('div');
|
||||
div.innerHTML = text;
|
||||
const plainText = div.textContent || div.innerText || '';
|
||||
return plainText.length > 28 ? `${plainText.slice(0, 27)}...` : plainText;
|
||||
};
|
||||
|
||||
switch (activity.attribute_type) {
|
||||
case IActivityLogAttributeTypes.ASSIGNEES:
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
<SingleAvatar
|
||||
avatarUrl={activity.assigned_user?.avatar_url}
|
||||
name={activity.assigned_user?.name}
|
||||
/>
|
||||
<Typography.Text>{truncateText(activity.assigned_user?.name)}</Typography.Text>
|
||||
<ArrowRightOutlined />
|
||||
<Tag color={'default'}>{truncateText(activity.log_type?.toUpperCase())}</Tag>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case IActivityLogAttributeTypes.LABEL:
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
<Tag color={activity.label_data?.color_code}>{truncateText(activity.label_data?.name)}</Tag>
|
||||
<ArrowRightOutlined />
|
||||
<Tag color={'default'}>{activity.log_type === 'create' ? 'ADD' : 'REMOVE'}</Tag>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case IActivityLogAttributeTypes.STATUS:
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
<Tag color={themeMode === 'dark' ? activity.previous_status?.color_code_dark : activity.previous_status?.color_code}>
|
||||
{truncateText(activity.previous_status?.name) || 'None'}
|
||||
</Tag>
|
||||
<ArrowRightOutlined />
|
||||
<Tag color={themeMode === 'dark' ? activity.next_status?.color_code_dark : activity.next_status?.color_code}>
|
||||
{truncateText(activity.next_status?.name) || 'None'}
|
||||
</Tag>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case IActivityLogAttributeTypes.PRIORITY:
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
<Tag color={themeMode === 'dark' ? activity.previous_priority?.color_code_dark : activity.previous_priority?.color_code}>
|
||||
{truncateText(activity.previous_priority?.name) || 'None'}
|
||||
</Tag>
|
||||
<ArrowRightOutlined />
|
||||
<Tag color={themeMode === 'dark' ? activity.next_priority?.color_code_dark : activity.next_priority?.color_code}>
|
||||
{truncateText(activity.next_priority?.name) || 'None'}
|
||||
</Tag>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
case IActivityLogAttributeTypes.PHASE:
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
<Tag color={activity.previous_phase?.color_code}>
|
||||
{truncateText(activity.previous_phase?.name) || 'None'}
|
||||
</Tag>
|
||||
<ArrowRightOutlined />
|
||||
<Tag color={activity.next_phase?.color_code}>
|
||||
{truncateText(activity.next_phase?.name) || 'None'}
|
||||
</Tag>
|
||||
</Flex>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Flex gap={4} align="center">
|
||||
<Tag color={'default'}>{truncateText(activity.previous) || 'None'}</Tag>
|
||||
<ArrowRightOutlined />
|
||||
<Tag color={'default'}>{truncateText(activity.current) || 'None'}</Tag>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
!loading && fetchActivityLogs();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Timeline: { itemPaddingBottom: 32, dotBorderWidth: '1.5px' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Skeleton active loading={loading}>
|
||||
<Timeline style={{ marginBlockStart: 24 }}>
|
||||
{activityLogs.logs?.map((activity, index) => (
|
||||
<Timeline.Item key={index}>
|
||||
<Flex gap={8} align="center">
|
||||
<SingleAvatar
|
||||
avatarUrl={activity.done_by?.avatar_url}
|
||||
name={activity.done_by?.name}
|
||||
/>
|
||||
<Flex vertical gap={4}>
|
||||
<Flex gap={4} align="center">
|
||||
<Typography.Text strong>{activity.done_by?.name}</Typography.Text>
|
||||
<Typography.Text>{activity.log_text}</Typography.Text>
|
||||
<Typography.Text strong>{activity.attribute_type}.</Typography.Text>
|
||||
<Tooltip
|
||||
title={
|
||||
activity.created_at ? formatDateTimeWithLocale(activity.created_at) : ''
|
||||
}
|
||||
>
|
||||
<Typography.Text strong type="secondary">
|
||||
{activity.created_at ? calculateTimeGap(activity.created_at) : ''}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
{renderAttributeType(activity)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
<Timeline.Item>
|
||||
<Flex gap={8} align="center">
|
||||
<SingleAvatar avatarUrl={activityLogs.avatar_url} name={activityLogs.name} />
|
||||
<Flex vertical gap={4}>
|
||||
<Flex gap={4} align="center">
|
||||
<Typography.Text strong>{activityLogs.name}</Typography.Text>
|
||||
<Typography.Text>created the task.</Typography.Text>
|
||||
<Tooltip
|
||||
title={
|
||||
activityLogs.created_at
|
||||
? formatDateTimeWithLocale(activityLogs.created_at)
|
||||
: ''
|
||||
}
|
||||
>
|
||||
<Typography.Text strong type="secondary">
|
||||
{activityLogs.created_at ? calculateTimeGap(activityLogs.created_at) : ''}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Timeline.Item>
|
||||
</Timeline>
|
||||
</Skeleton>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDrawerActivityLog;
|
||||
@@ -0,0 +1,62 @@
|
||||
import { ITaskAttachmentViewModel } from '@/types/tasks/task-attachment-view-model';
|
||||
import AttachmentsPreview from './attachments-preview';
|
||||
import './attachments-preview.css';
|
||||
import type { RcFile, UploadProps } from 'antd/es/upload';
|
||||
import { TFunction } from 'i18next';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import { message } from 'antd';
|
||||
import AttachmentsUpload from './attachments-upload';
|
||||
|
||||
interface AttachmentsGridProps {
|
||||
attachments: ITaskAttachmentViewModel[];
|
||||
onDelete?: (id: string) => void;
|
||||
onUpload?: (file: RcFile) => void;
|
||||
isCommentAttachment?: boolean;
|
||||
t: TFunction;
|
||||
loadingTask: boolean;
|
||||
uploading: boolean;
|
||||
handleFilesSelected: (files: File[]) => void;
|
||||
}
|
||||
|
||||
const AttachmentsGrid = ({
|
||||
attachments,
|
||||
onDelete,
|
||||
onUpload,
|
||||
isCommentAttachment = false,
|
||||
t,
|
||||
loadingTask,
|
||||
uploading,
|
||||
handleFilesSelected,
|
||||
}: AttachmentsGridProps) => {
|
||||
const handleUpload: UploadProps['beforeUpload'] = file => {
|
||||
if (onUpload) {
|
||||
onUpload(file);
|
||||
}
|
||||
return false; // Prevent default upload behavior
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="attachments-container">
|
||||
<div className="attachments-grid">
|
||||
{attachments.map(attachment => (
|
||||
<AttachmentsPreview
|
||||
key={attachment.id}
|
||||
attachment={attachment}
|
||||
onDelete={onDelete}
|
||||
isCommentAttachment={isCommentAttachment}
|
||||
/>
|
||||
))}
|
||||
{!isCommentAttachment && (
|
||||
<AttachmentsUpload
|
||||
t={t}
|
||||
loadingTask={loadingTask}
|
||||
uploading={uploading}
|
||||
onFilesSelected={handleFilesSelected}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentsGrid;
|
||||
@@ -0,0 +1,229 @@
|
||||
.file-icon {
|
||||
width: 30px;
|
||||
position: absolute;
|
||||
z-index: 1;
|
||||
background: #fff;
|
||||
top: -7px;
|
||||
left: -7px;
|
||||
border-radius: 4px;
|
||||
padding: 3px 0px;
|
||||
border: 1px solid #d9d9d9;
|
||||
}
|
||||
|
||||
ngx-doc-viewer {
|
||||
width: 100%;
|
||||
height: 80vh;
|
||||
}
|
||||
|
||||
.preview-container {
|
||||
max-width: 1024px;
|
||||
}
|
||||
|
||||
video {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
nz-spin {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.ant-upload-span {
|
||||
min-height: 86px;
|
||||
max-width: 86px;
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-position: center;
|
||||
}
|
||||
|
||||
/* Container for all attachments */
|
||||
.attachments-container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Row layout for attachment previews instead of grid */
|
||||
.attachments-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
/* Empty state container */
|
||||
.empty-attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
padding: 20px 0;
|
||||
}
|
||||
|
||||
/* Container styling */
|
||||
.ant-upload-list-picture-card-container {
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Item styling */
|
||||
.ant-upload-list-item {
|
||||
padding: 8px;
|
||||
border: 1px solid #5f5f5f;
|
||||
border-radius: 2px;
|
||||
position: relative;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.ant-upload-list-item-info {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.file-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
left: 4px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.ant-upload-span {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
}
|
||||
|
||||
.ant-upload-list-item-thumbnail {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* Action buttons styling with hover effect */
|
||||
.ant-upload-list-item-actions {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.ant-upload-list-item:hover .ant-upload-list-item-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.ant-upload-list-item-card-actions-btn {
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
/* Preview modal styling */
|
||||
.attachment-preview-modal .preview-container {
|
||||
min-height: 200px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.attachment-preview-modal .loader {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.attachment-preview-modal img.img-fluid,
|
||||
.attachment-preview-modal video,
|
||||
.attachment-preview-modal audio {
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
}
|
||||
|
||||
.position-relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Upload button styling */
|
||||
.upload-button-container {
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s;
|
||||
}
|
||||
|
||||
.upload-button-container-centered {
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 2px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s;
|
||||
margin: 20px auto;
|
||||
}
|
||||
|
||||
.upload-button-container:hover,
|
||||
.upload-button-container-centered:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.upload-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #8c8c8c;
|
||||
}
|
||||
|
||||
.upload-button .anticon {
|
||||
font-size: 24px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.upload-button-text {
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
import { useState } from "react";
|
||||
import { ITaskAttachmentViewModel } from "@/types/tasks/task-attachment-view-model";
|
||||
import { Button, Modal, Spin, Tooltip, Typography, Popconfirm, message } from "antd";
|
||||
import { EyeOutlined, DownloadOutlined, DeleteOutlined, QuestionCircleOutlined, LoadingOutlined } from "@ant-design/icons";
|
||||
import { attachmentsApiService } from "@/api/attachments/attachments.api.service";
|
||||
import { IconsMap } from "@/shared/constants";
|
||||
import './attachments-preview.css';
|
||||
import taskAttachmentsApiService from "@/api/tasks/task-attachments.api.service";
|
||||
import logger from "@/utils/errorLogger";
|
||||
import taskCommentsApiService from "@/api/tasks/task-comments.api.service";
|
||||
import { useAppSelector } from "@/hooks/useAppSelector";
|
||||
|
||||
interface AttachmentsPreviewProps {
|
||||
attachment: ITaskAttachmentViewModel;
|
||||
onDelete?: (id: string) => void;
|
||||
isCommentAttachment?: boolean;
|
||||
}
|
||||
|
||||
const AttachmentsPreview = ({
|
||||
attachment,
|
||||
onDelete,
|
||||
isCommentAttachment = false
|
||||
}: AttachmentsPreviewProps) => {
|
||||
const { selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [currentFileUrl, setCurrentFileUrl] = useState<string | null>(null);
|
||||
const [currentFileType, setCurrentFileType] = useState<string | null>(null);
|
||||
const [previewWidth, setPreviewWidth] = useState(768);
|
||||
const [downloading, setDownloading] = useState(false);
|
||||
const [previewedFileId, setPreviewedFileId] = useState<string | null>(null);
|
||||
const [previewedFileName, setPreviewedFileName] = useState<string | null>(null);
|
||||
|
||||
const getFileIcon = (type?: string) => {
|
||||
if (!type) return "search.png";
|
||||
return IconsMap[type] || "search.png";
|
||||
};
|
||||
|
||||
const isImageFile = (): boolean => {
|
||||
const imageTypes = ["jpeg", "jpg", "bmp", "gif", "webp", "png", "ico"];
|
||||
const type = attachment?.type;
|
||||
if (type) return imageTypes.includes(type);
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsVisible(false);
|
||||
setPreviewedFileId(null);
|
||||
setPreviewedFileName(null);
|
||||
};
|
||||
|
||||
const download = async (id?: string, name?: string) => {
|
||||
if (!id || !name) return;
|
||||
try {
|
||||
setDownloading(true);
|
||||
const api = isCommentAttachment
|
||||
? attachmentsApiService.downloadAttachment // Assuming this exists, adjust as needed
|
||||
: attachmentsApiService.downloadAttachment;
|
||||
|
||||
const res = await api(id, name);
|
||||
|
||||
if (res && res.done) {
|
||||
const link = document.createElement('a');
|
||||
link.href = res.body || '';
|
||||
link.download = name;
|
||||
link.click();
|
||||
link.remove();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('Failed to download file');
|
||||
} finally {
|
||||
setDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id?: string) => {
|
||||
if (!id || !selectedTaskId) return;
|
||||
try {
|
||||
setDeleting(true);
|
||||
|
||||
if (isCommentAttachment) {
|
||||
const res = await taskCommentsApiService.deleteAttachment(id, selectedTaskId);
|
||||
if (res.done) {
|
||||
document.dispatchEvent(new Event('task-comment-update'));
|
||||
}
|
||||
} else {
|
||||
const res = await taskAttachmentsApiService.deleteTaskAttachment(id);
|
||||
if (res.done) {
|
||||
if (onDelete) {
|
||||
onDelete(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error deleting attachment:', e);
|
||||
message.error('Failed to delete attachment');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isImage = (extension: string): boolean => {
|
||||
return ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'ico'].includes(extension);
|
||||
};
|
||||
|
||||
const isVideo = (extension: string): boolean => {
|
||||
return ['mp4', 'webm', 'ogg'].includes(extension);
|
||||
};
|
||||
|
||||
const isAudio = (extension: string): boolean => {
|
||||
return ['mp3', 'wav', 'ogg'].includes(extension);
|
||||
};
|
||||
|
||||
const isDoc = (extension: string): boolean => {
|
||||
return ['ppt', 'pptx', 'doc', 'docx', 'xls', 'xlsx', 'pdf'].includes(extension);
|
||||
};
|
||||
|
||||
const previewFile = (fileUrl?: string, id?: string, fileName?: string) => {
|
||||
if (!fileUrl || !id || !fileName) return;
|
||||
|
||||
setPreviewedFileId(id);
|
||||
setPreviewedFileName(fileName);
|
||||
|
||||
const extension = (fileUrl as string).split('.').pop()?.toLowerCase();
|
||||
|
||||
if (!extension) return;
|
||||
setIsVisible(true);
|
||||
|
||||
if (isImage(extension)) {
|
||||
setCurrentFileType('image');
|
||||
} else if (isVideo(extension)) {
|
||||
setCurrentFileType('video');
|
||||
} else if (isAudio(extension)) {
|
||||
setPreviewWidth(600);
|
||||
setCurrentFileType('audio');
|
||||
} else if (isDoc(extension)) {
|
||||
setCurrentFileType('document');
|
||||
setPreviewWidth(1024);
|
||||
} else {
|
||||
setPreviewWidth(600);
|
||||
setCurrentFileType('unknown');
|
||||
}
|
||||
|
||||
setCurrentFileUrl(fileUrl);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="ant-upload-list-picture-card-container">
|
||||
{attachment && (
|
||||
<div
|
||||
className="ant-upload-list-item ant-upload-list-item-done ant-upload-list-item-list-type-picture-card"
|
||||
>
|
||||
<Tooltip
|
||||
title={
|
||||
<div>
|
||||
<p style={{ margin: 0 }}>{attachment.name}</p>
|
||||
<p style={{ margin: 0 }}>{attachment.size}</p>
|
||||
<p style={{ margin: 0 }}>
|
||||
{attachment.created_at ? new Date(attachment.created_at).toLocaleString() : ''}
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
placement="bottom"
|
||||
>
|
||||
<div className="ant-upload-list-item-info">
|
||||
<img
|
||||
src={`/file-types/${getFileIcon(attachment.type)}`}
|
||||
className="file-icon"
|
||||
alt=""
|
||||
/>
|
||||
<div
|
||||
className="ant-upload-span"
|
||||
style={{
|
||||
backgroundImage: isImageFile() ? `url(${attachment.url})` : ''
|
||||
}}
|
||||
>
|
||||
<a
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ant-upload-list-item-thumbnail"
|
||||
href={attachment.url}
|
||||
>
|
||||
{!isImageFile() && (
|
||||
<span
|
||||
className="anticon anticon-file-unknown"
|
||||
style={{ fontSize: 34, color: '#cccccc' }}
|
||||
/>
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</Tooltip>
|
||||
|
||||
<span className="ant-upload-list-item-actions">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
title="Preview file"
|
||||
onClick={() => previewFile(attachment.url, attachment.id, attachment.name)}
|
||||
className="ant-upload-list-item-card-actions-btn"
|
||||
>
|
||||
<EyeOutlined />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
title="Download file"
|
||||
onClick={() => download(attachment.id, attachment.name)}
|
||||
loading={downloading}
|
||||
className="ant-upload-list-item-card-actions-btn"
|
||||
>
|
||||
<DownloadOutlined />
|
||||
</Button>
|
||||
|
||||
<Popconfirm
|
||||
title="Delete Attachment"
|
||||
description="Are you sure you want to delete this attachment?"
|
||||
icon={<QuestionCircleOutlined style={{ color: 'red' }} />}
|
||||
onConfirm={() => handleDelete(attachment.id)}
|
||||
okText="Yes"
|
||||
cancelText="No"
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
title="Remove file"
|
||||
loading={deleting}
|
||||
className="ant-upload-list-item-card-actions-btn"
|
||||
>
|
||||
<DeleteOutlined />
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={isVisible}
|
||||
title={<Typography.Text>{attachment?.name}</Typography.Text>}
|
||||
centered
|
||||
onCancel={handleCancel}
|
||||
width={previewWidth}
|
||||
className="attachment-preview-modal"
|
||||
footer={[
|
||||
previewedFileId && previewedFileName && (
|
||||
<Button
|
||||
key="download"
|
||||
onClick={() => download(previewedFileId, previewedFileName)}
|
||||
loading={downloading}
|
||||
>
|
||||
<DownloadOutlined /> Download
|
||||
</Button>
|
||||
)
|
||||
]}
|
||||
>
|
||||
<div className="preview-container text-center position-relative">
|
||||
{currentFileType === 'image' && (
|
||||
<>
|
||||
<img src={currentFileUrl || ''} className="img-fluid position-relative" alt="" />
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentFileType === 'video' && (
|
||||
<>
|
||||
<video className="position-relative" controls>
|
||||
<source src={currentFileUrl || ''} type="video/mp4" />
|
||||
</video>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentFileType === 'audio' && (
|
||||
<>
|
||||
<audio className="position-relative" controls>
|
||||
<source src={currentFileUrl || ''} type="audio/mpeg" />
|
||||
</audio>
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentFileType === 'document' && (
|
||||
<>
|
||||
{currentFileUrl && (
|
||||
<iframe
|
||||
src={`https://docs.google.com/viewer?url=${encodeURIComponent(currentFileUrl)}&embedded=true`}
|
||||
width="100%"
|
||||
height="500px"
|
||||
style={{ border: 'none' }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{currentFileType === 'unknown' && (
|
||||
<p>The preview for this file type is not available.</p>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentsPreview;
|
||||
@@ -0,0 +1,39 @@
|
||||
.ant-upload-list {
|
||||
border: 1px dashed transparent;
|
||||
transition: 0.15s all;
|
||||
}
|
||||
|
||||
.ant-upload-list.focused {
|
||||
border-color: #1890ff;
|
||||
background-color: #FAFAFA;
|
||||
}
|
||||
|
||||
.ant-upload-select-picture-card {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.ant-upload-select-picture-card .ant-upload {
|
||||
width: 104px;
|
||||
height: 104px;
|
||||
border: 1px dashed #d9d9d9;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.3s;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ant-upload-select-picture-card .ant-upload:hover {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.ant-upload-select-picture-card .ant-upload > div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { LoadingOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import React, { useRef, useState } from 'react';
|
||||
import { TFunction } from 'i18next';
|
||||
import './attachments-upload.css';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
|
||||
interface AttachmentsUploadProps {
|
||||
t: TFunction;
|
||||
loadingTask: boolean;
|
||||
uploading: boolean;
|
||||
onFilesSelected: (files: File[]) => void;
|
||||
}
|
||||
|
||||
const AttachmentsUpload = ({
|
||||
t,
|
||||
loadingTask,
|
||||
uploading,
|
||||
onFilesSelected
|
||||
}: AttachmentsUploadProps) => {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (event.target.files && event.target.files.length > 0) {
|
||||
const filesArray = Array.from(event.target.files);
|
||||
onFilesSelected(filesArray);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (!loadingTask && !uploading && fileInputRef.current) {
|
||||
fileInputRef.current.click();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
setIsDragOver(false);
|
||||
|
||||
if (!loadingTask && !uploading && e.dataTransfer.files.length > 0) {
|
||||
const filesArray = Array.from(e.dataTransfer.files);
|
||||
onFilesSelected(filesArray);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`ant-upload-list ant-upload-list-picture-card ${isDragOver ? 'focused' : ''}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<div className="ant-upload ant-upload-select ant-upload-select-picture-card">
|
||||
<div
|
||||
className="ant-upload"
|
||||
tabIndex={0}
|
||||
role="button"
|
||||
onClick={handleClick}
|
||||
style={{
|
||||
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
|
||||
}}
|
||||
>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
disabled={loadingTask || uploading}
|
||||
multiple
|
||||
/>
|
||||
<div>
|
||||
{uploading ? <LoadingOutlined spin /> : <PlusOutlined />}
|
||||
<div style={{
|
||||
marginTop: '8px',
|
||||
fontSize: '11px',
|
||||
marginLeft: 'auto',
|
||||
marginRight: 'auto',
|
||||
paddingLeft: '8px',
|
||||
paddingRight: '8px'
|
||||
}}>
|
||||
{uploading ? t('taskInfoTab.attachments.uploading') : t('taskInfoTab.attachments.chooseOrDropFileToUpload')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AttachmentsUpload;
|
||||
@@ -0,0 +1,288 @@
|
||||
.ant-skeleton {
|
||||
margin-top: -16px;
|
||||
}
|
||||
|
||||
.ant-comment-content p {
|
||||
user-select: text;
|
||||
margin-bottom: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Mentions styling with dark mode support */
|
||||
.mentions {
|
||||
font-weight: 500;
|
||||
border-radius: 4px;
|
||||
padding: 1px 4px;
|
||||
margin: 0 1px;
|
||||
}
|
||||
|
||||
.theme-light .mentions,
|
||||
.comment-content-light .mentions {
|
||||
background: #f0f2f5;
|
||||
color: #1890ff;
|
||||
border: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.theme-dark .mentions,
|
||||
.comment-content-dark .mentions {
|
||||
background: #2a3a4a;
|
||||
color: #40a9ff;
|
||||
border: 1px solid #2a4a6d;
|
||||
}
|
||||
|
||||
.ant-upload-list {
|
||||
border: 1px dashed transparent;
|
||||
transition: 0.15s all;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
.focused {
|
||||
border-color: #1890ff;
|
||||
background-color: #FAFAFA;
|
||||
}
|
||||
|
||||
/* Conversation-like styles */
|
||||
.task-view-comments {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
/* Base comment styles */
|
||||
.theme-light .ant-comment,
|
||||
.theme-dark .ant-comment {
|
||||
position: relative;
|
||||
margin-bottom: 12px;
|
||||
padding: 0;
|
||||
box-shadow: none;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* Comment content bubble styles */
|
||||
.theme-light .ant-comment-content-detail,
|
||||
.theme-dark .ant-comment-content-detail {
|
||||
position: relative;
|
||||
border-radius: 12px;
|
||||
padding: 8px 12px;
|
||||
margin-top: 2px;
|
||||
max-width: 85%;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.theme-light .ant-comment-content-detail {
|
||||
background-color: #f0f2f5;
|
||||
border: 1px solid #e6e6e6;
|
||||
}
|
||||
|
||||
.theme-dark .ant-comment-content-detail {
|
||||
background-color: #2a2a2a;
|
||||
border: 1px solid #333;
|
||||
}
|
||||
|
||||
/* Current user comment styles */
|
||||
.current-user-comment {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.current-user-comment .ant-comment-inner {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.current-user-comment .ant-comment-content {
|
||||
margin-right: 8px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.current-user-comment .ant-comment-content-author {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-right: 6px;
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
.current-user-comment .ant-comment-content-detail {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.theme-light .current-user-comment .ant-comment-content-detail {
|
||||
background-color: #e1f3ff;
|
||||
border-color: #c2e4f9;
|
||||
}
|
||||
|
||||
.theme-dark .current-user-comment .ant-comment-content-detail {
|
||||
background-color: #1e3a5f;
|
||||
border-color: #2a4a6d;
|
||||
}
|
||||
|
||||
/* Mentions in current user comments */
|
||||
.theme-light .current-user-comment .mentions {
|
||||
background: #d1e9ff;
|
||||
border-color: #b3d8ff;
|
||||
}
|
||||
|
||||
.theme-dark .current-user-comment .mentions {
|
||||
background: #2a4a6d;
|
||||
border-color: #3a5a7d;
|
||||
}
|
||||
|
||||
.current-user-comment .ant-comment-actions {
|
||||
margin-left: auto;
|
||||
padding-right: 12px;
|
||||
padding-left: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* Comment author and timestamp */
|
||||
.ant-comment-content-author {
|
||||
margin-bottom: 0;
|
||||
padding-left: 6px;
|
||||
}
|
||||
|
||||
/* Reduce the space between author and content */
|
||||
.ant-comment-inner {
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
/* Comment actions */
|
||||
.ant-comment-actions {
|
||||
margin-top: 2px;
|
||||
padding-left: 12px;
|
||||
}
|
||||
|
||||
/* Comment content */
|
||||
.comment-content-light {
|
||||
color: #333;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.comment-content-dark {
|
||||
color: #d1d0d3;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Attachments in comments */
|
||||
.ant-comment-content-detail .ant-upload-list {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.theme-dark .ant-upload-list-item {
|
||||
background-color: #2a2a2a;
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.theme-dark .ant-upload-list-item-info {
|
||||
background-color: #2a2a2a;
|
||||
}
|
||||
|
||||
/* Comment actions styling */
|
||||
.theme-light .ant-comment-actions > li > span {
|
||||
color: #707070;
|
||||
transition: color 0.3s;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.theme-dark .ant-comment-actions > li > span {
|
||||
color: #d1d0d3;
|
||||
transition: color 0.3s;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.theme-light .ant-comment-actions > li > span:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.theme-dark .ant-comment-actions > li > span:hover {
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
/* Comment edit styles */
|
||||
.comment-edit-light .ant-form-item,
|
||||
.comment-edit-dark .ant-form-item {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.comment-edit-light .ant-input-textarea,
|
||||
.comment-edit-dark .ant-input-textarea {
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.comment-edit-dark .ant-input {
|
||||
background-color: #2a2a2a;
|
||||
color: #d1d0d3;
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
.comment-edit-dark .ant-input:hover,
|
||||
.comment-edit-dark .ant-input:focus {
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.comment-edit-dark .ant-btn {
|
||||
border-color: #333;
|
||||
background-color: #2a2a2a;
|
||||
color: #d1d0d3;
|
||||
}
|
||||
|
||||
.comment-edit-dark .ant-btn:hover {
|
||||
border-color: #1890ff;
|
||||
color: #1890ff;
|
||||
}
|
||||
|
||||
.comment-edit-dark .ant-btn-primary {
|
||||
background-color: #1890ff;
|
||||
color: #fff;
|
||||
border-color: #1890ff;
|
||||
}
|
||||
|
||||
.comment-edit-dark .ant-btn-primary:hover {
|
||||
background-color: #40a9ff;
|
||||
border-color: #40a9ff;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* Time separator */
|
||||
.comment-time-separator {
|
||||
text-align: center;
|
||||
margin: 10px 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.comment-time-separator::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
background-color: #e6e6e6;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.theme-dark .comment-time-separator::before {
|
||||
background-color: #333;
|
||||
}
|
||||
|
||||
.comment-time-separator span {
|
||||
position: relative;
|
||||
padding: 0 10px;
|
||||
font-size: 11px;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.theme-light .comment-time-separator span {
|
||||
background-color: #fff;
|
||||
color: #707070;
|
||||
}
|
||||
|
||||
.theme-dark .comment-time-separator span {
|
||||
background-color: #1e1e1e;
|
||||
color: #d1d0d3;
|
||||
}
|
||||
|
||||
/* Adjust avatar size for more compact look */
|
||||
.ant-comment-avatar img {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
@@ -0,0 +1,359 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { Skeleton, Avatar, Tooltip, Popconfirm } from 'antd';
|
||||
import { Comment } from '@ant-design/compatible';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { LikeOutlined, LikeTwoTone } from '@ant-design/icons';
|
||||
import { ITaskCommentViewModel } from '@/types/tasks/task-comments.types';
|
||||
import taskCommentsApiService from '@/api/tasks/task-comments.api.service';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { fromNow } from '@/utils/dateUtils';
|
||||
import { AvatarNamesMap } from '@/shared/constants';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import TaskViewCommentEdit from './task-view-comment-edit';
|
||||
import './task-comments.css';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { colors } from '@/styles/colors';
|
||||
import AttachmentsGrid from '../attachments/attachments-grid';
|
||||
import { TFunction } from 'i18next';
|
||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||
|
||||
// Helper function to format date for time separators
|
||||
const formatDateForSeparator = (date: string) => {
|
||||
const today = dayjs();
|
||||
const commentDate = dayjs(date);
|
||||
|
||||
if (commentDate.isSame(today, 'day')) {
|
||||
return 'Today';
|
||||
} else if (commentDate.isSame(today.subtract(1, 'day'), 'day')) {
|
||||
return 'Yesterday';
|
||||
} else {
|
||||
return commentDate.format('MMMM D, YYYY');
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to check if two dates are from different days
|
||||
const isDifferentDay = (date1: string, date2: string) => {
|
||||
return !dayjs(date1).isSame(dayjs(date2), 'day');
|
||||
};
|
||||
|
||||
// Helper function to check if content already has processed mentions
|
||||
const hasProcessedMentions = (content: string): boolean => {
|
||||
return content.includes('<span class="mentions">');
|
||||
};
|
||||
|
||||
// Helper function to process mentions in content
|
||||
const processMentions = (content: string) => {
|
||||
if (!content) return '';
|
||||
|
||||
// Check if content already contains mentions spans
|
||||
if (hasProcessedMentions(content)) {
|
||||
return content; // Already processed, return as is
|
||||
}
|
||||
|
||||
// Replace @mentions with styled spans
|
||||
return content.replace(/@(\w+)/g, '<span class="mentions">@$1</span>');
|
||||
};
|
||||
|
||||
const TaskComments = ({ taskId, t }: { taskId?: string, t: TFunction }) => {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [comments, setComments] = useState<ITaskCommentViewModel[]>([]);
|
||||
const commentsViewRef = useRef<HTMLDivElement>(null);
|
||||
const auth = useAuthService();
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const currentUserId = auth.getCurrentSession()?.id;
|
||||
|
||||
const getComments = useCallback(
|
||||
async (showLoading = true) => {
|
||||
if (!taskId) return;
|
||||
|
||||
try {
|
||||
if (showLoading) {
|
||||
setLoading(true);
|
||||
}
|
||||
|
||||
const res = await taskCommentsApiService.getByTaskId(taskId);
|
||||
if (res.done) {
|
||||
// Sort comments by date (oldest first)
|
||||
const sortedComments = [...res.body].sort((a, b) => {
|
||||
return dayjs(a.created_at).isBefore(dayjs(b.created_at)) ? -1 : 1;
|
||||
});
|
||||
|
||||
// Process mentions in content
|
||||
sortedComments.forEach(comment => {
|
||||
if (comment.content && !hasProcessedMentions(comment.content)) {
|
||||
comment.content = processMentions(comment.content);
|
||||
}
|
||||
});
|
||||
|
||||
setComments(sortedComments);
|
||||
}
|
||||
|
||||
setLoading(false);
|
||||
} catch (e) {
|
||||
logger.error('Error fetching comments', e);
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
[taskId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (taskId) {
|
||||
getComments();
|
||||
}
|
||||
|
||||
return () => {
|
||||
setComments([]);
|
||||
};
|
||||
}, [taskId, getComments]);
|
||||
|
||||
const scrollIntoView = useCallback(() => {
|
||||
commentsViewRef.current?.scrollIntoView();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const handleCommentCreate = () => {
|
||||
getComments(false);
|
||||
scrollIntoView();
|
||||
};
|
||||
|
||||
const handleCommentUpdate = () => {
|
||||
getComments(false);
|
||||
};
|
||||
|
||||
document.addEventListener('task-comment-create', handleCommentCreate);
|
||||
document.addEventListener('task-comment-update', handleCommentUpdate);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('task-comment-create', handleCommentCreate);
|
||||
document.removeEventListener('task-comment-update', handleCommentUpdate);
|
||||
};
|
||||
}, [taskId, getComments, scrollIntoView]);
|
||||
|
||||
const canDelete = (userId?: string) => {
|
||||
if (!userId) return false;
|
||||
return userId === currentUserId;
|
||||
};
|
||||
|
||||
const alreadyLiked = (item: ITaskCommentViewModel) => {
|
||||
const teamMemberId = auth.getCurrentSession()?.team_member_id;
|
||||
if (!teamMemberId) return false;
|
||||
return !!item.reactions?.likes?.liked_member_ids?.includes(teamMemberId);
|
||||
};
|
||||
|
||||
const likeComment = async (item: ITaskCommentViewModel) => {
|
||||
if (!item.id || !taskId) return;
|
||||
|
||||
try {
|
||||
const res = await taskCommentsApiService.updateReaction(item.id, {
|
||||
reaction_type: 'like',
|
||||
task_id: taskId,
|
||||
});
|
||||
if (res.done) {
|
||||
getComments(false);
|
||||
|
||||
// Dispatch event to notify that a comment reaction was updated
|
||||
// Use update event instead of create to avoid scrolling
|
||||
document.dispatchEvent(new Event('task-comment-update'));
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error liking comment', e);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteComment = async (id?: string) => {
|
||||
if (!taskId || !id) return;
|
||||
|
||||
try {
|
||||
const res = await taskCommentsApiService.delete(id, taskId);
|
||||
if (res.done) {
|
||||
await getComments(false);
|
||||
document.dispatchEvent(new Event('task-comment-update'));
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error deleting comment', e);
|
||||
}
|
||||
};
|
||||
|
||||
const editComment = (item: ITaskCommentViewModel) => {
|
||||
item.edit = true;
|
||||
setComments([...comments]); // Force re-render
|
||||
};
|
||||
|
||||
const commentUpdated = (comment: ITaskCommentViewModel) => {
|
||||
comment.edit = false;
|
||||
// Process mentions in updated content
|
||||
if (comment.content && !hasProcessedMentions(comment.content)) {
|
||||
comment.content = processMentions(comment.content);
|
||||
}
|
||||
setComments([...comments]); // Force re-render
|
||||
};
|
||||
|
||||
const deleteAttachment = async (attachmentId: string) => {
|
||||
if (!attachmentId || !taskId) return;
|
||||
|
||||
try {
|
||||
const res = await taskCommentsApiService.deleteAttachment(attachmentId, taskId);
|
||||
if (res.done) {
|
||||
await getComments(false);
|
||||
|
||||
// Dispatch event to notify that an attachment was deleted
|
||||
document.dispatchEvent(new Event('task-comment-update'));
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error deleting attachment', e);
|
||||
}
|
||||
};
|
||||
|
||||
// Theme-aware styles
|
||||
const authorStyle = {
|
||||
color: themeWiseColor(colors.lightGray, colors.deepLightGray, themeMode),
|
||||
fontSize: '12px',
|
||||
};
|
||||
|
||||
const dateStyle = {
|
||||
color: themeWiseColor(colors.deepLightGray, colors.lightGray, themeMode),
|
||||
fontSize: '11px',
|
||||
};
|
||||
|
||||
const actionStyle = {
|
||||
color: themeWiseColor(colors.lightGray, colors.deepLightGray, themeMode),
|
||||
};
|
||||
|
||||
// Render time separator between comments from different days
|
||||
const renderTimeSeparator = (date: string) => (
|
||||
<div className="comment-time-separator">
|
||||
<span
|
||||
style={{
|
||||
backgroundColor: themeWiseColor('#fff', '#1e1e1e', themeMode),
|
||||
}}
|
||||
>
|
||||
{formatDateForSeparator(date)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
// Check if the comment is from the current user
|
||||
const isCurrentUser = (userId?: string) => {
|
||||
return userId === currentUserId;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`task-view-comments theme-${themeMode}`} ref={commentsViewRef}>
|
||||
<Skeleton loading={loading}>
|
||||
{comments.length > 0 ? (
|
||||
<>
|
||||
{comments.map((item, index) => {
|
||||
const isUserComment = isCurrentUser(item.user_id);
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
{/* Add time separator if this is the first comment or if it's from a different day than the previous comment */}
|
||||
{(index === 0 ||
|
||||
(index > 0 &&
|
||||
isDifferentDay(
|
||||
item.created_at || '',
|
||||
comments[index - 1].created_at || ''
|
||||
))) &&
|
||||
renderTimeSeparator(item.created_at || '')}
|
||||
|
||||
<Comment
|
||||
key={item.id}
|
||||
author={<span style={authorStyle}>{item.member_name}</span>}
|
||||
datetime={<span style={dateStyle}>{fromNow(item.created_at || '')}</span>}
|
||||
avatar={
|
||||
<SingleAvatar name={item.member_name} avatarUrl={item.avatar_url}/>
|
||||
}
|
||||
content={
|
||||
item.edit ? (
|
||||
<TaskViewCommentEdit commentData={item} onUpdated={commentUpdated} />
|
||||
) : (
|
||||
<>
|
||||
<p
|
||||
className={`comment-content-${themeMode}`}
|
||||
dangerouslySetInnerHTML={{ __html: item.content || '' }}
|
||||
/>
|
||||
{item.attachments && item.attachments.length > 0 && (
|
||||
<div className="ant-upload-list ant-upload-list-picture-card">
|
||||
<AttachmentsGrid
|
||||
attachments={item.attachments}
|
||||
t={t}
|
||||
loadingTask={false}
|
||||
uploading={false}
|
||||
handleFilesSelected={() => {}}
|
||||
isCommentAttachment={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
actions={[
|
||||
<span key="like" onClick={() => likeComment(item)}>
|
||||
<Tooltip
|
||||
title={
|
||||
item?.reactions?.likes?.count
|
||||
? item.reactions?.likes?.liked_members?.map(member => (
|
||||
<div key={member}>{member}</div>
|
||||
))
|
||||
: null
|
||||
}
|
||||
>
|
||||
{alreadyLiked(item) ? (
|
||||
<LikeTwoTone />
|
||||
) : (
|
||||
<LikeOutlined style={actionStyle} />
|
||||
)}{' '}
|
||||
|
||||
<span className="count like" style={actionStyle}>
|
||||
{item?.reactions?.likes?.count || ''}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</span>,
|
||||
// canDelete(item.user_id) && (
|
||||
// <span
|
||||
// key="edit"
|
||||
// onClick={() => editComment(item)}
|
||||
// style={actionStyle}
|
||||
// >
|
||||
// <EditOutlined />
|
||||
// <span style={{ marginLeft: 4 }}>Edit</span>
|
||||
// </span>
|
||||
// ),
|
||||
canDelete(item.user_id) && (
|
||||
<Popconfirm
|
||||
key="delete"
|
||||
title={t('taskInfoTab.comments.confirmDeleteComment')}
|
||||
onConfirm={() => deleteComment(item.id)}
|
||||
>
|
||||
<span style={actionStyle}>{t('taskInfoTab.comments.delete')}</span>
|
||||
</Popconfirm>
|
||||
),
|
||||
].filter(Boolean)}
|
||||
className={isUserComment ? 'current-user-comment' : ''}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
) : (
|
||||
<div className="empty-comments">
|
||||
<p
|
||||
style={{
|
||||
textAlign: 'center',
|
||||
color: themeWiseColor(colors.lightGray, colors.deepLightGray, themeMode),
|
||||
padding: '16px 0',
|
||||
}}
|
||||
>
|
||||
{t('taskInfoTab.comments.noComments')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Skeleton>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskComments;
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button, Form, Input, Space } from 'antd';
|
||||
import { ITaskCommentViewModel } from '@/types/tasks/task-comments.types';
|
||||
import taskCommentsApiService from '@/api/tasks/task-comments.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { colors } from '@/styles/colors';
|
||||
|
||||
interface TaskViewCommentEditProps {
|
||||
commentData: ITaskCommentViewModel;
|
||||
onUpdated: (comment: ITaskCommentViewModel) => void;
|
||||
}
|
||||
|
||||
// Helper function to prepare content for editing by removing HTML tags
|
||||
const prepareContentForEditing = (content: string): string => {
|
||||
if (!content) return '';
|
||||
|
||||
// Replace mention spans with plain @mentions
|
||||
const withoutMentionSpans = content.replace(/<span class="mentions">@(\w+)<\/span>/g, '@$1');
|
||||
|
||||
// Remove any other HTML tags
|
||||
return withoutMentionSpans.replace(/<[^>]*>/g, '');
|
||||
};
|
||||
|
||||
const TaskViewCommentEdit = ({ commentData, onUpdated }: TaskViewCommentEditProps) => {
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [content, setContent] = useState('');
|
||||
|
||||
// Initialize content when component mounts
|
||||
useEffect(() => {
|
||||
if (commentData.content) {
|
||||
setContent(prepareContentForEditing(commentData.content));
|
||||
}
|
||||
}, [commentData.content]);
|
||||
|
||||
const handleCancel = () => {
|
||||
commentData.edit = false;
|
||||
onUpdated(commentData);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!commentData.id || !commentData.task_id) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await taskCommentsApiService.update(commentData.id, {
|
||||
...commentData,
|
||||
content: content,
|
||||
});
|
||||
|
||||
if (res.done) {
|
||||
commentData.content = content;
|
||||
onUpdated(commentData);
|
||||
|
||||
// Dispatch event to notify that a comment was updated
|
||||
document.dispatchEvent(new Event('task-comment-update'));
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Error updating comment', e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Theme-aware styles
|
||||
const textAreaStyle = {
|
||||
backgroundColor: themeWiseColor('#fff', '#2a2a2a', themeMode),
|
||||
color: themeWiseColor('#333', '#d1d0d3', themeMode),
|
||||
borderColor: themeWiseColor('#d9d9d9', '#333', themeMode),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`comment-edit-${themeMode}`}>
|
||||
<Form layout="vertical">
|
||||
<Form.Item>
|
||||
<Input.TextArea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||||
style={textAreaStyle}
|
||||
placeholder="Type your comment here... Use @username to mention someone"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button onClick={handleCancel}>Cancel</Button>
|
||||
<Button type="primary" loading={loading} onClick={handleSave}>
|
||||
Save
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskViewCommentEdit;
|
||||
@@ -0,0 +1,9 @@
|
||||
/* Add these styles for dependency action buttons */
|
||||
.dependency-actions {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.custom-two-colors-row-table .ant-table-row:hover .dependency-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
import {
|
||||
Button,
|
||||
Col,
|
||||
Flex,
|
||||
Form,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Table,
|
||||
TableProps,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { DeleteOutlined, ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { TFunction } from 'i18next';
|
||||
import { IDependencyType, ITaskDependency } from '@/types/tasks/task-dependency.types';
|
||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import { taskDependenciesApiService } from '@/api/tasks/task-dependencies.api.service';
|
||||
import { tasksApiService } from '@/api/tasks/tasks.api.service';
|
||||
import './dependencies-table.css';
|
||||
|
||||
interface DependenciesTableProps {
|
||||
task: ITaskViewModel;
|
||||
t: TFunction;
|
||||
taskDependencies: ITaskDependency[];
|
||||
loadingTaskDependencies: boolean;
|
||||
refreshTaskDependencies: () => void;
|
||||
}
|
||||
|
||||
const DependenciesTable = ({
|
||||
task,
|
||||
t,
|
||||
taskDependencies,
|
||||
loadingTaskDependencies,
|
||||
refreshTaskDependencies,
|
||||
}: DependenciesTableProps) => {
|
||||
const [hoverRow, setHoverRow] = useState<string | null>(null);
|
||||
const [isDependencyInputShow, setIsDependencyInputShow] = useState(false);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const [taskList, setTaskList] = useState<{ label: string; value: string }[]>([]);
|
||||
const [loadingTaskList, setLoadingTaskList] = useState(false);
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
const handleAddDependency = async (taskId: string) => {
|
||||
console.log('taskId', taskId);
|
||||
if (!task.id) return;
|
||||
|
||||
try {
|
||||
const body: ITaskDependency = {
|
||||
dependency_type: IDependencyType.BLOCKED_BY,
|
||||
task_id: task.id,
|
||||
related_task_id: taskId,
|
||||
};
|
||||
const res = await taskDependenciesApiService.createTaskDependency(body);
|
||||
if (res.done) {
|
||||
refreshTaskDependencies();
|
||||
setIsDependencyInputShow(false);
|
||||
setTaskList([]);
|
||||
setSearchTerm('');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error adding dependency:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearchTask = async (value: string) => {
|
||||
if (!task.id || !projectId) return;
|
||||
setSearchTerm(value);
|
||||
|
||||
try {
|
||||
setLoadingTaskList(true);
|
||||
const res = await tasksApiService.searchTask(task.id, projectId, value);
|
||||
if (res.done) {
|
||||
setTaskList(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error searching tasks:', error);
|
||||
} finally {
|
||||
setLoadingTaskList(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteDependency = async (dependencyId: string | undefined) => {
|
||||
if (!dependencyId) return;
|
||||
try {
|
||||
const res = await taskDependenciesApiService.deleteTaskDependency(dependencyId);
|
||||
if (res.done) {
|
||||
refreshTaskDependencies();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting dependency:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const columns: TableProps<ITaskDependency>['columns'] = [
|
||||
{
|
||||
key: 'name',
|
||||
render: (record: ITaskDependency) => (
|
||||
<Flex align="center" gap={8}>
|
||||
<Typography.Text ellipsis={{ tooltip: record.task_name }}>
|
||||
{record.task_name}
|
||||
</Typography.Text>
|
||||
<Tag>{record.task_key}</Tag>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'blockedBy',
|
||||
render: record => (
|
||||
<Select
|
||||
value={record.dependency_type}
|
||||
options={[{ key: IDependencyType.BLOCKED_BY, value: IDependencyType.BLOCKED_BY, label: 'Blocked By' }]}
|
||||
size="small"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actionBtns',
|
||||
width: 60,
|
||||
render: (record: ITaskDependency) => (
|
||||
<div className="dependency-actions">
|
||||
<Popconfirm
|
||||
title={t('taskInfoTab.dependencies.confirmDeleteDependency')}
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
onConfirm={() => handleDeleteDependency(record.id)}
|
||||
>
|
||||
<Button shape="default" icon={<DeleteOutlined />} size="small" danger />
|
||||
</Popconfirm>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Flex vertical gap={12}>
|
||||
{taskDependencies.length > 0 && (
|
||||
<Table
|
||||
className="custom-two-colors-row-table"
|
||||
showHeader={false}
|
||||
dataSource={taskDependencies}
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
loading={loadingTaskDependencies}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isDependencyInputShow ? (
|
||||
<Form layout="inline">
|
||||
<Row gutter={8} style={{ width: '100%' }}>
|
||||
<Col span={14}>
|
||||
<Form.Item name="taskName" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
placeholder={t('taskInfoTab.dependencies.searchTask')}
|
||||
size="small"
|
||||
showSearch
|
||||
value={searchTerm}
|
||||
onSearch={handleSearchTask}
|
||||
options={taskList}
|
||||
loading={loadingTaskList}
|
||||
onSelect={handleAddDependency}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') {
|
||||
handleAddDependency;
|
||||
}
|
||||
}}
|
||||
filterOption={false}
|
||||
notFoundContent={t('taskInfoTab.dependencies.noTasksFound')}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
<Col span={6}>
|
||||
<Form.Item name="blockedBy" style={{ marginBottom: 0 }}>
|
||||
<Select
|
||||
options={[{ key: IDependencyType.BLOCKED_BY, value: IDependencyType.BLOCKED_BY, label: 'Blocked By' }]}
|
||||
size="small"
|
||||
disabled
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
|
||||
<Col span={4}>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setIsDependencyInputShow(false);
|
||||
setTaskList([]);
|
||||
setSearchTerm('');
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
) : (
|
||||
<Button
|
||||
type="text"
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
color: colors.skyBlue,
|
||||
padding: 0,
|
||||
}}
|
||||
onClick={() => setIsDependencyInputShow(true)}
|
||||
>
|
||||
{t('taskInfoTab.dependencies.addDependency')}
|
||||
</Button>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default DependenciesTable;
|
||||
@@ -0,0 +1,198 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { Editor } from '@tinymce/tinymce-react';
|
||||
import DOMPurify from 'dompurify';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
|
||||
interface DescriptionEditorProps {
|
||||
description: string | null;
|
||||
taskId: string;
|
||||
parentTaskId: string | null;
|
||||
}
|
||||
|
||||
const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEditorProps) => {
|
||||
const { socket } = useSocket();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [isEditorOpen, setIsEditorOpen] = useState<boolean>(false);
|
||||
const [content, setContent] = useState<string>(description || '');
|
||||
const [isEditorLoading, setIsEditorLoading] = useState<boolean>(false);
|
||||
const [wordCount, setWordCount] = useState<number>(0); // State for word count
|
||||
const editorRef = useRef<any>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// Preload TinyMCE script
|
||||
useEffect(() => {
|
||||
const preloadTinyMCE = () => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'preload';
|
||||
link.href = '/tinymce/tinymce.min.js';
|
||||
link.as = 'script';
|
||||
document.head.appendChild(link);
|
||||
};
|
||||
|
||||
preloadTinyMCE();
|
||||
}, []);
|
||||
|
||||
const handleDescriptionChange = () => {
|
||||
if (!taskId) return;
|
||||
socket?.emit(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), JSON.stringify({
|
||||
task_id: taskId,
|
||||
description: content || null,
|
||||
parent_task: parentTaskId,
|
||||
}));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const wrapper = wrapperRef.current;
|
||||
const target = event.target as Node;
|
||||
|
||||
const isClickedInsideWrapper = wrapper && wrapper.contains(target);
|
||||
const isClickedInsideEditor = document.querySelector('.tox-tinymce')?.contains(target);
|
||||
const isClickedInsideToolbarPopup = document.querySelector('.tox-menu, .tox-pop, .tox-collection')?.contains(target);
|
||||
|
||||
if (
|
||||
isEditorOpen &&
|
||||
!isClickedInsideWrapper &&
|
||||
!isClickedInsideEditor &&
|
||||
!isClickedInsideToolbarPopup
|
||||
) {
|
||||
if (content !== description) {
|
||||
handleDescriptionChange();
|
||||
}
|
||||
setIsEditorOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isEditorOpen, content, description, taskId, parentTaskId, socket]);
|
||||
|
||||
const handleEditorChange = (content: string) => {
|
||||
const sanitizedContent = DOMPurify.sanitize(content);
|
||||
setContent(sanitizedContent);
|
||||
// Update word count when content changes
|
||||
if (editorRef.current) {
|
||||
const count = editorRef.current.plugins.wordcount.getCount();
|
||||
setWordCount(count);
|
||||
}
|
||||
};
|
||||
|
||||
const handleInit = (evt: any, editor: any) => {
|
||||
editorRef.current = editor;
|
||||
editor.on('focus', () => setIsEditorOpen(true));
|
||||
// Set initial word count on init
|
||||
const initialCount = editor.plugins.wordcount.getCount();
|
||||
setWordCount(initialCount);
|
||||
setIsEditorLoading(false);
|
||||
};
|
||||
|
||||
const handleOpenEditor = () => {
|
||||
setIsEditorOpen(true);
|
||||
setIsEditorLoading(true);
|
||||
};
|
||||
|
||||
const darkModeStyles = themeMode === 'dark' ? `
|
||||
body {
|
||||
background-color: #1e1e1e !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
body.mce-content-body[data-mce-placeholder]:not([contenteditable="false"]):before {
|
||||
color: #666666 !important;
|
||||
}
|
||||
` : '';
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef}>
|
||||
{isEditorOpen ? (
|
||||
<div style={{
|
||||
minHeight: '200px',
|
||||
backgroundColor: themeMode === 'dark' ? '#1e1e1e' : '#ffffff'
|
||||
}}>
|
||||
{isEditorLoading && (
|
||||
<div style={{
|
||||
position: 'absolute',
|
||||
zIndex: 10,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
alignItems: 'center',
|
||||
width: '100%',
|
||||
height: '200px',
|
||||
backgroundColor: themeMode === 'dark' ? 'rgba(30, 30, 30, 0.8)' : 'rgba(255, 255, 255, 0.8)',
|
||||
color: themeMode === 'dark' ? '#ffffff' : '#000000'
|
||||
}}>
|
||||
<div>Loading editor...</div>
|
||||
</div>
|
||||
)}
|
||||
<Editor
|
||||
tinymceScriptSrc="/tinymce/tinymce.min.js"
|
||||
value={content}
|
||||
onInit={handleInit}
|
||||
licenseKey="gpl"
|
||||
init={{
|
||||
height: 200,
|
||||
menubar: false,
|
||||
branding: false,
|
||||
plugins: [
|
||||
'advlist', 'autolink', 'lists', 'link', 'charmap', 'preview',
|
||||
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
|
||||
'insertdatetime', 'media', 'table', 'code', 'wordcount' // Added wordcount
|
||||
],
|
||||
toolbar: 'blocks |' +
|
||||
'bold italic underline strikethrough | ' +
|
||||
'bullist numlist | link | removeformat | help',
|
||||
content_style: `
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
}
|
||||
${darkModeStyles}
|
||||
`,
|
||||
skin: themeMode === 'dark' ? 'oxide-dark' : 'oxide',
|
||||
content_css: themeMode === 'dark' ? 'dark' : 'default',
|
||||
skin_url: `/tinymce/skins/ui/${themeMode === 'dark' ? 'oxide-dark' : 'oxide'}`,
|
||||
content_css_cors: true,
|
||||
auto_focus: true,
|
||||
init_instance_callback: (editor) => {
|
||||
editor.dom.setStyle(editor.getBody(), 'backgroundColor', themeMode === 'dark' ? '#1e1e1e' : '#ffffff');
|
||||
}
|
||||
}}
|
||||
onEditorChange={handleEditorChange}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={handleOpenEditor}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
style={{
|
||||
minHeight: '32px',
|
||||
padding: '4px 11px',
|
||||
border: `1px solid ${isHovered ? (themeMode === 'dark' ? '#177ddc' : '#40a9ff') : 'transparent'}`,
|
||||
borderRadius: '6px',
|
||||
cursor: 'pointer',
|
||||
color: themeMode === 'dark' ? '#ffffff' : '#000000',
|
||||
transition: 'border-color 0.3s ease'
|
||||
}}
|
||||
>
|
||||
{content ? (
|
||||
<div
|
||||
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content) }}
|
||||
style={{ color: 'inherit' }}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color: themeMode === 'dark' ? '#666666' : '#bfbfbf' }}>
|
||||
Add a more detailed description...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DescriptionEditor;
|
||||
@@ -0,0 +1,198 @@
|
||||
import { InputRef } from 'antd/es/input';
|
||||
import Card from 'antd/es/card';
|
||||
import Checkbox from 'antd/es/checkbox';
|
||||
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 { PlusOutlined } 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 { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
||||
import { sortTeamMembers } from '@/utils/sort-team-members';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
|
||||
import { setTaskAssignee } from '@/features/task-drawer/task-drawer.slice';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
import { updateTaskAssignees as updateBoardTaskAssignees } from '@/features/board/board-slice';
|
||||
import { updateTaskAssignees as updateTasksListTaskAssignees } from '@/features/tasks/tasks.slice';
|
||||
interface TaskDrawerAssigneeSelectorProps {
|
||||
task: ITaskViewModel;
|
||||
}
|
||||
|
||||
const TaskDrawerAssigneeSelector = ({ task }: TaskDrawerAssigneeSelectorProps) => {
|
||||
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 { tab } = useTabSearchParam();
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
|
||||
|
||||
const filteredMembersData = useMemo(() => {
|
||||
return teamMembers?.data?.filter(member =>
|
||||
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [teamMembers, searchQuery]);
|
||||
|
||||
const handleMembersDropdownOpen = (open: boolean) => {
|
||||
if (open) {
|
||||
const membersData = (members?.data || []).map(member => ({
|
||||
...member,
|
||||
selected: task?.assignees?.some(assignee => assignee === 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;
|
||||
try {
|
||||
const checked =
|
||||
e?.target.checked || !task?.assignees?.some(assignee => assignee === 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));
|
||||
socket?.once(
|
||||
SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(),
|
||||
(data: ITaskAssigneesUpdateResponse) => {
|
||||
dispatch(setTaskAssignee(data));
|
||||
// if (tab === 'tasks-list') {
|
||||
// dispatch(updateTasksListTaskAssignees(data));
|
||||
// }
|
||||
// if (tab === 'board') {
|
||||
// dispatch(updateBoardTaskAssignees(data));
|
||||
// }
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error updating assignee:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const checkMemberSelected = (memberId: string) => {
|
||||
if (!memberId) return false;
|
||||
|
||||
return task?.assignees?.some(assignee => assignee === 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>
|
||||
</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 TaskDrawerAssigneeSelector;
|
||||
@@ -0,0 +1,30 @@
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { Switch } from 'antd/es';
|
||||
|
||||
interface TaskDrawerBillableProps {
|
||||
task?: ITaskViewModel | null;
|
||||
}
|
||||
|
||||
const TaskDrawerBillable = ({ task = null }: TaskDrawerBillableProps) => {
|
||||
const { socket, connected } = useSocket();
|
||||
|
||||
const handleBillableChange = (checked: boolean) => {
|
||||
if (!connected) return;
|
||||
|
||||
try {
|
||||
socket?.emit(SocketEvents.TASK_BILLABLE_CHANGE.toString(), {
|
||||
task_id: task?.id,
|
||||
billable: checked,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error updating billable status', error);
|
||||
}
|
||||
};
|
||||
|
||||
return <Switch defaultChecked={task?.billable} onChange={handleBillableChange} />;
|
||||
};
|
||||
|
||||
export default TaskDrawerBillable;
|
||||
@@ -0,0 +1,133 @@
|
||||
import { useState } from 'react';
|
||||
import { Flex, DatePicker, Typography, Button, Form, FormInstance } from 'antd';
|
||||
import { t, TFunction } from 'i18next';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { colors } from '@/styles/colors';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
import { getUserSession } from '@/utils/session-helper';
|
||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setStartDate, setTaskEndDate } from '@/features/task-drawer/task-drawer.slice';
|
||||
interface TaskDrawerDueDateProps {
|
||||
task: ITaskViewModel;
|
||||
t: TFunction;
|
||||
form: FormInstance;
|
||||
}
|
||||
|
||||
const TaskDrawerDueDate = ({ task, t, form }: TaskDrawerDueDateProps) => {
|
||||
const { socket } = useSocket();
|
||||
const [isShowStartDate, setIsShowStartDate] = useState(false);
|
||||
const dispatch = useAppDispatch();
|
||||
// Date handling
|
||||
const startDayjs = task?.start_date ? dayjs(task.start_date) : null;
|
||||
const dueDayjs = task?.end_date ? dayjs(task.end_date) : null;
|
||||
const isValidStartDate = startDayjs?.isValid();
|
||||
const isValidDueDate = dueDayjs?.isValid();
|
||||
|
||||
// Date validation
|
||||
const disabledStartDate = (current: Dayjs) => {
|
||||
if (isValidDueDate && current && dueDayjs && current > dueDayjs) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const disabledEndDate = (current: Dayjs) => {
|
||||
if (!isShowStartDate || !isValidStartDate) {
|
||||
return current && current < dayjs().startOf('day');
|
||||
}
|
||||
return current && startDayjs && current < startDayjs;
|
||||
};
|
||||
|
||||
const handleStartDateChange = (date: Dayjs | null) => {
|
||||
try {
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_START_DATE_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
start_date: date?.format(),
|
||||
parent_task: task.parent_task_id,
|
||||
time_zone: getUserSession()?.timezone_name
|
||||
? getUserSession()?.timezone_name
|
||||
: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
);
|
||||
socket?.once(
|
||||
SocketEvents.TASK_START_DATE_CHANGE.toString(),
|
||||
(data: IProjectTask) => {
|
||||
dispatch(setStartDate(data));
|
||||
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to update start date:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEndDateChange = (date: Dayjs | null) => {
|
||||
try {
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_END_DATE_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
end_date: date?.format(),
|
||||
parent_task: task.parent_task_id,
|
||||
time_zone: getUserSession()?.timezone_name
|
||||
? getUserSession()?.timezone_name
|
||||
: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
||||
})
|
||||
);
|
||||
socket?.once(
|
||||
SocketEvents.TASK_END_DATE_CHANGE.toString(),
|
||||
(data: IProjectTask) => {
|
||||
dispatch(setTaskEndDate(data));
|
||||
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Failed to update due date:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Item name="dueDate" label={t('taskInfoTab.details.due-date')}>
|
||||
<Flex align="center" gap={8}>
|
||||
{isShowStartDate && (
|
||||
<>
|
||||
<DatePicker
|
||||
placeholder={t('taskInfoTab.details.start-date')}
|
||||
disabledDate={(current: Dayjs) => disabledStartDate(current) ?? false}
|
||||
onChange={handleStartDateChange}
|
||||
value={isValidStartDate ? startDayjs : null}
|
||||
format={'MMM DD, YYYY'}
|
||||
suffixIcon={null}
|
||||
/>
|
||||
<Typography.Text>-</Typography.Text>
|
||||
</>
|
||||
)}
|
||||
<DatePicker
|
||||
placeholder={t('taskInfoTab.details.end-date')}
|
||||
disabledDate={(current: Dayjs) => disabledEndDate(current) ?? false}
|
||||
onChange={handleEndDateChange}
|
||||
value={isValidDueDate ? dueDayjs : null}
|
||||
format={'MMM DD, YYYY'}
|
||||
/>
|
||||
<Button
|
||||
type="text"
|
||||
onClick={() => setIsShowStartDate(prev => !prev)}
|
||||
style={{ color: isShowStartDate ? 'red' : colors.skyBlue }}
|
||||
>
|
||||
{isShowStartDate ? t('taskInfoTab.details.hide-start-date') : t('taskInfoTab.details.show-start-date')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDrawerDueDate;
|
||||
@@ -0,0 +1,84 @@
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import { Flex, Form, FormInstance, InputNumber, Typography } from 'antd';
|
||||
import { TFunction } from 'i18next';
|
||||
import { useState } from 'react';
|
||||
|
||||
interface TaskDrawerEstimationProps {
|
||||
t: TFunction;
|
||||
task: ITaskViewModel;
|
||||
form: FormInstance<any>;
|
||||
}
|
||||
|
||||
const TaskDrawerEstimation = ({ t, task, form }: TaskDrawerEstimationProps) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const [hours, setHours] = useState(0);
|
||||
const [minutes, setMinutes] = useState(0);
|
||||
|
||||
const handleTimeEstimationBlur = (e: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (!connected || !task.id) return;
|
||||
|
||||
// Get current form values instead of using state
|
||||
const currentHours = form.getFieldValue('hours') || 0;
|
||||
const currentMinutes = form.getFieldValue('minutes') || 0;
|
||||
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: task.id,
|
||||
total_hours: currentHours,
|
||||
total_minutes: currentMinutes,
|
||||
parent_task: task.parent_task_id,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Item name="timeEstimation" label={t('taskInfoTab.details.time-estimation')}>
|
||||
<Flex gap={8}>
|
||||
<Form.Item
|
||||
name={'hours'}
|
||||
label={
|
||||
<Typography.Text style={{ color: colors.lightGray, fontSize: 12 }}>
|
||||
{t('taskInfoTab.details.hours')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ marginBottom: 36 }}
|
||||
labelCol={{ style: { paddingBlock: 0 } }}
|
||||
layout="vertical"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={24}
|
||||
placeholder={t('taskInfoTab.details.hours')}
|
||||
onBlur={handleTimeEstimationBlur}
|
||||
onChange={value => setHours(value || 0)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={'minutes'}
|
||||
label={
|
||||
<Typography.Text style={{ color: colors.lightGray, fontSize: 12 }}>
|
||||
{t('taskInfoTab.details.minutes')}
|
||||
</Typography.Text>
|
||||
}
|
||||
style={{ marginBottom: 36 }}
|
||||
labelCol={{ style: { paddingBlock: 0 } }}
|
||||
layout="vertical"
|
||||
>
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={60}
|
||||
placeholder={t('taskInfoTab.details.minutes')}
|
||||
onBlur={handleTimeEstimationBlur}
|
||||
onChange={value => setMinutes(value || 0)}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDrawerEstimation;
|
||||
@@ -0,0 +1,19 @@
|
||||
import { ITaskFormViewModel } from '@/types/tasks/task.types';
|
||||
import { Tag } from 'antd';
|
||||
|
||||
import { Form } from 'antd';
|
||||
|
||||
interface TaskDrawerKeyProps {
|
||||
taskKey: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
const TaskDrawerKey = ({ taskKey, label }: TaskDrawerKeyProps) => {
|
||||
return (
|
||||
<Form.Item name="taskId" label={label}>
|
||||
<Tag>{taskKey}</Tag>
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDrawerKey;
|
||||
@@ -0,0 +1,223 @@
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
Flex,
|
||||
Form,
|
||||
Input,
|
||||
InputRef,
|
||||
List,
|
||||
Tag,
|
||||
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 { useAuthService } from '@/hooks/useAuth';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import { ALPHA_CHANNEL } from '@/shared/constants';
|
||||
import { TFunction } from 'i18next';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setTaskLabels } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { setLabels, updateTaskLabel } from '@/features/tasks/tasks.slice';
|
||||
import { setBoardLabels, updateBoardTaskLabel } from '@/features/board/board-slice';
|
||||
import { ILabelsChangeResponse } from '@/types/tasks/taskList.types';
|
||||
import { ITaskLabelFilter } from '@/types/tasks/taskLabel.types';
|
||||
|
||||
interface TaskDrawerLabelsProps {
|
||||
task: ITaskViewModel;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
const TaskDrawerLabels = ({ task, t }: TaskDrawerLabelsProps) => {
|
||||
const { socket } = useSocket();
|
||||
const dispatch = useAppDispatch();
|
||||
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 { tab } = useTabSearchParam();
|
||||
const handleLabelChange = (label: ITaskLabel) => {
|
||||
try {
|
||||
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));
|
||||
socket?.once(
|
||||
SocketEvents.TASK_LABELS_CHANGE.toString(),
|
||||
(data: ILabelsChangeResponse) => {
|
||||
dispatch(setTaskLabels(data));
|
||||
if (tab === 'tasks-list') {
|
||||
dispatch(updateTaskLabel(data));
|
||||
}
|
||||
if (tab === 'board') {
|
||||
dispatch(updateBoardTaskLabel(data));
|
||||
}
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Error changing label:', error);
|
||||
}
|
||||
};
|
||||
|
||||
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));
|
||||
socket?.once(
|
||||
SocketEvents.CREATE_LABEL.toString(),
|
||||
(data: ILabelsChangeResponse) => {
|
||||
dispatch(setTaskLabels(data));
|
||||
if (tab === 'tasks-list') {
|
||||
dispatch(updateTaskLabel(data));
|
||||
}
|
||||
if (tab === 'board') {
|
||||
dispatch(updateBoardTaskLabel(data));
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
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', overflowY: 'auto', maxHeight: '255px' } }}
|
||||
>
|
||||
<Flex vertical gap={8}>
|
||||
<Input
|
||||
ref={labelInputRef}
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.currentTarget.value)}
|
||||
placeholder={t('taskInfoTab.labels.labelInputPlaceholder')}
|
||||
onKeyDown={e => {
|
||||
const isLabel = filteredLabelData.findIndex(
|
||||
label => label.name?.toLowerCase() === searchQuery.toLowerCase()
|
||||
);
|
||||
if (isLabel === -1) {
|
||||
if (e.key === 'Enter') {
|
||||
handleCreateLabel();
|
||||
setSearchQuery('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<List style={{ padding: 0, maxHeight: 300, 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',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
onClick={() => handleLabelChange(label)}
|
||||
>
|
||||
<Checkbox
|
||||
id={label.id}
|
||||
checked={
|
||||
task?.labels
|
||||
? task?.labels.some(existingLabel => existingLabel.id === label.id)
|
||||
: false
|
||||
}
|
||||
onChange={e => e.preventDefault()}
|
||||
>
|
||||
<Flex gap={8}>
|
||||
<Badge color={label.color_code} />
|
||||
{label.name}
|
||||
</Flex>
|
||||
</Checkbox>
|
||||
</List.Item>
|
||||
))
|
||||
) : (
|
||||
<Typography.Text
|
||||
style={{ color: colors.lightGray }}
|
||||
onClick={() => handleCreateLabel()}
|
||||
>
|
||||
{t('taskInfoTab.labels.labelsSelectorInputTip')}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</List>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// function to focus label input
|
||||
const handleLabelDropdownOpen = (open: boolean) => {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
labelInputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Item name="labels" label={t('taskInfoTab.details.labels')}>
|
||||
<Flex gap={8} wrap="wrap" align="center">
|
||||
{task?.labels?.map((label, index) => (
|
||||
<Tag
|
||||
key={label.id}
|
||||
color={label.color_code + ALPHA_CHANNEL}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyItems: 'center',
|
||||
height: 18,
|
||||
fontSize: 11,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</Tag>
|
||||
))}
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
dropdownRender={() => labelDropdownContent}
|
||||
onOpenChange={handleLabelDropdownOpen}
|
||||
>
|
||||
<Button
|
||||
type="dashed"
|
||||
icon={<PlusOutlined style={{ fontSize: 11 }} />}
|
||||
style={{ height: 18, marginBottom: 4 }}
|
||||
size="small"
|
||||
/>
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDrawerLabels;
|
||||
@@ -0,0 +1,52 @@
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { Select } from 'antd';
|
||||
|
||||
import { Form } from 'antd';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
|
||||
interface TaskDrawerPhaseSelectorProps {
|
||||
phases: ITaskPhase[];
|
||||
task: ITaskViewModel;
|
||||
}
|
||||
|
||||
const TaskDrawerPhaseSelector = ({ phases, task }: TaskDrawerPhaseSelectorProps) => {
|
||||
const { socket, connected } = useSocket();
|
||||
|
||||
const phaseMenuItems = phases?.map(phase => ({
|
||||
key: phase.id,
|
||||
value: phase.id,
|
||||
label: phase.name,
|
||||
}));
|
||||
|
||||
const handlePhaseChange = (value: string) => {
|
||||
socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
|
||||
task_id: task.id,
|
||||
phase_id: value,
|
||||
parent_task: task.parent_task_id || null
|
||||
});
|
||||
|
||||
// socket?.once(SocketEvents.TASK_PHASE_CHANGE.toString(), () => {
|
||||
// if(list.getCurrentGroup().value === this.list.GROUP_BY_PHASE_VALUE && this.list.isSubtasksIncluded) {
|
||||
// this.list.emitRefreshSubtasksIncluded();
|
||||
// }
|
||||
// });
|
||||
};
|
||||
|
||||
return (
|
||||
<Form.Item name="phase" label="Phase">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="Select Phase"
|
||||
options={phaseMenuItems}
|
||||
style={{ width: 'fit-content' }}
|
||||
dropdownStyle={{ width: 'fit-content' }}
|
||||
onChange={handlePhaseChange}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDrawerPhaseSelector;
|
||||
@@ -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,115 @@
|
||||
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 { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
||||
import { DoubleLeftOutlined, MinusOutlined, PauseOutlined } from '@ant-design/icons';
|
||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types';
|
||||
import { setTaskPriority } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { updateTaskPriority as updateBoardTaskPriority } from '@/features/board/board-slice';
|
||||
import { updateTaskPriority as updateTasksListTaskPriority } from '@/features/tasks/tasks.slice';
|
||||
|
||||
type PriorityDropdownProps = {
|
||||
task: ITaskViewModel;
|
||||
};
|
||||
|
||||
const PriorityDropdown = ({ task }: 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 currentSession = useAuthService().getCurrentSession();
|
||||
const dispatch = useAppDispatch();
|
||||
const { tab } = useTabSearchParam();
|
||||
|
||||
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: currentSession?.team_id,
|
||||
})
|
||||
);
|
||||
socket?.once(
|
||||
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
|
||||
(data: ITaskListPriorityChangeResponse) => {
|
||||
dispatch(setTaskPriority(data));
|
||||
if (tab === 'tasks-list') {
|
||||
dispatch(updateTasksListTaskPriority(data));
|
||||
}
|
||||
if (tab === 'board') {
|
||||
dispatch(updateBoardTaskPriority(data));
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
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 (
|
||||
<>
|
||||
{(
|
||||
<Select
|
||||
value={task?.priority_id}
|
||||
onChange={handlePriorityChange}
|
||||
dropdownStyle={{ borderRadius: 8, minWidth: 150, maxWidth: 200 }}
|
||||
style={{
|
||||
width: 'fit-content',
|
||||
backgroundColor:
|
||||
themeMode === 'dark'
|
||||
? selectedPriority?.color_code_dark
|
||||
: selectedPriority?.color_code + ALPHA_CHANNEL,
|
||||
}}
|
||||
|
||||
options={options}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PriorityDropdown;
|
||||
@@ -0,0 +1,472 @@
|
||||
import { Button, Flex, Form, Mentions, Space, Tooltip, Typography, message } from 'antd';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { PaperClipOutlined, DeleteOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
|
||||
import { calculateTimeDifference } from '@/utils/calculate-time-difference';
|
||||
import {
|
||||
IMentionMemberSelectOption,
|
||||
IMentionMemberViewModel,
|
||||
} from '@/types/project/projectComments.types';
|
||||
import { projectCommentsApiService } from '@/api/projects/comments/project-comments.api.service';
|
||||
import { ITaskCommentsCreateRequest } from '@/types/tasks/task-comments.types';
|
||||
import { ITaskAttachment } from '@/types/tasks/task-attachment-view-model';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import taskCommentsApiService from '@/api/tasks/task-comments.api.service';
|
||||
import { teamMembersApiService } from '@/api/team-members/teamMembers.api.service';
|
||||
import { ITeamMember } from '@/types/teamMembers/teamMember.types';
|
||||
|
||||
// Utility function to convert file to base64
|
||||
const getBase64 = (file: File): Promise<string> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file);
|
||||
reader.onload = () => resolve(reader.result as string);
|
||||
reader.onerror = error => reject(error);
|
||||
});
|
||||
};
|
||||
|
||||
// Utility function to format file size
|
||||
const formatFileSize = (bytes: number): string => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
const InfoTabFooter = () => {
|
||||
const MAXIMUM_FILE_COUNT = 5;
|
||||
|
||||
const [characterLength, setCharacterLength] = useState<number>(0);
|
||||
const [isCommentBoxExpand, setIsCommentBoxExpand] = useState<boolean>(false);
|
||||
const [attachmentComment, setAttachmentComment] = useState<boolean>(false);
|
||||
const [selectedFiles, setSelectedFiles] = useState<ITaskAttachment[]>([]);
|
||||
|
||||
const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const [members, setMembers] = useState<ITeamMember[]>([]);
|
||||
const [membersLoading, setMembersLoading] = useState<boolean>(false);
|
||||
|
||||
const [selectedMembers, setSelectedMembers] = useState<{ team_member_id: string; name: string }[]>([]);
|
||||
const [commentValue, setCommentValue] = useState<string>('');
|
||||
const [uploading, setUploading] = useState<boolean>(false);
|
||||
|
||||
const [form] = Form.useForm();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// get theme details from theme slice
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
// get member list from project members slice
|
||||
const projectMembersList = useAppSelector(state => state.projectMemberReducer.membersList);
|
||||
|
||||
// function to handle cancel
|
||||
const handleCancel = () => {
|
||||
form.resetFields(['comment']);
|
||||
setCharacterLength(0);
|
||||
setIsCommentBoxExpand(false);
|
||||
setSelectedFiles([]);
|
||||
setAttachmentComment(false);
|
||||
};
|
||||
|
||||
// Check if comment is valid (either has text or files)
|
||||
const isCommentValid = useCallback(() => {
|
||||
return characterLength > 0 || selectedFiles.length > 0;
|
||||
}, [characterLength, selectedFiles.length]);
|
||||
|
||||
const getMembers = useCallback(async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setMembersLoading(true);
|
||||
const res = await teamMembersApiService.get(1, 10, null, null, null, true);
|
||||
|
||||
if (res.done) {
|
||||
setMembers(res.body.data?.filter(t => !t.pending_invitation) as ITeamMember[]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch members:', error);
|
||||
} finally {
|
||||
setMembersLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
// mentions options
|
||||
const mentionsOptions =
|
||||
members?.map(member => ({
|
||||
value: member.id,
|
||||
label: member.name,
|
||||
})) ?? [];
|
||||
|
||||
const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => {
|
||||
console.log('member', member);
|
||||
if (!member?.value || !member?.label) return;
|
||||
setSelectedMembers(prev =>
|
||||
prev.some(mention => mention.team_member_id === member.value)
|
||||
? prev
|
||||
: [...prev, { team_member_id: member.value, name: member.label }]
|
||||
);
|
||||
|
||||
setCommentValue(prev => {
|
||||
const parts = prev.split('@');
|
||||
const lastPart = parts[parts.length - 1];
|
||||
const mentionText = member.label;
|
||||
// Keep only the part before the @ and add the new mention
|
||||
return prev.slice(0, prev.length - lastPart.length) + mentionText;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleCommentChange = useCallback((value: string) => {
|
||||
// Only update the value without trying to replace mentions
|
||||
setCommentValue(value);
|
||||
setCharacterLength(value.trim().length);
|
||||
}, []);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!selectedTaskId || !projectId) return;
|
||||
|
||||
if (!isCommentValid()) {
|
||||
message.error('Please add a comment or attach files');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
const body: ITaskCommentsCreateRequest = {
|
||||
task_id: selectedTaskId,
|
||||
content: commentValue || '',
|
||||
mentions: Array.from(new Set(selectedMembers.map(member => JSON.stringify(member)))).map(
|
||||
str => JSON.parse(str)
|
||||
),
|
||||
attachments: selectedFiles,
|
||||
};
|
||||
|
||||
const res = await taskCommentsApiService.create(body);
|
||||
if (res.done) {
|
||||
form.resetFields(['comment']);
|
||||
setCharacterLength(0);
|
||||
setSelectedFiles([]);
|
||||
setAttachmentComment(false);
|
||||
setIsCommentBoxExpand(false);
|
||||
setCommentValue('');
|
||||
|
||||
// Dispatch event to notify that a comment was created
|
||||
// This will trigger scrolling to the new comment
|
||||
document.dispatchEvent(new Event('task-comment-create'));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to create comment:', error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [
|
||||
commentValue,
|
||||
selectedMembers,
|
||||
selectedFiles,
|
||||
selectedTaskId,
|
||||
projectId,
|
||||
form,
|
||||
isCommentValid,
|
||||
]);
|
||||
|
||||
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
if (!event.target.files || !event.target.files.length || !selectedTaskId || !projectId) return;
|
||||
|
||||
const files = Array.from(event.target.files);
|
||||
|
||||
if (selectedFiles.length + files.length > MAXIMUM_FILE_COUNT) {
|
||||
message.error(`You can only upload a maximum of ${MAXIMUM_FILE_COUNT} files`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setUploading(true);
|
||||
setAttachmentComment(true);
|
||||
|
||||
const newFiles: ITaskAttachment[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const base64Data = await getBase64(file);
|
||||
const attachment: ITaskAttachment = {
|
||||
file: base64Data,
|
||||
file_name: file.name,
|
||||
project_id: projectId,
|
||||
task_id: selectedTaskId,
|
||||
size: file.size,
|
||||
};
|
||||
|
||||
newFiles.push(attachment);
|
||||
}
|
||||
|
||||
setSelectedFiles(prev => [...prev, ...newFiles]);
|
||||
|
||||
// Expand the comment box if it's not already expanded
|
||||
if (!isCommentBoxExpand) {
|
||||
setIsCommentBoxExpand(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to process files:', error);
|
||||
message.error('Failed to process files');
|
||||
} finally {
|
||||
setUploading(false);
|
||||
|
||||
// Reset the file input so the same file can be selected again
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
setSelectedFiles(prev => {
|
||||
const newFiles = [...prev];
|
||||
newFiles.splice(index, 1);
|
||||
|
||||
if (newFiles.length === 0) {
|
||||
setAttachmentComment(false);
|
||||
}
|
||||
|
||||
return newFiles;
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void getMembers();
|
||||
}, [getMembers]);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
vertical
|
||||
style={{
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
minHeight: 80,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBlockEnd: 0,
|
||||
height: 1,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
backgroundColor: themeWiseColor('#ebebeb', '#3a3a3a', themeMode),
|
||||
}}
|
||||
/>
|
||||
|
||||
{!isCommentBoxExpand ? (
|
||||
// Collapsed state - simple textarea with counter
|
||||
<Flex
|
||||
vertical
|
||||
style={{
|
||||
width: '100%',
|
||||
padding: '12px 0',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<Mentions
|
||||
placeholder={'Add a comment...'}
|
||||
options={mentionsOptions}
|
||||
autoSize
|
||||
maxLength={5000}
|
||||
onClick={() => setIsCommentBoxExpand(true)}
|
||||
onChange={e => setCharacterLength(e.length)}
|
||||
style={{
|
||||
minHeight: 60,
|
||||
resize: 'none',
|
||||
borderRadius: 4,
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
}}
|
||||
/>
|
||||
</Flex>
|
||||
) : (
|
||||
// Expanded state - textarea with buttons
|
||||
<Form
|
||||
form={form}
|
||||
style={{
|
||||
width: '100%',
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
animation: 'expandAnimation 0.3s ease-in-out',
|
||||
}}
|
||||
>
|
||||
{selectedFiles.length > 0 && (
|
||||
<Flex vertical gap={8} style={{ marginTop: 12 }}>
|
||||
<Typography.Title level={5} style={{ margin: 0 }}>
|
||||
Selected Files (Up to 25MB, Maximum of {MAXIMUM_FILE_COUNT})
|
||||
</Typography.Title>
|
||||
<Flex
|
||||
vertical
|
||||
style={{
|
||||
border: `1px solid ${themeWiseColor('#d9d9d9', '#434343', themeMode)}`,
|
||||
borderRadius: 4,
|
||||
padding: '8px 16px',
|
||||
backgroundColor: themeWiseColor('#fff', '#141414', themeMode),
|
||||
}}
|
||||
>
|
||||
{selectedFiles.map((file, index) => (
|
||||
<Flex
|
||||
key={index}
|
||||
justify="space-between"
|
||||
align="center"
|
||||
style={{
|
||||
padding: '8px 0',
|
||||
borderBottom:
|
||||
index < selectedFiles.length - 1
|
||||
? `1px solid ${themeWiseColor('#f0f0f0', '#303030', themeMode)}`
|
||||
: 'none',
|
||||
}}
|
||||
>
|
||||
<Typography.Text
|
||||
style={{ color: themeWiseColor('#000000d9', '#ffffffd9', themeMode) }}
|
||||
>
|
||||
{file.file_name} ({formatFileSize(file.size)})
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="text"
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => removeFile(index)}
|
||||
style={{ color: '#f5222d' }}
|
||||
/>
|
||||
</Flex>
|
||||
))}
|
||||
<Flex
|
||||
justify="center"
|
||||
align="center"
|
||||
style={{
|
||||
marginTop: 8,
|
||||
cursor: selectedFiles.length >= MAXIMUM_FILE_COUNT ? 'not-allowed' : 'pointer',
|
||||
opacity: selectedFiles.length >= MAXIMUM_FILE_COUNT ? 0.5 : 1,
|
||||
}}
|
||||
onClick={() => {
|
||||
if (selectedFiles.length < MAXIMUM_FILE_COUNT && !uploading) {
|
||||
fileInputRef.current?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="link"
|
||||
icon={<PlusOutlined />}
|
||||
disabled={selectedFiles.length >= MAXIMUM_FILE_COUNT || uploading}
|
||||
>
|
||||
Add more files
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
)}
|
||||
|
||||
<Form.Item name={'comment'} style={{ marginBlock: 12 }}>
|
||||
<div>
|
||||
<Mentions
|
||||
placeholder={'Add a comment...'}
|
||||
options={mentionsOptions}
|
||||
autoSize
|
||||
autoFocus
|
||||
maxLength={5000}
|
||||
value={commentValue}
|
||||
onSelect={option => memberSelectHandler(option as IMentionMemberSelectOption)}
|
||||
onChange={handleCommentChange}
|
||||
prefix="@"
|
||||
split=""
|
||||
style={{
|
||||
minHeight: 100,
|
||||
maxHeight: 200,
|
||||
overflow: 'auto',
|
||||
paddingBlockEnd: 24,
|
||||
resize: 'none',
|
||||
borderRadius: 4,
|
||||
transition: 'all 0.3s ease-in-out',
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 4,
|
||||
right: 12,
|
||||
color: colors.lightGray,
|
||||
fontSize: 12,
|
||||
}}
|
||||
>{`${characterLength}/5000`}</span>
|
||||
</div>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBottom: 0 }}>
|
||||
<Flex gap={8} justify="space-between">
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
style={{ display: 'none' }}
|
||||
onChange={handleFileChange}
|
||||
disabled={uploading || selectedFiles.length >= MAXIMUM_FILE_COUNT}
|
||||
multiple
|
||||
/>
|
||||
<Tooltip
|
||||
title={
|
||||
selectedFiles.length >= MAXIMUM_FILE_COUNT
|
||||
? `Maximum ${MAXIMUM_FILE_COUNT} files allowed`
|
||||
: 'Attach files'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
icon={<PaperClipOutlined />}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={uploading || selectedFiles.length >= MAXIMUM_FILE_COUNT}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
<Space>
|
||||
<Button onClick={handleCancel}>Cancel</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!isCommentValid()}
|
||||
onClick={handleSubmit}
|
||||
loading={uploading}
|
||||
>
|
||||
Comment
|
||||
</Button>
|
||||
</Space>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
|
||||
<Flex align="center" justify="space-between" style={{ width: '100%', marginTop: 8 }}>
|
||||
<Tooltip
|
||||
title={
|
||||
taskFormViewModel?.task?.created_at
|
||||
? formatDateTimeWithLocale(taskFormViewModel.task.created_at)
|
||||
: 'N/A'
|
||||
}
|
||||
>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Created{' '}
|
||||
{taskFormViewModel?.task?.created_at
|
||||
? calculateTimeDifference(taskFormViewModel.task.created_at)
|
||||
: 'N/A'}{' '}
|
||||
by {taskFormViewModel?.task?.reporter}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
title={
|
||||
taskFormViewModel?.task?.updated_at
|
||||
? formatDateTimeWithLocale(taskFormViewModel.task.updated_at)
|
||||
: 'N/A'
|
||||
}
|
||||
>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
Updated{' '}
|
||||
{taskFormViewModel?.task?.updated_at
|
||||
? calculateTimeDifference(taskFormViewModel.task.updated_at)
|
||||
: 'N/A'}
|
||||
</Typography.Text>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default InfoTabFooter;
|
||||
@@ -0,0 +1,230 @@
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Dropdown,
|
||||
Empty,
|
||||
Flex,
|
||||
Input,
|
||||
InputRef,
|
||||
List,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
||||
import { teamMembersApiService } from '@/api/team-members/teamMembers.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||
import { sortTeamMembers } from '@/utils/sort-team-members';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import Avatars from '@/components/avatars/avatars';
|
||||
import { tasksApiService } from '@/api/tasks/tasks.api.service';
|
||||
import { setTaskSubscribers } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||
|
||||
interface NotifyMemberSelectorProps {
|
||||
task: ITaskViewModel;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
const NotifyMemberSelector = ({ task, t }: NotifyMemberSelectorProps) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const dispatch = useAppDispatch();
|
||||
const { tab } = useTabSearchParam();
|
||||
|
||||
const membersInputRef = useRef<InputRef>(null);
|
||||
const [searchQuery, setSearchQuery] = useState<string>('');
|
||||
const [teamMembersLoading, setTeamMembersLoading] = useState(false);
|
||||
const [members, setMembers] = useState<ITeamMembersViewModel>({ data: [], total: 0 });
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { subscribers } = useAppSelector(state => state.taskDrawerReducer);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
|
||||
const fetchTeamMembers = async () => {
|
||||
if (!projectId) return;
|
||||
|
||||
try {
|
||||
setTeamMembersLoading(true);
|
||||
const response = await teamMembersApiService.getAll(projectId);
|
||||
if (response.done) {
|
||||
let sortedMembers = sortTeamMembers(response.body || []);
|
||||
|
||||
setMembers({ data: sortedMembers });
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching team members:', error);
|
||||
} finally {
|
||||
setTeamMembersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSubscribers = async () => {
|
||||
if (!task || !task.id) return;
|
||||
try {
|
||||
const response = await tasksApiService.getSubscribers(task.id);
|
||||
if (response.done) {
|
||||
dispatch(setTaskSubscribers(response.body || []));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching subscribers:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// used useMemo hook for re render the list when searching
|
||||
const filteredMembersData = useMemo(() => {
|
||||
return members.data?.filter(member =>
|
||||
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [members, searchQuery]);
|
||||
|
||||
const handleMemberClick = (member: ITeamMemberViewModel, checked: boolean) => {
|
||||
if (!task || !connected || !currentSession?.id || !member.id) return;
|
||||
try {
|
||||
const body = {
|
||||
team_member_id: member.id,
|
||||
task_id: task.id,
|
||||
user_id: member.user_id || null,
|
||||
mode: checked ? 0 : 1,
|
||||
};
|
||||
socket?.emit(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), body);
|
||||
socket?.once(
|
||||
SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(),
|
||||
(data: InlineMember[]) => {
|
||||
dispatch(setTaskSubscribers(data));
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error('Error notifying member:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 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('taskInfoTab.searchInputPlaceholder')}
|
||||
/>
|
||||
<List
|
||||
style={{ padding: 0, maxHeight: 250, overflow: 'auto' }}
|
||||
loading={teamMembersLoading}
|
||||
size="small"
|
||||
>
|
||||
{filteredMembersData?.length ? (
|
||||
filteredMembersData.map(member => (
|
||||
<List.Item
|
||||
className={`${themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'} ${member.pending_invitation || member.is_pending ? 'disabled' : ''}`}
|
||||
key={member.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: 8,
|
||||
justifyContent: 'flex-start',
|
||||
padding: '4px 8px',
|
||||
border: 'none',
|
||||
cursor:
|
||||
member.pending_invitation || member.is_pending ? 'not-allowed' : 'pointer',
|
||||
pointerEvents: member.pending_invitation || member.is_pending ? 'none' : 'auto',
|
||||
opacity: member.pending_invitation || member.is_pending ? 0.6 : 1,
|
||||
}}
|
||||
onClick={e => {
|
||||
if (member.pending_invitation || member.is_pending) return;
|
||||
handleMemberClick(
|
||||
member,
|
||||
!subscribers?.some(sub => sub.team_member_id === member.id)
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Checkbox
|
||||
id={member.id}
|
||||
checked={subscribers?.some(sub => sub.team_member_id === member.id)}
|
||||
onChange={e => e.stopPropagation()}
|
||||
disabled={member.pending_invitation || member.is_pending}
|
||||
/>
|
||||
<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.is_pending && (
|
||||
<Typography.Text type="danger" style={{ fontSize: 10 }}>
|
||||
({t('taskInfoTab.pendingInvitation')})
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
</List.Item>
|
||||
))
|
||||
) : (
|
||||
<Empty />
|
||||
)}
|
||||
</List>
|
||||
</Flex>
|
||||
</Card>
|
||||
);
|
||||
|
||||
// function to focus members input
|
||||
const handleMembersDropdownOpen = (open: boolean) => {
|
||||
if (open) {
|
||||
fetchTeamMembers();
|
||||
setTimeout(() => {
|
||||
membersInputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
getSubscribers();
|
||||
}, [task?.id]);
|
||||
|
||||
return (
|
||||
<Flex gap={8}>
|
||||
<Avatars members={subscribers || []} />
|
||||
<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>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotifyMemberSelector;
|
||||
@@ -0,0 +1,19 @@
|
||||
/* Subtask Table CSS */
|
||||
.subtask-table .action-buttons {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.subtask-table .ant-table-row:hover .action-buttons {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Add these styles for task dependency action buttons */
|
||||
.task-dependency-actions {
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.task-dependency:hover .task-dependency-actions {
|
||||
opacity: 1;
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
import { Button, Flex, Input, Popconfirm, Progress, Table, Tag, Tooltip } from 'antd';
|
||||
import { useState, useMemo, useEffect } from 'react';
|
||||
import { DeleteOutlined, EditOutlined, ExclamationCircleFilled } from '@ant-design/icons';
|
||||
import { nanoid } from '@reduxjs/toolkit';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ISubTask } from '@/types/tasks/subTask.types';
|
||||
import { tasksApiService } from '@/api/tasks/tasks.api.service';
|
||||
import Avatars from '@/components/avatars/avatars';
|
||||
import './subtask-table.css';
|
||||
import { ITaskCreateRequest } from '@/types/tasks/task-create-request.types';
|
||||
import { getUserSession } from '@/utils/session-helper';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import {
|
||||
getCurrentGroup,
|
||||
GROUP_BY_STATUS_VALUE,
|
||||
GROUP_BY_PRIORITY_VALUE,
|
||||
GROUP_BY_PHASE_VALUE,
|
||||
} from '@/features/tasks/tasks.slice';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import {
|
||||
setShowTaskDrawer,
|
||||
setSelectedTaskId,
|
||||
fetchTask
|
||||
} from '@/features/task-drawer/task-drawer.slice';
|
||||
import { updateSubtask } from '@/features/board/board-slice';
|
||||
|
||||
type SubTaskTableProps = {
|
||||
subTasks: ISubTask[];
|
||||
loadingSubTasks: boolean;
|
||||
refreshSubTasks: () => void;
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTaskTableProps) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const [isEdit, setIsEdit] = useState(false);
|
||||
const [creatingTask, setCreatingTask] = useState(false);
|
||||
const [newTaskName, setNewTaskName] = useState('');
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
|
||||
const currentSession = getUserSession();
|
||||
const { projectView } = useTabSearchParam();
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const createRequestBody = (taskName: string): ITaskCreateRequest | null => {
|
||||
if (!projectId || !currentSession) return null;
|
||||
|
||||
const body: ITaskCreateRequest = {
|
||||
project_id: projectId,
|
||||
name: taskName,
|
||||
reporter_id: currentSession.id,
|
||||
team_id: currentSession.team_id,
|
||||
};
|
||||
|
||||
const groupBy = getCurrentGroup();
|
||||
const task = taskFormViewModel?.task;
|
||||
|
||||
if (groupBy.value === GROUP_BY_STATUS_VALUE) {
|
||||
body.status_id = task?.status_id;
|
||||
} else if (groupBy.value === GROUP_BY_PRIORITY_VALUE) {
|
||||
body.priority_id = task?.priority_id;
|
||||
} else if (groupBy.value === GROUP_BY_PHASE_VALUE) {
|
||||
body.phase_id = task?.phase_id;
|
||||
}
|
||||
|
||||
if (selectedTaskId) {
|
||||
body.parent_task_id = selectedTaskId;
|
||||
}
|
||||
|
||||
return body;
|
||||
};
|
||||
|
||||
const addInstantTask = async (taskName: string) => {
|
||||
if (creatingTask || !taskName?.trim() || !connected) return;
|
||||
|
||||
try {
|
||||
setCreatingTask(true);
|
||||
const body = createRequestBody(taskName);
|
||||
if (!body) return;
|
||||
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => {
|
||||
if (task.parent_task_id) {
|
||||
refreshSubTasks();
|
||||
dispatch(updateSubtask({ sectionId: '', subtask: task, mode: 'add' }));
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error adding subtask:', error);
|
||||
} finally {
|
||||
setCreatingTask(false);
|
||||
setNewTaskName('');
|
||||
setIsEdit(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSubTask = async (taskId?: string) => {
|
||||
if (!taskId) return;
|
||||
|
||||
try {
|
||||
await tasksApiService.deleteTask(taskId);
|
||||
dispatch(updateSubtask({ sectionId: '', subtask: { id: taskId, parent_task_id: selectedTaskId || '' }, mode: 'delete' }));
|
||||
refreshSubTasks();
|
||||
} catch (error) {
|
||||
logger.error('Error deleting subtask:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOnBlur = () => {
|
||||
if (newTaskName.trim() === '') {
|
||||
setIsEdit(true);
|
||||
return;
|
||||
}
|
||||
|
||||
addInstantTask(newTaskName);
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
if (newTaskName.trim() === '') {
|
||||
setIsEdit(false);
|
||||
} else {
|
||||
handleOnBlur();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit && !creatingTask && newTaskName === '') {
|
||||
const inputElement = document.querySelector('.subtask-table-input') as HTMLInputElement;
|
||||
if (inputElement) {
|
||||
inputElement.focus();
|
||||
}
|
||||
}
|
||||
}, [isEdit, creatingTask, newTaskName]);
|
||||
|
||||
const handleEditSubTask = (taskId: string) => {
|
||||
if (!taskId || !projectId) return;
|
||||
|
||||
// Close the current drawer and open the drawer for the selected sub task
|
||||
dispatch(setShowTaskDrawer(false));
|
||||
|
||||
// Small delay to ensure the current drawer is closed before opening the new one
|
||||
setTimeout(() => {
|
||||
dispatch(setSelectedTaskId(taskId));
|
||||
dispatch(setShowTaskDrawer(true));
|
||||
|
||||
// Fetch task data for the subtask
|
||||
dispatch(fetchTask({ taskId, projectId }));
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const getSubTasksProgress = () => {
|
||||
const ratio = taskFormViewModel?.task?.complete_ratio || 0;
|
||||
return ratio == Infinity ? 0 : ratio;
|
||||
}
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
key: 'name',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
render: (record: IProjectTask) => (
|
||||
<Tag
|
||||
color={themeMode === 'dark' ? record.priority_color_dark : record.priority_color}
|
||||
style={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
{record.priority_name}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
render: (record: IProjectTask) => (
|
||||
<Tag
|
||||
color={themeMode === 'dark' ? record.status_color_dark : record.status_color}
|
||||
style={{ textTransform: 'capitalize' }}
|
||||
>
|
||||
{record.status_name}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'assignee',
|
||||
render: (record: ISubTask) => <Avatars members={record.names || []} />,
|
||||
},
|
||||
{
|
||||
key: 'actionBtns',
|
||||
width: 80,
|
||||
render: (record: IProjectTask) => (
|
||||
<Flex gap={8} align="center" className="action-buttons">
|
||||
<Tooltip title={typeof t === 'function' ? t('taskInfoTab.subTasks.edit') : 'Edit'}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => record.id && handleEditSubTask(record.id)}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Popconfirm
|
||||
title="Are you sure?"
|
||||
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
|
||||
okText="Yes"
|
||||
cancelText="No"
|
||||
onConfirm={() => handleDeleteSubTask(record.id)}
|
||||
>
|
||||
<Tooltip title="Delete">
|
||||
<Button shape="default" icon={<DeleteOutlined />} size="small" />
|
||||
</Tooltip>
|
||||
</Popconfirm>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
], [themeMode, t]);
|
||||
|
||||
return (
|
||||
<Flex vertical gap={12}>
|
||||
{taskFormViewModel?.task?.sub_tasks && <Progress percent={getSubTasksProgress()} />}
|
||||
|
||||
<Flex vertical gap={6}>
|
||||
{subTasks.length > 0 && (
|
||||
<Table
|
||||
className="custom-two-colors-row-table subtask-table"
|
||||
showHeader={false}
|
||||
dataSource={subTasks}
|
||||
columns={columns}
|
||||
rowKey={record => record?.id || nanoid()}
|
||||
pagination={{ hideOnSinglePage: true }}
|
||||
onRow={record => ({
|
||||
style: {
|
||||
cursor: 'pointer',
|
||||
height: 36,
|
||||
},
|
||||
onClick: () => record.id && handleEditSubTask(record.id)
|
||||
})}
|
||||
loading={loadingSubTasks}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div>
|
||||
{isEdit ? (
|
||||
<Input
|
||||
autoFocus
|
||||
value={newTaskName}
|
||||
onChange={e => setNewTaskName(e.target.value)}
|
||||
style={{
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
height: 38,
|
||||
}}
|
||||
placeholder={typeof t === 'function' ? t('taskInfoTab.subTasks.addSubTaskInputPlaceholder') : 'Type your task and hit enter'}
|
||||
onBlur={handleInputBlur}
|
||||
onPressEnter={handleOnBlur}
|
||||
size="small"
|
||||
className="subtask-table-input"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
onFocus={() => setIsEdit(true)}
|
||||
value={t('taskInfoTab.subTasks.addSubTask')}
|
||||
className={`border-none ${themeMode === 'dark' ? 'hover:bg-[#343a40]' : 'hover:bg-[#edebf0]'} hover:text-[#1890ff]`}
|
||||
readOnly
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default SubTaskTable;
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import {
|
||||
Form,
|
||||
InputNumber,
|
||||
Select,
|
||||
DatePicker,
|
||||
Switch,
|
||||
Typography,
|
||||
Button,
|
||||
ConfigProvider,
|
||||
Flex,
|
||||
} from 'antd';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { ITaskFormViewModel, ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { simpleDateFormat } from '@/utils/simpleDateFormat';
|
||||
|
||||
import NotifyMemberSelector from './notify-member-selector';
|
||||
import TaskDrawerPhaseSelector from './details/task-drawer-phase-selector/task-drawer-phase-selector';
|
||||
import TaskDrawerKey from './details/task-drawer-key/task-drawer-key';
|
||||
import TaskDrawerLabels from './details/task-drawer-labels/task-drawer-labels';
|
||||
import TaskDrawerAssigneeSelector from './details/task-drawer-assignee-selector/task-drawer-assignee-selector';
|
||||
import Avatars from '@/components/avatars/avatars';
|
||||
import TaskDrawerDueDate from './details/task-drawer-due-date/task-drawer-due-date';
|
||||
import TaskDrawerEstimation from './details/task-drawer-estimation/task-drawer-estimation';
|
||||
import TaskDrawerPrioritySelector from './details/task-drawer-priority-selector/task-drawer-priority-selector';
|
||||
import TaskDrawerBillable from './details/task-drawer-billable/task-drawer-billable';
|
||||
|
||||
interface TaskDetailsFormProps {
|
||||
taskFormViewModel?: ITaskFormViewModel | null;
|
||||
}
|
||||
|
||||
const TaskDetailsForm = ({ taskFormViewModel = null }: TaskDetailsFormProps) => {
|
||||
const { t } = useTranslation('task-drawer/task-drawer');
|
||||
const [form] = Form.useForm();
|
||||
|
||||
useEffect(() => {
|
||||
if (!taskFormViewModel) {
|
||||
form.resetFields();
|
||||
return;
|
||||
}
|
||||
|
||||
const { task } = taskFormViewModel;
|
||||
form.setFieldsValue({
|
||||
taskId: task?.id,
|
||||
phase: task?.phase_id,
|
||||
assignees: task?.assignees,
|
||||
dueDate: task?.end_date ?? null,
|
||||
hours: task?.total_hours || 0,
|
||||
minutes: task?.total_minutes || 0,
|
||||
priority: task?.priority || 'medium',
|
||||
labels: task?.labels || [],
|
||||
billable: task?.billable || false,
|
||||
notify: [],
|
||||
});
|
||||
}, [taskFormViewModel, form]);
|
||||
|
||||
const priorityMenuItems = taskFormViewModel?.priorities?.map(priority => ({
|
||||
key: priority.id,
|
||||
value: priority.id,
|
||||
label: priority.name,
|
||||
}));
|
||||
|
||||
const handleSubmit = async () => {
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
console.log('task details form values', values);
|
||||
} catch (error) {
|
||||
console.error('Form validation failed:', error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ConfigProvider
|
||||
theme={{
|
||||
components: {
|
||||
Form: { itemMarginBottom: 8 },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
labelCol={{ span: 6 }}
|
||||
wrapperCol={{ span: 18 }}
|
||||
initialValues={{
|
||||
priority: 'medium',
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
billable: false,
|
||||
}}
|
||||
onFinish={handleSubmit}
|
||||
>
|
||||
<TaskDrawerKey
|
||||
taskKey={taskFormViewModel?.task?.task_key || 'NEW-TASK'}
|
||||
label={t('taskInfoTab.details.task-key')}
|
||||
/>
|
||||
<TaskDrawerPhaseSelector
|
||||
phases={taskFormViewModel?.phases || []}
|
||||
task={taskFormViewModel?.task as ITaskViewModel}
|
||||
/>
|
||||
|
||||
<Form.Item name="assignees" label={t('taskInfoTab.details.assignees')}>
|
||||
<Flex gap={4} align="center">
|
||||
<Avatars members={taskFormViewModel?.task?.names || []} />
|
||||
<TaskDrawerAssigneeSelector
|
||||
task={(taskFormViewModel?.task as ITaskViewModel) || null}
|
||||
/>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
|
||||
<TaskDrawerDueDate task={taskFormViewModel?.task as ITaskViewModel} t={t} form={form} />
|
||||
|
||||
<TaskDrawerEstimation t={t} task={taskFormViewModel?.task as ITaskViewModel} form={form} />
|
||||
|
||||
<Form.Item name="priority" label={t('taskInfoTab.details.priority')}>
|
||||
<TaskDrawerPrioritySelector task={taskFormViewModel?.task as ITaskViewModel} />
|
||||
</Form.Item>
|
||||
|
||||
<TaskDrawerLabels task={taskFormViewModel?.task as ITaskViewModel} t={t} />
|
||||
|
||||
<Form.Item name="billable" label={t('taskInfoTab.details.billable')}>
|
||||
<TaskDrawerBillable task={taskFormViewModel?.task as ITaskViewModel} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="notify" label={t('taskInfoTab.details.notify')}>
|
||||
<NotifyMemberSelector task={taskFormViewModel?.task as ITaskViewModel} t={t} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</ConfigProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDetailsForm;
|
||||
@@ -0,0 +1,295 @@
|
||||
import { Button, Collapse, CollapseProps, Flex, Skeleton, Tooltip, Typography, Upload } from 'antd';
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
import DescriptionEditor from './description-editor';
|
||||
import SubTaskTable from './subtask-table';
|
||||
import DependenciesTable from './dependencies-table';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import TaskDetailsForm from './task-details-form';
|
||||
import { fetchTask } from '@/features/tasks/tasks.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { TFunction } from 'i18next';
|
||||
import { subTasksApiService } from '@/api/tasks/subtasks.api.service';
|
||||
import { ISubTask } from '@/types/tasks/subTask.types';
|
||||
import { ITaskDependency } from '@/types/tasks/task-dependency.types';
|
||||
import { taskDependenciesApiService } from '@/api/tasks/task-dependencies.api.service';
|
||||
import logger from '@/utils/errorLogger';
|
||||
import { getBase64 } from '@/utils/file-utils';
|
||||
import {
|
||||
ITaskAttachment,
|
||||
ITaskAttachmentViewModel,
|
||||
} from '@/types/tasks/task-attachment-view-model';
|
||||
import taskAttachmentsApiService from '@/api/tasks/task-attachments.api.service';
|
||||
import AttachmentsGrid from './attachments/attachments-grid';
|
||||
import TaskComments from './comments/task-comments';
|
||||
import { ITaskCommentViewModel } from '@/types/tasks/task-comments.types';
|
||||
import taskCommentsApiService from '@/api/tasks/task-comments.api.service';
|
||||
|
||||
interface TaskDrawerInfoTabProps {
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
const TaskDrawerInfoTab = ({ t }: TaskDrawerInfoTabProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||
const { taskFormViewModel, loadingTask, selectedTaskId } = useAppSelector(
|
||||
state => state.taskDrawerReducer
|
||||
);
|
||||
const [subTasks, setSubTasks] = useState<ISubTask[]>([]);
|
||||
const [loadingSubTasks, setLoadingSubTasks] = useState<boolean>(false);
|
||||
|
||||
const [taskDependencies, setTaskDependencies] = useState<ITaskDependency[]>([]);
|
||||
const [loadingTaskDependencies, setLoadingTaskDependencies] = useState<boolean>(false);
|
||||
|
||||
const [processingUpload, setProcessingUpload] = useState(false);
|
||||
const selectedFilesRef = useRef<File[]>([]);
|
||||
|
||||
const [taskAttachments, setTaskAttachments] = useState<ITaskAttachmentViewModel[]>([]);
|
||||
const [loadingTaskAttachments, setLoadingTaskAttachments] = useState<boolean>(false);
|
||||
|
||||
const [taskComments, setTaskComments] = useState<ITaskCommentViewModel[]>([]);
|
||||
const [loadingTaskComments, setLoadingTaskComments] = useState<boolean>(false);
|
||||
|
||||
const handleFilesSelected = async (files: File[]) => {
|
||||
if (!taskFormViewModel?.task?.id || !projectId) return;
|
||||
|
||||
if (!processingUpload) {
|
||||
setProcessingUpload(true);
|
||||
|
||||
try {
|
||||
const filesToUpload = [...files];
|
||||
selectedFilesRef.current = filesToUpload;
|
||||
|
||||
// Upload all files and wait for all promises to complete
|
||||
await Promise.all(
|
||||
filesToUpload.map(async file => {
|
||||
const base64 = await getBase64(file);
|
||||
const body: ITaskAttachment = {
|
||||
file: base64 as string,
|
||||
file_name: file.name,
|
||||
task_id: taskFormViewModel?.task?.id || '',
|
||||
project_id: projectId,
|
||||
size: file.size,
|
||||
};
|
||||
await taskAttachmentsApiService.createTaskAttachment(body);
|
||||
})
|
||||
);
|
||||
} finally {
|
||||
setProcessingUpload(false);
|
||||
selectedFilesRef.current = [];
|
||||
// Refetch attachments after all uploads are complete
|
||||
fetchTaskAttachments();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTaskData = () => {
|
||||
if (!loadingTask && selectedTaskId && projectId) {
|
||||
dispatch(fetchTask({ taskId: selectedTaskId, projectId }));
|
||||
}
|
||||
};
|
||||
|
||||
const panelStyle: React.CSSProperties = {
|
||||
border: 'none',
|
||||
paddingBlock: 0,
|
||||
};
|
||||
|
||||
// Define all info items
|
||||
const allInfoItems: CollapseProps['items'] = [
|
||||
{
|
||||
key: 'details',
|
||||
label: <Typography.Text strong>{t('taskInfoTab.details.title')}</Typography.Text>,
|
||||
children: <TaskDetailsForm taskFormViewModel={taskFormViewModel} />,
|
||||
style: panelStyle,
|
||||
className: 'custom-task-drawer-info-collapse',
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: <Typography.Text strong>{t('taskInfoTab.description.title')}</Typography.Text>,
|
||||
children: (
|
||||
<DescriptionEditor
|
||||
description={taskFormViewModel?.task?.description || null}
|
||||
taskId={taskFormViewModel?.task?.id || ''}
|
||||
parentTaskId={taskFormViewModel?.task?.parent_task_id || ''}
|
||||
/>
|
||||
),
|
||||
style: panelStyle,
|
||||
className: 'custom-task-drawer-info-collapse',
|
||||
},
|
||||
{
|
||||
key: 'subTasks',
|
||||
label: <Typography.Text strong>{t('taskInfoTab.subTasks.title')}</Typography.Text>,
|
||||
extra: (
|
||||
<Tooltip title={t('taskInfoTab.subTasks.refreshSubTasks')} trigger={'hover'}>
|
||||
<Button
|
||||
shape="circle"
|
||||
icon={<ReloadOutlined spin={loadingSubTasks} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation(); // Prevent click from bubbling up
|
||||
fetchSubTasks();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
),
|
||||
children: (
|
||||
<SubTaskTable
|
||||
subTasks={subTasks}
|
||||
loadingSubTasks={loadingSubTasks}
|
||||
refreshSubTasks={() => fetchSubTasks()}
|
||||
t={t}
|
||||
/>
|
||||
),
|
||||
style: panelStyle,
|
||||
className: 'custom-task-drawer-info-collapse',
|
||||
},
|
||||
{
|
||||
key: 'dependencies',
|
||||
label: <Typography.Text strong>{t('taskInfoTab.dependencies.title')}</Typography.Text>,
|
||||
children: (
|
||||
<DependenciesTable
|
||||
task={taskFormViewModel?.task || {}}
|
||||
t={t}
|
||||
taskDependencies={taskDependencies}
|
||||
loadingTaskDependencies={loadingTaskDependencies}
|
||||
refreshTaskDependencies={() => fetchTaskDependencies()}
|
||||
/>
|
||||
),
|
||||
style: panelStyle,
|
||||
className: 'custom-task-drawer-info-collapse',
|
||||
},
|
||||
{
|
||||
key: 'attachments',
|
||||
label: <Typography.Text strong>{t('taskInfoTab.attachments.title')}</Typography.Text>,
|
||||
children: (
|
||||
<Flex vertical gap={16}>
|
||||
<AttachmentsGrid
|
||||
attachments={taskAttachments}
|
||||
onDelete={() => fetchTaskAttachments()}
|
||||
onUpload={() => fetchTaskAttachments()}
|
||||
t={t}
|
||||
loadingTask={loadingTask}
|
||||
uploading={processingUpload}
|
||||
handleFilesSelected={handleFilesSelected}
|
||||
/>
|
||||
</Flex>
|
||||
),
|
||||
style: panelStyle,
|
||||
className: 'custom-task-drawer-info-collapse',
|
||||
},
|
||||
{
|
||||
key: 'comments',
|
||||
label: <Typography.Text strong>{t('taskInfoTab.comments.title')}</Typography.Text>,
|
||||
style: panelStyle,
|
||||
className: 'custom-task-drawer-info-collapse',
|
||||
children: (
|
||||
<TaskComments
|
||||
taskId={selectedTaskId || ''}
|
||||
t={t}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
// Filter out the 'subTasks' item if this task is a subtask
|
||||
const infoItems = taskFormViewModel?.task?.parent_task_id
|
||||
? allInfoItems.filter(item => item.key !== 'subTasks')
|
||||
: allInfoItems;
|
||||
|
||||
const fetchSubTasks = async () => {
|
||||
if (!selectedTaskId || loadingSubTasks) return;
|
||||
try {
|
||||
setLoadingSubTasks(true);
|
||||
const res = await subTasksApiService.getSubTasks(selectedTaskId);
|
||||
if (res.done) {
|
||||
setSubTasks(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching sub tasks:', error);
|
||||
} finally {
|
||||
setLoadingSubTasks(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTaskDependencies = async () => {
|
||||
if (!selectedTaskId || loadingTaskDependencies) return;
|
||||
try {
|
||||
setLoadingTaskDependencies(true);
|
||||
const res = await taskDependenciesApiService.getTaskDependencies(selectedTaskId);
|
||||
if (res.done) {
|
||||
setTaskDependencies(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching task dependencies:', error);
|
||||
} finally {
|
||||
setLoadingTaskDependencies(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTaskAttachments = async () => {
|
||||
if (!selectedTaskId || loadingTaskAttachments) return;
|
||||
try {
|
||||
setLoadingTaskAttachments(true);
|
||||
const res = await taskAttachmentsApiService.getTaskAttachments(selectedTaskId);
|
||||
if (res.done) {
|
||||
setTaskAttachments(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching task attachments:', error);
|
||||
} finally {
|
||||
setLoadingTaskAttachments(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchTaskComments = async () => {
|
||||
if (!selectedTaskId || loadingTaskComments) return;
|
||||
try {
|
||||
setLoadingTaskComments(true);
|
||||
const res = await taskCommentsApiService.getByTaskId(selectedTaskId);
|
||||
if (res.done) {
|
||||
setTaskComments(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error fetching task comments:', error);
|
||||
} finally {
|
||||
setLoadingTaskComments(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTaskData();
|
||||
fetchSubTasks();
|
||||
fetchTaskDependencies();
|
||||
fetchTaskAttachments();
|
||||
fetchTaskComments();
|
||||
|
||||
return () => {
|
||||
setSubTasks([]);
|
||||
setTaskDependencies([]);
|
||||
setTaskAttachments([]);
|
||||
selectedFilesRef.current = [];
|
||||
setTaskComments([]);
|
||||
};
|
||||
}, [selectedTaskId, projectId]);
|
||||
|
||||
return (
|
||||
<Skeleton active loading={loadingTask}>
|
||||
<Flex vertical>
|
||||
<Collapse
|
||||
items={infoItems}
|
||||
bordered={false}
|
||||
defaultActiveKey={[
|
||||
'details',
|
||||
'description',
|
||||
...(taskFormViewModel?.task?.parent_task_id ? [] : ['subTasks']),
|
||||
'dependencies',
|
||||
'attachments',
|
||||
'comments',
|
||||
]}
|
||||
/>
|
||||
</Flex>
|
||||
</Skeleton>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDrawerInfoTab;
|
||||
@@ -0,0 +1,162 @@
|
||||
import { DownloadOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { Button, Divider, Flex, Skeleton, Typography } from 'antd';
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import EmptyListPlaceholder from '@/components/EmptyListPlaceholder';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import TimeLogForm from './time-log-form';
|
||||
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
|
||||
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
||||
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
|
||||
import { useTaskTimer } from '@/hooks/useTaskTimer';
|
||||
import TimeLogItem from './time-log-item';
|
||||
|
||||
const TaskDrawerTimeLog = () => {
|
||||
const [timeLoggedList, setTimeLoggedList] = useState<ITaskLogViewModel[]>([]);
|
||||
const [totalTimeText, setTotalTimeText] = useState<string>('0m 0s');
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
const [isAddTimelogFormShow, setIsTimeLogFormShow] = useState<boolean>(false);
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { selectedTaskId, taskFormViewModel } = useAppSelector(state => state.taskDrawerReducer);
|
||||
|
||||
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer(
|
||||
selectedTaskId || '',
|
||||
taskFormViewModel?.task?.timer_start_time || null
|
||||
);
|
||||
|
||||
const buildTotalTimeText = (logs: ITaskLogViewModel[]) => {
|
||||
const totalLogged = logs.reduce((total, log) => {
|
||||
const timeSpentInSeconds = Number(log.time_spent || '0');
|
||||
log.time_spent_text = `${Math.floor(timeSpentInSeconds / 60)}m ${timeSpentInSeconds % 60}s`;
|
||||
return total + timeSpentInSeconds;
|
||||
}, 0);
|
||||
|
||||
const totalMinutes = Math.floor(totalLogged / 60);
|
||||
const totalSeconds = totalLogged % 60;
|
||||
setTotalTimeText(`${totalMinutes}m ${totalSeconds}s`);
|
||||
};
|
||||
|
||||
const fetchTimeLoggedList = async () => {
|
||||
if (!selectedTaskId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await taskTimeLogsApiService.getByTask(selectedTaskId);
|
||||
if (res.done) {
|
||||
buildTotalTimeText(res.body);
|
||||
setTimeLoggedList(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching time logs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTimerStop = async () => {
|
||||
handleStopTimer();
|
||||
await fetchTimeLoggedList();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTimeLoggedList();
|
||||
}, [selectedTaskId]);
|
||||
|
||||
const renderTimeLogList = () => {
|
||||
if (timeLoggedList.length === 0) {
|
||||
return (
|
||||
<Flex vertical gap={8} align="center">
|
||||
<EmptyListPlaceholder text="No time logs found in the task." imageHeight={120} />
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
style={{ width: 'fit-content' }}
|
||||
onClick={() => setIsTimeLogFormShow(true)}
|
||||
>
|
||||
Add Timelog
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Skeleton active loading={loading}>
|
||||
<Flex vertical gap={6}>
|
||||
{timeLoggedList.map(log => (
|
||||
<TimeLogItem key={log.id} log={log} />
|
||||
))}
|
||||
</Flex>
|
||||
</Skeleton>
|
||||
);
|
||||
};
|
||||
|
||||
const renderAddTimeLogButton = () => {
|
||||
if (!isAddTimelogFormShow && timeLoggedList.length > 0) {
|
||||
return (
|
||||
<Flex
|
||||
gap={8}
|
||||
vertical
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
height: 'fit-content',
|
||||
justifySelf: 'flex-end',
|
||||
paddingBlockStart: 24,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBlockEnd: 0,
|
||||
height: 1,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
width: '120%',
|
||||
backgroundColor: themeWiseColor('#ebebeb', '#3a3a3a', themeMode),
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
style={{ width: '100%' }}
|
||||
onClick={() => setIsTimeLogFormShow(true)}
|
||||
>
|
||||
Add Timelog
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex vertical justify="space-between" style={{ width: '100%', height: '78vh' }}>
|
||||
<Flex vertical>
|
||||
<Flex align="center" justify="space-between" style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary">Total Logged: {totalTimeText}</Typography.Text>
|
||||
<Flex gap={8} align="center">
|
||||
<TaskTimer
|
||||
started={started}
|
||||
handleStartTimer={handleStartTimer}
|
||||
handleStopTimer={handleTimerStop}
|
||||
timeString={timeString}
|
||||
timeTrackingLogCard={<div>Time Tracking Log</div>}
|
||||
/>
|
||||
<Button size="small" icon={<DownloadOutlined />}>
|
||||
Export to Excel
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Divider style={{ marginBlock: 8 }} />
|
||||
{renderTimeLogList()}
|
||||
</Flex>
|
||||
|
||||
{renderAddTimeLogButton()}
|
||||
{isAddTimelogFormShow && <TimeLogForm onCancel={() => setIsTimeLogFormShow(false)} />}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDrawerTimeLog;
|
||||
@@ -0,0 +1,147 @@
|
||||
import { DownloadOutlined, PlayCircleFilled, PlusOutlined } from '@ant-design/icons';
|
||||
import { Button, Divider, Flex, Skeleton, Typography } from 'antd';
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import EmptyListPlaceholder from '@/components/EmptyListPlaceholder';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setTimeLogEditing } from '@/features/task-drawer/task-drawer.slice';
|
||||
import TimeLogList from './time-log-list';
|
||||
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
|
||||
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
||||
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
|
||||
import { useTaskTimer } from '@/hooks/useTaskTimer';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
interface TaskDrawerTimeLogProps {
|
||||
t: TFunction;
|
||||
refreshTrigger?: number;
|
||||
}
|
||||
|
||||
const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) => {
|
||||
const [timeLoggedList, setTimeLoggedList] = useState<ITaskLogViewModel[]>([]);
|
||||
const [totalTimeText, setTotalTimeText] = useState<string>('0m 0s');
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { selectedTaskId, taskFormViewModel, timeLogEditing } = useAppSelector(
|
||||
state => state.taskDrawerReducer
|
||||
);
|
||||
|
||||
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer(
|
||||
selectedTaskId || '',
|
||||
taskFormViewModel?.task?.timer_start_time || null
|
||||
);
|
||||
|
||||
const formatTimeComponents = (hours: number, minutes: number, seconds: number): string => {
|
||||
const parts = [];
|
||||
if (hours > 0) parts.push(`${hours}h`);
|
||||
if (minutes > 0) parts.push(`${minutes}m`);
|
||||
if (seconds > 0 || parts.length === 0) parts.push(`${seconds}s`);
|
||||
return parts.join(' ');
|
||||
};
|
||||
|
||||
const buildTotalTimeText = useCallback((logs: ITaskLogViewModel[]) => {
|
||||
let totalLogged = 0;
|
||||
|
||||
for (const log of logs) {
|
||||
const timeSpentInSeconds = Number(log.time_spent || '0');
|
||||
|
||||
// Calculate hours, minutes, seconds for individual time log
|
||||
const hours = Math.floor(timeSpentInSeconds / 3600);
|
||||
const minutes = Math.floor((timeSpentInSeconds % 3600) / 60);
|
||||
const seconds = timeSpentInSeconds % 60;
|
||||
|
||||
// Format individual time log text
|
||||
log.time_spent_text = formatTimeComponents(hours, minutes, seconds);
|
||||
|
||||
// Add to total
|
||||
totalLogged += timeSpentInSeconds;
|
||||
}
|
||||
|
||||
// Format total time text
|
||||
const totalHours = Math.floor(totalLogged / 3600);
|
||||
const totalMinutes = Math.floor((totalLogged % 3600) / 60);
|
||||
const totalSeconds = totalLogged % 60;
|
||||
|
||||
setTotalTimeText(formatTimeComponents(totalHours, totalMinutes, totalSeconds));
|
||||
}, []);
|
||||
|
||||
const fetchTimeLoggedList = useCallback(async () => {
|
||||
if (!selectedTaskId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const res = await taskTimeLogsApiService.getByTask(selectedTaskId);
|
||||
if (res.done) {
|
||||
buildTotalTimeText(res.body);
|
||||
setTimeLoggedList(res.body);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch time logs', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [selectedTaskId, buildTotalTimeText]);
|
||||
|
||||
const handleTimerStop = async () => {
|
||||
handleStopTimer();
|
||||
await fetchTimeLoggedList();
|
||||
};
|
||||
|
||||
const handleExportToExcel = () => {
|
||||
if (!selectedTaskId) return;
|
||||
taskTimeLogsApiService.exportToExcel(selectedTaskId);
|
||||
};
|
||||
|
||||
// Fetch time logs when selectedTaskId changes or refreshTrigger changes
|
||||
useEffect(() => {
|
||||
fetchTimeLoggedList();
|
||||
}, [selectedTaskId, fetchTimeLoggedList, refreshTrigger]);
|
||||
|
||||
const renderTimeLogContent = () => {
|
||||
if (loading) {
|
||||
return <Skeleton active />;
|
||||
}
|
||||
|
||||
if (timeLoggedList.length === 0) {
|
||||
return (
|
||||
<Flex vertical gap={8} align="center">
|
||||
<EmptyListPlaceholder text={t('taskTimeLogTab.noTimeLogsFound')} imageHeight={120} />
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
return <TimeLogList timeLoggedList={timeLoggedList} onRefresh={fetchTimeLoggedList} />;
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex vertical justify="space-between" style={{ width: '100%', height: '78vh' }}>
|
||||
<Flex vertical>
|
||||
<Flex align="center" justify="space-between" style={{ width: '100%' }}>
|
||||
<Typography.Text type="secondary">
|
||||
{t('taskTimeLogTab.totalLogged')}: {totalTimeText}
|
||||
</Typography.Text>
|
||||
<Flex gap={8} align="center">
|
||||
<TaskTimer
|
||||
taskId={selectedTaskId || ''}
|
||||
started={started}
|
||||
handleStartTimer={handleStartTimer}
|
||||
handleStopTimer={handleTimerStop}
|
||||
timeString={timeString}
|
||||
/>
|
||||
<Button size="small" icon={<DownloadOutlined />} onClick={handleExportToExcel}>
|
||||
{t('taskTimeLogTab.exportToExcel')}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Divider style={{ marginBlock: 8 }} />
|
||||
{renderTimeLogContent()}
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDrawerTimeLog;
|
||||
@@ -0,0 +1,268 @@
|
||||
import React from 'react';
|
||||
import { Button, DatePicker, Form, Input, TimePicker, Flex } from 'antd';
|
||||
import { ClockCircleOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { themeWiseColor } from '@/utils/themeWiseColor';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { ITaskAssigneesUpdateResponse } from '@/types/tasks/task-assignee-update-response';
|
||||
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
||||
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
|
||||
|
||||
interface TimeLogFormProps {
|
||||
onCancel: () => void;
|
||||
onSubmitSuccess?: () => void;
|
||||
initialValues?: ITaskLogViewModel;
|
||||
mode?: 'create' | 'edit';
|
||||
}
|
||||
|
||||
const TimeLogForm = ({
|
||||
onCancel,
|
||||
onSubmitSuccess,
|
||||
initialValues,
|
||||
mode = 'create'
|
||||
}: TimeLogFormProps) => {
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
const { socket, connected } = useSocket();
|
||||
const [form] = Form.useForm();
|
||||
const [formValues, setFormValues] = React.useState<{
|
||||
date: any;
|
||||
startTime: any;
|
||||
endTime: any;
|
||||
}>({
|
||||
date: null,
|
||||
startTime: null,
|
||||
endTime: null,
|
||||
});
|
||||
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { taskFormViewModel } = useAppSelector(state => state.taskDrawerReducer);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (initialValues && mode === 'edit') {
|
||||
const createdAt = dayjs(initialValues.created_at);
|
||||
|
||||
const startTime = dayjs(initialValues.start_time || initialValues.created_at);
|
||||
|
||||
let endTime;
|
||||
if (initialValues.time_spent) {
|
||||
endTime = dayjs(startTime).add(initialValues.time_spent, 'second');
|
||||
} else {
|
||||
endTime = dayjs(initialValues.end_time || initialValues.created_at);
|
||||
}
|
||||
|
||||
form.setFieldsValue({
|
||||
date: createdAt,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
description: initialValues.description || '',
|
||||
});
|
||||
|
||||
setFormValues({
|
||||
date: createdAt,
|
||||
startTime: startTime,
|
||||
endTime: endTime,
|
||||
});
|
||||
}
|
||||
}, [initialValues, mode, form]);
|
||||
|
||||
const quickAssignMember = (session: any) => {
|
||||
if (!taskFormViewModel?.task || !connected) return;
|
||||
|
||||
const body = {
|
||||
team_member_id: session.team_member_id,
|
||||
project_id: taskFormViewModel?.task?.project_id,
|
||||
task_id: taskFormViewModel?.task?.id,
|
||||
reporter_id: session?.id,
|
||||
mode: 0,
|
||||
parent_task: taskFormViewModel?.task?.parent_task_id,
|
||||
};
|
||||
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body));
|
||||
socket?.once(
|
||||
SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(),
|
||||
(response: ITaskAssigneesUpdateResponse) => {
|
||||
if (session.team_member_id) {
|
||||
// TODO: emitTimeLogAssignMember(response);
|
||||
}
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const mapToRequest = () => {
|
||||
return {
|
||||
id: taskFormViewModel?.task?.id || undefined,
|
||||
project_id: taskFormViewModel?.task?.project_id as string,
|
||||
start_time: form.getFieldValue('startTime') || null,
|
||||
end_time: form.getFieldValue('endTime') || null,
|
||||
description: form.getFieldValue('description'),
|
||||
created_at: form.getFieldValue('date') || null,
|
||||
};
|
||||
};
|
||||
|
||||
const createReqBody = () => {
|
||||
const map = mapToRequest();
|
||||
if (!map.start_time || !map.end_time || !map.created_at) return;
|
||||
|
||||
const createdAt = new Date(map.created_at);
|
||||
const startTime = dayjs(map.start_time);
|
||||
const endTime = dayjs(map.end_time);
|
||||
|
||||
const formattedStartTime = dayjs(createdAt)
|
||||
.hour(startTime.hour())
|
||||
.minute(startTime.minute())
|
||||
.second(0)
|
||||
.millisecond(0);
|
||||
|
||||
const formattedEndTime = dayjs(createdAt)
|
||||
.hour(endTime.hour())
|
||||
.minute(endTime.minute())
|
||||
.second(0)
|
||||
.millisecond(0);
|
||||
|
||||
const diff = formattedStartTime.diff(formattedEndTime, 'seconds');
|
||||
|
||||
return {
|
||||
id: mode === 'edit' && initialValues?.id ? initialValues.id : taskFormViewModel?.task?.id,
|
||||
project_id: taskFormViewModel?.task?.project_id as string,
|
||||
formatted_start: formattedStartTime.toISOString(),
|
||||
seconds_spent: Math.floor(Math.abs(diff)),
|
||||
description: map.description,
|
||||
};
|
||||
};
|
||||
|
||||
const onFinish = async (values: any) => {
|
||||
const { date, startTime, endTime } = values;
|
||||
|
||||
if (startTime && endTime && startTime.isAfter(endTime)) {
|
||||
form.setFields([
|
||||
{
|
||||
name: 'endTime',
|
||||
errors: ['End time must be after start time'],
|
||||
},
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!currentSession) return;
|
||||
|
||||
const assignees = taskFormViewModel?.task?.assignees as string[];
|
||||
if (currentSession && !assignees.includes(currentSession?.team_member_id as string)) {
|
||||
quickAssignMember(currentSession);
|
||||
}
|
||||
|
||||
const requestBody = createReqBody();
|
||||
if (!requestBody) return;
|
||||
|
||||
try {
|
||||
if (mode === 'edit' && initialValues?.id) {
|
||||
console.log('Updating time log:', requestBody);
|
||||
await taskTimeLogsApiService.update(initialValues.id, requestBody);
|
||||
} else {
|
||||
console.log('Creating new time log:', requestBody);
|
||||
await taskTimeLogsApiService.create(requestBody);
|
||||
}
|
||||
console.log('Received values:', values);
|
||||
|
||||
// Call onSubmitSuccess if provided, otherwise just cancel
|
||||
if (onSubmitSuccess) {
|
||||
onSubmitSuccess();
|
||||
} else {
|
||||
onCancel();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving time log:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const isFormValid = () => {
|
||||
return formValues.date && formValues.startTime && formValues.endTime;
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex
|
||||
gap={8}
|
||||
vertical
|
||||
align="center"
|
||||
justify="center"
|
||||
style={{
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
height: 'fit-content',
|
||||
justifySelf: 'flex-end',
|
||||
paddingTop: 16,
|
||||
paddingBottom: 0,
|
||||
overflow: 'visible'
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
marginBlockEnd: 0,
|
||||
height: 1,
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
width: '100%',
|
||||
backgroundColor: themeWiseColor('#ebebeb', '#3a3a3a', themeMode),
|
||||
}}
|
||||
/>
|
||||
|
||||
<Form
|
||||
form={form}
|
||||
style={{ width: '100%', overflow: 'visible' }}
|
||||
layout="vertical"
|
||||
onFinish={onFinish}
|
||||
onValuesChange={(_, values) => setFormValues(values)}
|
||||
>
|
||||
<Form.Item style={{ marginBlockEnd: 0 }}>
|
||||
<Flex gap={8} wrap="wrap" style={{ width: '100%' }}>
|
||||
<Form.Item
|
||||
name="date"
|
||||
label="Date"
|
||||
rules={[{ required: true, message: 'Please select a date' }]}
|
||||
>
|
||||
<DatePicker disabledDate={current => current && current.toDate() > new Date()} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="startTime"
|
||||
label="Start Time"
|
||||
rules={[{ required: true, message: 'Please select start time' }]}
|
||||
>
|
||||
<TimePicker format="HH:mm" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="endTime"
|
||||
label="End Time"
|
||||
rules={[{ required: true, message: 'Please select end time' }]}
|
||||
>
|
||||
<TimePicker format="HH:mm" />
|
||||
</Form.Item>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="description" label="Work Description" style={{ marginBlockEnd: 12 }}>
|
||||
<Input.TextArea placeholder="Add a description" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item style={{ marginBlockEnd: 0 }}>
|
||||
<Flex gap={8}>
|
||||
<Button onClick={onCancel}>Cancel</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<ClockCircleOutlined />}
|
||||
disabled={!isFormValid()}
|
||||
htmlType="submit"
|
||||
>
|
||||
{mode === 'edit' ? 'Update time' : 'Log time'}
|
||||
</Button>
|
||||
</Flex>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeLogForm;
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import { Button, Divider, Flex, Popconfirm, Typography, Space } from 'antd';
|
||||
import { colors } from '@/styles/colors';
|
||||
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
||||
import SingleAvatar from '@/components/common/single-avatar/single-avatar';
|
||||
import { formatDateTimeWithLocale } from '@/utils/format-date-time-with-locale';
|
||||
import { calculateTimeGap } from '@/utils/calculate-time-gap';
|
||||
import './time-log-item.css';
|
||||
import { taskTimeLogsApiService } from '@/api/tasks/task-time-logs.api.service';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setTimeLogEditing } from '@/features/task-drawer/task-drawer.slice';
|
||||
import TimeLogForm from './time-log-form';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
|
||||
type TimeLogItemProps = {
|
||||
log: ITaskLogViewModel;
|
||||
onDelete?: () => void;
|
||||
};
|
||||
|
||||
const TimeLogItem = ({ log, onDelete }: TimeLogItemProps) => {
|
||||
const { user_name, avatar_url, time_spent_text, logged_by_timer, created_at, user_id, description } = log;
|
||||
const { selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
|
||||
const dispatch = useAppDispatch();
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
const renderLoggedByTimer = () => {
|
||||
if (!logged_by_timer) return null;
|
||||
return (
|
||||
<>
|
||||
via Timer about{' '}
|
||||
<Typography.Text strong style={{ fontSize: 15 }}>
|
||||
{logged_by_timer}
|
||||
</Typography.Text>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const canDelete = user_id === currentSession?.id;
|
||||
|
||||
const handleDeleteTimeLog = async (logId: string | undefined) => {
|
||||
if (!logId || !selectedTaskId) return;
|
||||
const res = await taskTimeLogsApiService.delete(logId, selectedTaskId);
|
||||
if (res.done) {
|
||||
if (onDelete) onDelete();
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = () => {
|
||||
dispatch(
|
||||
setTimeLogEditing({
|
||||
isEditing: true,
|
||||
logBeingEdited: log,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const renderActionButtons = () => {
|
||||
if (!canDelete) return null;
|
||||
|
||||
return (
|
||||
<Space size={8}>
|
||||
<Button type="link" onClick={handleEdit} style={{ padding: '0', height: 'auto', fontSize: '14px' }}>
|
||||
Edit
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="Are you sure you want to delete this time log?"
|
||||
onConfirm={() => handleDeleteTimeLog(log.id)}
|
||||
>
|
||||
<Button type="link" style={{ padding: '0', height: 'auto', fontSize: '14px' }}>
|
||||
Delete
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="time-log-item">
|
||||
<Flex vertical gap={8}>
|
||||
<Flex align="start" gap={12}>
|
||||
<SingleAvatar avatarUrl={avatar_url} name={user_name} />
|
||||
<Flex vertical style={{ flex: 1 }}>
|
||||
<Flex justify="space-between" align="start">
|
||||
<Flex vertical>
|
||||
<Typography.Text>
|
||||
<Typography.Text strong>{user_name}</Typography.Text> logged <Typography.Text strong>{time_spent_text}</Typography.Text> {renderLoggedByTimer()} {calculateTimeGap(created_at || '')}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
{formatDateTimeWithLocale(created_at || '')}
|
||||
</Typography.Text>
|
||||
</Flex>
|
||||
{renderActionButtons()}
|
||||
</Flex>
|
||||
|
||||
{description && (
|
||||
<Typography.Text style={{ marginTop: 8, display: 'block' }}>
|
||||
{description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</Flex>
|
||||
</Flex>
|
||||
<Divider style={{ margin: '8px 0' }} />
|
||||
</Flex>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeLogItem;
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Flex } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import TimeLogItem from './time-log-item';
|
||||
import { ITaskLogViewModel } from '@/types/tasks/task-log-view.types';
|
||||
|
||||
type TimeLogListProps = {
|
||||
timeLoggedList: ITaskLogViewModel[];
|
||||
onRefresh?: () => void;
|
||||
};
|
||||
|
||||
const TimeLogList = ({ timeLoggedList, onRefresh }: TimeLogListProps) => {
|
||||
return (
|
||||
<Flex vertical gap={6}>
|
||||
{timeLoggedList.map(log => (
|
||||
<TimeLogItem
|
||||
key={log.id}
|
||||
log={log}
|
||||
onDelete={onRefresh}
|
||||
/>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TimeLogList;
|
||||
@@ -0,0 +1,9 @@
|
||||
.task-name-input {
|
||||
outline: none;
|
||||
transition: outline 0.2s ease;
|
||||
}
|
||||
|
||||
.task-name-input:not(:focus):hover {
|
||||
outline: 1px solid #d9d9d9;
|
||||
border-radius: 4px;
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Button, Dropdown, Flex, Input, InputRef, MenuProps } from 'antd';
|
||||
import React, { ChangeEvent, useEffect, useRef, useState } from 'react';
|
||||
import { EllipsisOutlined } from '@ant-design/icons';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
import './task-drawer-header.css';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import TaskDrawerStatusDropdown from '../task-drawer-status-dropdown/task-drawer-status-dropdown';
|
||||
import { tasksApiService } from '@/api/tasks/tasks.api.service';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { setSelectedTaskId, setShowTaskDrawer } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import useTaskDrawerUrlSync from '@/hooks/useTaskDrawerUrlSync';
|
||||
import { deleteTask } from '@/features/tasks/tasks.slice';
|
||||
import { deleteBoardTask, updateTaskName } from '@/features/board/board-slice';
|
||||
|
||||
type TaskDrawerHeaderProps = {
|
||||
inputRef: React.RefObject<InputRef | null>;
|
||||
t: TFunction;
|
||||
};
|
||||
|
||||
const TaskDrawerHeader = ({ inputRef, t }: TaskDrawerHeaderProps) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { socket, connected } = useSocket();
|
||||
const { clearTaskFromUrl } = useTaskDrawerUrlSync();
|
||||
const isDeleting = useRef(false);
|
||||
|
||||
const { taskFormViewModel, selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
|
||||
const [taskName, setTaskName] = useState<string>(taskFormViewModel?.task?.name ?? '');
|
||||
const currentSession = useAuthService().getCurrentSession();
|
||||
|
||||
useEffect(() => {
|
||||
setTaskName(taskFormViewModel?.task?.name ?? '');
|
||||
}, [taskFormViewModel?.task?.name]);
|
||||
|
||||
const onTaskNameChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
setTaskName(e.currentTarget.value);
|
||||
};
|
||||
|
||||
const handleDeleteTask = async () => {
|
||||
if (!selectedTaskId) return;
|
||||
|
||||
// Set flag to indicate we're deleting the task
|
||||
isDeleting.current = true;
|
||||
|
||||
const res = await tasksApiService.deleteTask(selectedTaskId);
|
||||
if (res.done) {
|
||||
// Explicitly clear the task parameter from URL
|
||||
clearTaskFromUrl();
|
||||
|
||||
dispatch(setShowTaskDrawer(false));
|
||||
dispatch(setSelectedTaskId(null));
|
||||
dispatch(deleteTask({ taskId: selectedTaskId }));
|
||||
dispatch(deleteBoardTask({ sectionId: '', taskId: selectedTaskId }));
|
||||
|
||||
// Reset the flag after a short delay
|
||||
setTimeout(() => {
|
||||
isDeleting.current = false;
|
||||
}, 100);
|
||||
if (taskFormViewModel?.task?.parent_task_id) {
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), taskFormViewModel?.task?.parent_task_id);
|
||||
}
|
||||
} else {
|
||||
isDeleting.current = false;
|
||||
}
|
||||
};
|
||||
|
||||
const deletTaskDropdownItems: MenuProps['items'] = [
|
||||
{
|
||||
key: '1',
|
||||
label: (
|
||||
<Flex gap={8} align="center">
|
||||
<Button type="text" danger onClick={handleDeleteTask}>
|
||||
{t('taskHeader.deleteTask')}
|
||||
</Button>
|
||||
</Flex>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const handleReceivedTaskNameChange = (data: { id: string; parent_task: string; name: string }) => {
|
||||
if (data.id === selectedTaskId) {
|
||||
dispatch(updateTaskName({ task: data }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputBlur = () => {
|
||||
if (
|
||||
!selectedTaskId ||
|
||||
!connected ||
|
||||
taskName === taskFormViewModel?.task?.name ||
|
||||
taskName === undefined ||
|
||||
taskName === null ||
|
||||
taskName === ''
|
||||
)
|
||||
return;
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_NAME_CHANGE.toString(),
|
||||
JSON.stringify({
|
||||
task_id: selectedTaskId,
|
||||
name: taskName,
|
||||
parent_task: taskFormViewModel?.task?.parent_task_id,
|
||||
})
|
||||
);
|
||||
socket?.once(SocketEvents.TASK_NAME_CHANGE.toString(), (data: any) => {
|
||||
handleReceivedTaskNameChange(data);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex gap={12} align="center" style={{ marginBlockEnd: 6 }}>
|
||||
<Flex style={{ position: 'relative', width: '100%' }}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
size="large"
|
||||
value={taskName}
|
||||
onChange={e => onTaskNameChange(e)}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder={t('taskHeader.taskNamePlaceholder')}
|
||||
className="task-name-input"
|
||||
style={{
|
||||
width: '100%',
|
||||
border: 'none',
|
||||
}}
|
||||
showCount={false}
|
||||
maxLength={250}
|
||||
/>
|
||||
</Flex>
|
||||
|
||||
<TaskDrawerStatusDropdown
|
||||
statuses={taskFormViewModel?.statuses ?? []}
|
||||
task={taskFormViewModel?.task ?? {}}
|
||||
teamId={currentSession?.team_id ?? ''}
|
||||
/>
|
||||
|
||||
<Dropdown overlayClassName={'delete-task-dropdown'} menu={{ items: deletTaskDropdownItems }}>
|
||||
<Button type="text" icon={<EllipsisOutlined />} />
|
||||
</Dropdown>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDrawerHeader;
|
||||
@@ -0,0 +1,19 @@
|
||||
.status-dropdown .ant-dropdown-menu {
|
||||
padding: 0 !important;
|
||||
margin-top: 8px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.status-dropdown .ant-dropdown-menu-item {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.status-dropdown-card .ant-card-body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.status-menu .ant-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 41px !important;
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { updateBoardTaskStatus } from '@/features/board/board-slice';
|
||||
import { setTaskStatus } from '@/features/task-drawer/task-drawer.slice';
|
||||
import { updateTaskStatus } from '@/features/tasks/tasks.slice';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||
import alertService from '@/services/alerts/alertService';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { ITaskListStatusChangeResponse } from '@/types/tasks/task-list-status.types';
|
||||
import { ITaskViewModel } from '@/types/tasks/task.types';
|
||||
import { ITaskStatus } from '@/types/tasks/taskStatus.types';
|
||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||
import { Select } from 'antd';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
|
||||
interface TaskDrawerStatusDropdownProps {
|
||||
statuses: ITaskStatus[];
|
||||
task: ITaskViewModel;
|
||||
teamId: string;
|
||||
}
|
||||
|
||||
const TaskDrawerStatusDropdown = ({ statuses, task, teamId }: TaskDrawerStatusDropdownProps) => {
|
||||
const { socket, connected } = useSocket();
|
||||
const dispatch = useAppDispatch();
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const { tab } = useTabSearchParam();
|
||||
|
||||
const getTaskProgress = (taskId: string) => {
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), taskId);
|
||||
};
|
||||
|
||||
const handleStatusChange = async (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?.once(
|
||||
SocketEvents.TASK_STATUS_CHANGE.toString(),
|
||||
(data: ITaskListStatusChangeResponse) => {
|
||||
dispatch(setTaskStatus(data));
|
||||
|
||||
if (tab === 'tasks-list') {
|
||||
dispatch(updateTaskStatus(data));
|
||||
}
|
||||
if (tab === 'board') {
|
||||
dispatch(updateBoardTaskStatus(data));
|
||||
}
|
||||
if (data.parent_task) getTaskProgress(data.parent_task);
|
||||
}
|
||||
);
|
||||
if (task.status_id !== statusId) {
|
||||
const canContinue = await checkTaskDependencyStatus(task.id, statusId);
|
||||
if (!canContinue) {
|
||||
alertService.error(
|
||||
'Task is not completed',
|
||||
'Please complete the task dependencies before proceeding'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
const options = useMemo(
|
||||
() =>
|
||||
statuses.map(status => ({
|
||||
value: status.id,
|
||||
label: status.name,
|
||||
})),
|
||||
[statuses]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{task.status_id && (
|
||||
<Select
|
||||
variant="borderless"
|
||||
value={task.status_id}
|
||||
onChange={handleStatusChange}
|
||||
dropdownStyle={{ borderRadius: 8, minWidth: 150, maxWidth: 200 }}
|
||||
style={{
|
||||
backgroundColor: themeMode === 'dark' ? task.status_color_dark : task.status_color,
|
||||
borderRadius: 16,
|
||||
}}
|
||||
labelRender={status => {
|
||||
return <span style={{ fontSize: 13 }}>{status.label}</span>;
|
||||
}}
|
||||
options={options}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDrawerStatusDropdown;
|
||||
32
worklenz-frontend/src/components/task-drawer/task-drawer.css
Normal file
32
worklenz-frontend/src/components/task-drawer/task-drawer.css
Normal file
@@ -0,0 +1,32 @@
|
||||
.delete-task-dropdown .ant-dropdown-menu {
|
||||
padding: 0 !important;
|
||||
margin-top: 8px !important;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.delete-task-dropdown .ant-dropdown-menu-item {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.delete-task-dropdown-card .ant-card-body {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.delete-task-menu .ant-menu-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.delete-task-button {
|
||||
color: red;
|
||||
}
|
||||
|
||||
.delete-task-button:hover {
|
||||
background-color: red !important;
|
||||
color: white !important;
|
||||
}
|
||||
|
||||
.custom-task-drawer-info-collapse .ant-collapse-header {
|
||||
padding-block-end: 0 !important;
|
||||
}
|
||||
221
worklenz-frontend/src/components/task-drawer/task-drawer.tsx
Normal file
221
worklenz-frontend/src/components/task-drawer/task-drawer.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
import { TabsProps, Tabs, Button } from 'antd';
|
||||
import Drawer from 'antd/es/drawer';
|
||||
import { InputRef } from 'antd/es/input';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import {
|
||||
setSelectedTaskId,
|
||||
setShowTaskDrawer,
|
||||
setTaskFormViewModel,
|
||||
setTaskSubscribers,
|
||||
setTimeLogEditing,
|
||||
} from '@/features/task-drawer/task-drawer.slice';
|
||||
|
||||
import './task-drawer.css';
|
||||
import TaskDrawerHeader from './task-drawer-header/task-drawer-header';
|
||||
import TaskDrawerActivityLog from './shared/activity-log/task-drawer-activity-log';
|
||||
import TaskDrawerInfoTab from './shared/info-tab/task-drawer-info-tab';
|
||||
import TaskDrawerTimeLog from './shared/time-log/task-drawer-time-log';
|
||||
import TimeLogForm from './shared/time-log/time-log-form';
|
||||
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
||||
import useTaskDrawerUrlSync from '@/hooks/useTaskDrawerUrlSync';
|
||||
import InfoTabFooter from './shared/info-tab/info-tab-footer';
|
||||
import { Flex } from 'antd';
|
||||
|
||||
const TaskDrawer = () => {
|
||||
const { t } = useTranslation('task-drawer/task-drawer');
|
||||
const [activeTab, setActiveTab] = useState<string>('info');
|
||||
const [refreshTimeLogTrigger, setRefreshTimeLogTrigger] = useState(0);
|
||||
|
||||
const { showTaskDrawer, timeLogEditing } = useAppSelector(state => state.taskDrawerReducer);
|
||||
|
||||
const taskNameInputRef = useRef<InputRef>(null);
|
||||
const isClosingManually = useRef(false);
|
||||
|
||||
// Use the custom hook to sync the task drawer state with the URL
|
||||
const { clearTaskFromUrl } = useTaskDrawerUrlSync();
|
||||
|
||||
useEffect(() => {
|
||||
if (taskNameInputRef.current?.input?.value === DEFAULT_TASK_NAME) {
|
||||
taskNameInputRef.current.focus();
|
||||
}
|
||||
}, [showTaskDrawer]);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
|
||||
const handleOnClose = () => {
|
||||
// Set flag to indicate we're manually closing the drawer
|
||||
isClosingManually.current = true;
|
||||
setActiveTab('info');
|
||||
|
||||
// Explicitly clear the task parameter from URL
|
||||
clearTaskFromUrl();
|
||||
|
||||
// Update the Redux state
|
||||
dispatch(setShowTaskDrawer(false));
|
||||
dispatch(setSelectedTaskId(null));
|
||||
dispatch(setTaskFormViewModel({}));
|
||||
dispatch(setTaskSubscribers([]));
|
||||
|
||||
// Reset the flag after a short delay
|
||||
setTimeout(() => {
|
||||
isClosingManually.current = false;
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleTabChange = (key: string) => {
|
||||
setActiveTab(key);
|
||||
};
|
||||
|
||||
const handleCancelTimeLog = () => {
|
||||
dispatch(
|
||||
setTimeLogEditing({
|
||||
isEditing: false,
|
||||
logBeingEdited: null,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
const handleAddTimeLog = () => {
|
||||
dispatch(
|
||||
setTimeLogEditing({
|
||||
isEditing: true,
|
||||
logBeingEdited: null,
|
||||
})
|
||||
);
|
||||
};
|
||||
|
||||
// Function to trigger a refresh of the time log list
|
||||
const refreshTimeLogs = () => {
|
||||
setRefreshTimeLogTrigger(prev => prev + 1);
|
||||
};
|
||||
|
||||
const handleTimeLogSubmitSuccess = () => {
|
||||
// Close the form
|
||||
handleCancelTimeLog();
|
||||
// Trigger refresh of time logs
|
||||
refreshTimeLogs();
|
||||
};
|
||||
|
||||
const tabItems: TabsProps['items'] = [
|
||||
{
|
||||
key: 'info',
|
||||
label: t('taskInfoTab.title'),
|
||||
children: <TaskDrawerInfoTab t={t} />,
|
||||
},
|
||||
{
|
||||
key: 'timeLog',
|
||||
label: t('taskTimeLogTab.title'),
|
||||
children: <TaskDrawerTimeLog t={t} refreshTrigger={refreshTimeLogTrigger} />,
|
||||
},
|
||||
{
|
||||
key: 'activityLog',
|
||||
label: t('taskActivityLogTab.title'),
|
||||
children: <TaskDrawerActivityLog />,
|
||||
},
|
||||
];
|
||||
|
||||
// Render the appropriate footer based on the active tab
|
||||
const renderFooter = () => {
|
||||
if (activeTab === 'info') {
|
||||
return <InfoTabFooter />;
|
||||
} else if (activeTab === 'timeLog') {
|
||||
if (timeLogEditing.isEditing) {
|
||||
return (
|
||||
<TimeLogForm
|
||||
onCancel={handleCancelTimeLog}
|
||||
onSubmitSuccess={handleTimeLogSubmitSuccess}
|
||||
initialValues={timeLogEditing.logBeingEdited || undefined}
|
||||
mode={timeLogEditing.logBeingEdited ? 'edit' : 'create'}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<Flex justify="center" style={{ width: '100%', padding: '16px 0 0' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddTimeLog}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
Add new time log
|
||||
</Button>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Create conditional footer styles based on active tab
|
||||
const getFooterStyle = () => {
|
||||
const baseStyle = {
|
||||
padding: '0 24px 16px',
|
||||
width: '100%',
|
||||
height: 'auto',
|
||||
boxSizing: 'border-box' as const,
|
||||
};
|
||||
|
||||
if (activeTab === 'timeLog') {
|
||||
return {
|
||||
...baseStyle,
|
||||
overflow: 'visible', // Remove scrolling for timeLog tab
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...baseStyle,
|
||||
overflow: 'hidden',
|
||||
};
|
||||
};
|
||||
|
||||
// Get conditional body style
|
||||
const getBodyStyle = () => {
|
||||
const baseStyle = {
|
||||
padding: '24px',
|
||||
overflow: 'auto'
|
||||
};
|
||||
|
||||
if (activeTab === 'timeLog' && timeLogEditing.isEditing) {
|
||||
return {
|
||||
...baseStyle,
|
||||
height: 'calc(100% - 220px)', // More space for the timeLog form
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...baseStyle,
|
||||
height: 'calc(100% - 180px)',
|
||||
};
|
||||
};
|
||||
|
||||
const drawerProps = {
|
||||
open: showTaskDrawer,
|
||||
onClose: handleOnClose,
|
||||
width: 720,
|
||||
style: { justifyContent: 'space-between' },
|
||||
destroyOnClose: true,
|
||||
title: <TaskDrawerHeader inputRef={taskNameInputRef} t={t} />,
|
||||
footer: renderFooter(),
|
||||
bodyStyle: getBodyStyle(),
|
||||
footerStyle: getFooterStyle(),
|
||||
};
|
||||
|
||||
return (
|
||||
<Drawer {...drawerProps}>
|
||||
<Tabs
|
||||
type="card"
|
||||
items={tabItems}
|
||||
destroyInactiveTabPane
|
||||
onChange={handleTabChange}
|
||||
activeKey={activeTab}
|
||||
/>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDrawer;
|
||||
Reference in New Issue
Block a user