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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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