From f48476478ab4403dc987d1da131bf728b77c0081 Mon Sep 17 00:00:00 2001 From: shancds Date: Tue, 15 Jul 2025 16:19:40 +0530 Subject: [PATCH 01/21] Implement task deletion functionality in TaskCard component - Added context menu for task deletion with confirmation modal. - Integrated localization for delete task prompts in multiple languages. - Updated TaskCard to handle task deletion logic, including dispatching actions to update the state and emit socket events for task progress. --- .../public/locales/alb/kanban-board.json | 5 + .../public/locales/de/kanban-board.json | 5 + .../public/locales/en/kanban-board.json | 5 + .../public/locales/es/kanban-board.json | 5 + .../public/locales/pt/kanban-board.json | 5 + .../public/locales/zh/kanban-board.json | 6 +- .../EnhancedKanbanBoardNativeDnD/TaskCard.tsx | 111 +++++++++++++++++- 7 files changed, 138 insertions(+), 4 deletions(-) diff --git a/worklenz-frontend/public/locales/alb/kanban-board.json b/worklenz-frontend/public/locales/alb/kanban-board.json index def705aa..71245ac4 100644 --- a/worklenz-frontend/public/locales/alb/kanban-board.json +++ b/worklenz-frontend/public/locales/alb/kanban-board.json @@ -10,6 +10,11 @@ "deleteConfirmationOk": "Po", "deleteConfirmationCancel": "Anulo", + "deleteTaskTitle": "Fshi Detyrën", + "deleteTaskContent": "Jeni i sigurt që doni të fshini këtë detyrë? Kjo veprim nuk mund të zhbëhet.", + "deleteTaskConfirm": "Fshi", + "deleteTaskCancel": "Anulo", + "dueDate": "Data e përfundimit", "cancel": "Anulo", diff --git a/worklenz-frontend/public/locales/de/kanban-board.json b/worklenz-frontend/public/locales/de/kanban-board.json index 70e1f6ca..8fc39559 100644 --- a/worklenz-frontend/public/locales/de/kanban-board.json +++ b/worklenz-frontend/public/locales/de/kanban-board.json @@ -10,6 +10,11 @@ "deleteConfirmationOk": "Ja", "deleteConfirmationCancel": "Abbrechen", + "deleteTaskTitle": "Aufgabe löschen", + "deleteTaskContent": "Sind Sie sicher, dass Sie diese Aufgabe löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "deleteTaskConfirm": "Löschen", + "deleteTaskCancel": "Abbrechen", + "dueDate": "Fälligkeitsdatum", "cancel": "Abbrechen", diff --git a/worklenz-frontend/public/locales/en/kanban-board.json b/worklenz-frontend/public/locales/en/kanban-board.json index e295a6c6..f9cf0bd4 100644 --- a/worklenz-frontend/public/locales/en/kanban-board.json +++ b/worklenz-frontend/public/locales/en/kanban-board.json @@ -10,6 +10,11 @@ "deleteConfirmationOk": "Yes", "deleteConfirmationCancel": "Cancel", + "deleteTaskTitle": "Delete Task", + "deleteTaskContent": "Are you sure you want to delete this task? This action cannot be undone.", + "deleteTaskConfirm": "Delete", + "deleteTaskCancel": "Cancel", + "dueDate": "Due date", "cancel": "Cancel", diff --git a/worklenz-frontend/public/locales/es/kanban-board.json b/worklenz-frontend/public/locales/es/kanban-board.json index 6e8d5975..703afd32 100644 --- a/worklenz-frontend/public/locales/es/kanban-board.json +++ b/worklenz-frontend/public/locales/es/kanban-board.json @@ -10,6 +10,11 @@ "deleteConfirmationOk": "Sí", "deleteConfirmationCancel": "Cancelar", + "deleteTaskTitle": "Eliminar tarea", + "deleteTaskContent": "¿Estás seguro de que deseas eliminar esta tarea? Esta acción no se puede deshacer.", + "deleteTaskConfirm": "Eliminar", + "deleteTaskCancel": "Cancelar", + "dueDate": "Fecha de vencimiento", "cancel": "Cancelar", diff --git a/worklenz-frontend/public/locales/pt/kanban-board.json b/worklenz-frontend/public/locales/pt/kanban-board.json index a2034daa..3ba0626d 100644 --- a/worklenz-frontend/public/locales/pt/kanban-board.json +++ b/worklenz-frontend/public/locales/pt/kanban-board.json @@ -10,6 +10,11 @@ "deleteConfirmationOk": "Sim", "deleteConfirmationCancel": "Cancelar", + "deleteTaskTitle": "Excluir Tarefa", + "deleteTaskContent": "Tem certeza de que deseja excluir esta tarefa? Esta ação não pode ser desfeita.", + "deleteTaskConfirm": "Excluir", + "deleteTaskCancel": "Cancelar", + "dueDate": "Data de vencimento", "cancel": "Cancelar", diff --git a/worklenz-frontend/public/locales/zh/kanban-board.json b/worklenz-frontend/public/locales/zh/kanban-board.json index 7b72c5d5..51f7a171 100644 --- a/worklenz-frontend/public/locales/zh/kanban-board.json +++ b/worklenz-frontend/public/locales/zh/kanban-board.json @@ -15,5 +15,9 @@ "assignToMe": "分配给我", "archive": "归档", "newTaskNamePlaceholder": "写一个任务名称", - "newSubtaskNamePlaceholder": "写一个子任务名称" + "newSubtaskNamePlaceholder": "写一个子任务名称", + "deleteTaskTitle": "删除任务", + "deleteTaskContent": "您确定要删除此任务吗?此操作无法撤销。", + "deleteTaskConfirm": "删除", + "deleteTaskCancel": "取消" } \ No newline at end of file diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx index bf499a12..daa8793c 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx @@ -14,8 +14,11 @@ import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; import { getUserSession } from '@/utils/session-helper'; import { themeWiseColor } from '@/utils/themeWiseColor'; -import { toggleTaskExpansion, fetchBoardSubTasks } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { toggleTaskExpansion, fetchBoardSubTasks, deleteTask as deleteKanbanTask, updateEnhancedKanbanSubtask } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import TaskProgressCircle from './TaskProgressCircle'; +import { Button, Modal } from 'antd'; +import { DeleteOutlined } from '@ant-design/icons'; +import { tasksApiService } from '@/api/tasks/tasks.api.service'; // Simple Portal component const Portal: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -70,6 +73,9 @@ const TaskCard: React.FC = memo(({ const d = selectedDate || new Date(); return new Date(d.getFullYear(), d.getMonth(), 1); }); + const [contextMenu, setContextMenu] = useState<{ visible: boolean; x: number; y: number }>({ visible: false, x: 0, y: 0 }); + const contextMenuRef = useRef(null); + const [selectedTask, setSelectedTask] = useState(null); useEffect(() => { setSelectedDate(task.end_date ? new Date(task.end_date) : null); @@ -102,6 +108,21 @@ const TaskCard: React.FC = memo(({ } }, [showDatePicker]); + // Hide context menu on click elsewhere + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) { + setContextMenu({ ...contextMenu, visible: false }); + } + }; + if (contextMenu.visible) { + document.addEventListener('mousedown', handleClick); + } + return () => { + document.removeEventListener('mousedown', handleClick); + }; + }, [contextMenu]); + const handleCardClick = useCallback((e: React.MouseEvent, id: string) => { e.stopPropagation(); dispatch(setSelectedTaskId(id)); @@ -178,6 +199,48 @@ const TaskCard: React.FC = memo(({ handleSubTaskExpand(); }, [handleSubTaskExpand]); + // Delete logic (similar to task-drawer-header) + const handleDeleteTask = async (task: IProjectTask | null) => { + if (!task || !task.id) return; + Modal.confirm({ + title: t('deleteTaskTitle'), + content: t('deleteTaskContent'), + okText: t('deleteTaskConfirm'), + okType: 'danger', + cancelText: t('deleteTaskCancel'), + centered: true, + onOk: async () => { + if (!task.id) return; + const res = await tasksApiService.deleteTask(task.id); + if (res.done) { + dispatch(setSelectedTaskId(null)); + if (task.is_sub_task) { + dispatch(updateEnhancedKanbanSubtask({ + sectionId: '', + subtask: { id: task.id , parent_task_id: task.parent_task_id || '', manual_progress: false }, + mode: 'delete', + })); + } else { + dispatch(deleteKanbanTask(task.id)); + } + dispatch(setShowTaskDrawer(false)); + if (task.parent_task_id) { + socket?.emit( + SocketEvents.GET_TASK_PROGRESS.toString(), + task.parent_task_id + ); + } + } + setContextMenu({ visible: false, x: 0, y: 0 }); + setSelectedTask(null); + }, + onCancel: () => { + setContextMenu({ visible: false, x: 0, y: 0 }); + setSelectedTask(null); + }, + }); + }; + // Calendar rendering helpers const year = calendarMonth.getFullYear(); const month = calendarMonth.getMonth(); @@ -202,7 +265,37 @@ const TaskCard: React.FC = memo(({ return ( <> -
+ {/* Context menu for delete */} + {contextMenu.visible && ( +
+ +
+ )} +
{/* Progress circle at top right */}
@@ -221,6 +314,11 @@ const TaskCard: React.FC = memo(({ onDrop={e => onTaskDrop(e, groupId, idx)} onDragEnd={onDragEnd} // <-- add this onClick={e => handleCardClick(e, task.id!)} + onContextMenu={e => { + e.preventDefault(); + setContextMenu({ visible: true, x: e.clientX, y: e.clientY }); + setSelectedTask(task); + }} >
@@ -447,7 +545,14 @@ const TaskCard: React.FC = memo(({ {!task.sub_tasks_loading && Array.isArray(task.sub_tasks) && task.sub_tasks.length > 0 && (
    {task.sub_tasks.map(sub => ( -
  • handleCardClick(e, sub.id!)} className="flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-50 dark:hover:bg-gray-800"> +
  • handleCardClick(e, sub.id!)} + className="flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-50 dark:hover:bg-gray-800" + onContextMenu={e => { + e.preventDefault(); + setContextMenu({ visible: true, x: e.clientX, y: e.clientY }); + setSelectedTask(sub); + }}> {sub.priority_color || sub.priority_color_dark ? ( Date: Tue, 15 Jul 2025 16:34:03 +0530 Subject: [PATCH 02/21] Add delete status confirmation modal with localization support - Implemented a confirmation modal for deleting statuses in the Kanban board. - Integrated localized messages for delete status prompts in multiple languages, enhancing user experience. - Removed the previous portal-based confirmation approach in favor of Ant Design's Modal component for better consistency and usability. --- .../public/locales/alb/kanban-board.json | 3 + .../public/locales/de/kanban-board.json | 3 + .../public/locales/en/kanban-board.json | 3 + .../public/locales/es/kanban-board.json | 3 + .../public/locales/pt/kanban-board.json | 3 + .../public/locales/zh/kanban-board.json | 4 +- .../KanbanGroup.tsx | 77 +++++++------------ 7 files changed, 44 insertions(+), 52 deletions(-) diff --git a/worklenz-frontend/public/locales/alb/kanban-board.json b/worklenz-frontend/public/locales/alb/kanban-board.json index 71245ac4..9dc3ac7d 100644 --- a/worklenz-frontend/public/locales/alb/kanban-board.json +++ b/worklenz-frontend/public/locales/alb/kanban-board.json @@ -15,6 +15,9 @@ "deleteTaskConfirm": "Fshi", "deleteTaskCancel": "Anulo", + "deleteStatusTitle": "Fshi Statusin", + "deleteStatusContent": "Jeni i sigurt që doni të fshini këtë status? Kjo veprim nuk mund të zhbëhet.", + "dueDate": "Data e përfundimit", "cancel": "Anulo", diff --git a/worklenz-frontend/public/locales/de/kanban-board.json b/worklenz-frontend/public/locales/de/kanban-board.json index 8fc39559..cdb2f3c8 100644 --- a/worklenz-frontend/public/locales/de/kanban-board.json +++ b/worklenz-frontend/public/locales/de/kanban-board.json @@ -15,6 +15,9 @@ "deleteTaskConfirm": "Löschen", "deleteTaskCancel": "Abbrechen", + "deleteStatusTitle": "Status löschen", + "deleteStatusContent": "Sind Sie sicher, dass Sie diesen Status löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "dueDate": "Fälligkeitsdatum", "cancel": "Abbrechen", diff --git a/worklenz-frontend/public/locales/en/kanban-board.json b/worklenz-frontend/public/locales/en/kanban-board.json index f9cf0bd4..d629b3a2 100644 --- a/worklenz-frontend/public/locales/en/kanban-board.json +++ b/worklenz-frontend/public/locales/en/kanban-board.json @@ -15,6 +15,9 @@ "deleteTaskConfirm": "Delete", "deleteTaskCancel": "Cancel", + "deleteStatusTitle": "Delete Status", + "deleteStatusContent": "Are you sure you want to delete this status? This action cannot be undone.", + "dueDate": "Due date", "cancel": "Cancel", diff --git a/worklenz-frontend/public/locales/es/kanban-board.json b/worklenz-frontend/public/locales/es/kanban-board.json index 703afd32..6dc68a29 100644 --- a/worklenz-frontend/public/locales/es/kanban-board.json +++ b/worklenz-frontend/public/locales/es/kanban-board.json @@ -15,6 +15,9 @@ "deleteTaskConfirm": "Eliminar", "deleteTaskCancel": "Cancelar", + "deleteStatusTitle": "Eliminar estado", + "deleteStatusContent": "¿Estás seguro de que deseas eliminar este estado? Esta acción no se puede deshacer.", + "dueDate": "Fecha de vencimiento", "cancel": "Cancelar", diff --git a/worklenz-frontend/public/locales/pt/kanban-board.json b/worklenz-frontend/public/locales/pt/kanban-board.json index 3ba0626d..6055b1c9 100644 --- a/worklenz-frontend/public/locales/pt/kanban-board.json +++ b/worklenz-frontend/public/locales/pt/kanban-board.json @@ -15,6 +15,9 @@ "deleteTaskConfirm": "Excluir", "deleteTaskCancel": "Cancelar", + "deleteStatusTitle": "Excluir Status", + "deleteStatusContent": "Tem certeza de que deseja excluir este status? Esta ação não pode ser desfeita.", + "dueDate": "Data de vencimento", "cancel": "Cancelar", diff --git a/worklenz-frontend/public/locales/zh/kanban-board.json b/worklenz-frontend/public/locales/zh/kanban-board.json index 51f7a171..b147066c 100644 --- a/worklenz-frontend/public/locales/zh/kanban-board.json +++ b/worklenz-frontend/public/locales/zh/kanban-board.json @@ -19,5 +19,7 @@ "deleteTaskTitle": "删除任务", "deleteTaskContent": "您确定要删除此任务吗?此操作无法撤销。", "deleteTaskConfirm": "删除", - "deleteTaskCancel": "取消" + "deleteTaskCancel": "取消", + "deleteStatusTitle": "删除状态", + "deleteStatusContent": "您确定要删除此状态吗?此操作无法撤销。" } \ No newline at end of file diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx index f5e443d2..927bfd3c 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx @@ -25,6 +25,7 @@ import { IGroupBy, } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import { createPortal } from 'react-dom'; +import { Modal } from 'antd'; // Simple Portal component const Portal: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -218,7 +219,30 @@ const KanbanGroup: React.FC = memo(({ }; const handleDelete = () => { - setShowDeleteConfirm(true); + if (groupBy === IGroupBy.STATUS) { + Modal.confirm({ + title: t('deleteStatusTitle'), + content: t('deleteStatusContent'), + okText: t('deleteTaskConfirm'), + okType: 'danger', + cancelText: t('deleteTaskCancel'), + centered: true, + onOk: async () => { + await handleDeleteSection(); + }, + }); + } else { + Modal.confirm({ + title: t('deleteConfirmationTitle'), + okText: t('deleteTaskConfirm'), + okType: 'danger', + cancelText: t('deleteTaskCancel'), + centered: true, + onOk: async () => { + await handleDeleteSection(); + }, + }); + } setShowDropdown(false); }; @@ -419,56 +443,7 @@ const KanbanGroup: React.FC = memo(({
{/* Simple Delete Confirmation */} - {showDeleteConfirm && ( - -
setShowDeleteConfirm(false)} - > -
e.stopPropagation()} - > -
-
-
- - - -
-
-

- {t('deleteConfirmationTitle')} -

-
-
-
- - -
-
-
-
-
- )} + {/* Portal-based confirmation removed, now handled by Modal.confirm */}
{/* Create card at top */} {showNewCardTop && ( From d89247eb023e9fcf979f35550cd37f1c10e78950 Mon Sep 17 00:00:00 2001 From: shancds Date: Tue, 15 Jul 2025 16:45:57 +0530 Subject: [PATCH 03/21] Add delete phase confirmation modal with localization support - Implemented a confirmation modal for deleting phases in the Kanban board. - Integrated localized messages for delete phase prompts in multiple languages, enhancing user experience. - Updated KanbanGroup component to utilize the new confirmation modal for phase deletions. --- .../public/locales/alb/kanban-board.json | 3 +++ .../public/locales/de/kanban-board.json | 3 +++ .../public/locales/en/kanban-board.json | 3 +++ .../public/locales/es/kanban-board.json | 3 +++ .../public/locales/pt/kanban-board.json | 3 +++ .../public/locales/zh/kanban-board.json | 4 +++- .../EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx | 12 ++++++++++++ 7 files changed, 30 insertions(+), 1 deletion(-) diff --git a/worklenz-frontend/public/locales/alb/kanban-board.json b/worklenz-frontend/public/locales/alb/kanban-board.json index 9dc3ac7d..48987f10 100644 --- a/worklenz-frontend/public/locales/alb/kanban-board.json +++ b/worklenz-frontend/public/locales/alb/kanban-board.json @@ -18,6 +18,9 @@ "deleteStatusTitle": "Fshi Statusin", "deleteStatusContent": "Jeni i sigurt që doni të fshini këtë status? Kjo veprim nuk mund të zhbëhet.", + "deletePhaseTitle": "Fshi Fazen", + "deletePhaseContent": "Jeni i sigurt që doni të fshini këtë fazë? Kjo veprim nuk mund të zhbëhet.", + "dueDate": "Data e përfundimit", "cancel": "Anulo", diff --git a/worklenz-frontend/public/locales/de/kanban-board.json b/worklenz-frontend/public/locales/de/kanban-board.json index cdb2f3c8..9d748e30 100644 --- a/worklenz-frontend/public/locales/de/kanban-board.json +++ b/worklenz-frontend/public/locales/de/kanban-board.json @@ -18,6 +18,9 @@ "deleteStatusTitle": "Status löschen", "deleteStatusContent": "Sind Sie sicher, dass Sie diesen Status löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "deletePhaseTitle": "Phase löschen", + "deletePhaseContent": "Sind Sie sicher, dass Sie diese Phase löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "dueDate": "Fälligkeitsdatum", "cancel": "Abbrechen", diff --git a/worklenz-frontend/public/locales/en/kanban-board.json b/worklenz-frontend/public/locales/en/kanban-board.json index d629b3a2..018d52a9 100644 --- a/worklenz-frontend/public/locales/en/kanban-board.json +++ b/worklenz-frontend/public/locales/en/kanban-board.json @@ -18,6 +18,9 @@ "deleteStatusTitle": "Delete Status", "deleteStatusContent": "Are you sure you want to delete this status? This action cannot be undone.", + "deletePhaseTitle": "Delete Phase", + "deletePhaseContent": "Are you sure you want to delete this phase? This action cannot be undone.", + "dueDate": "Due date", "cancel": "Cancel", diff --git a/worklenz-frontend/public/locales/es/kanban-board.json b/worklenz-frontend/public/locales/es/kanban-board.json index 6dc68a29..c49a02ca 100644 --- a/worklenz-frontend/public/locales/es/kanban-board.json +++ b/worklenz-frontend/public/locales/es/kanban-board.json @@ -18,6 +18,9 @@ "deleteStatusTitle": "Eliminar estado", "deleteStatusContent": "¿Estás seguro de que deseas eliminar este estado? Esta acción no se puede deshacer.", + "deletePhaseTitle": "Eliminar fase", + "deletePhaseContent": "¿Estás seguro de que deseas eliminar esta fase? Esta acción no se puede deshacer.", + "dueDate": "Fecha de vencimiento", "cancel": "Cancelar", diff --git a/worklenz-frontend/public/locales/pt/kanban-board.json b/worklenz-frontend/public/locales/pt/kanban-board.json index 6055b1c9..f290258e 100644 --- a/worklenz-frontend/public/locales/pt/kanban-board.json +++ b/worklenz-frontend/public/locales/pt/kanban-board.json @@ -18,6 +18,9 @@ "deleteStatusTitle": "Excluir Status", "deleteStatusContent": "Tem certeza de que deseja excluir este status? Esta ação não pode ser desfeita.", + "deletePhaseTitle": "Excluir Fase", + "deletePhaseContent": "Tem certeza de que deseja excluir esta fase? Esta ação não pode ser desfeita.", + "dueDate": "Data de vencimento", "cancel": "Cancelar", diff --git a/worklenz-frontend/public/locales/zh/kanban-board.json b/worklenz-frontend/public/locales/zh/kanban-board.json index b147066c..4c51dfa8 100644 --- a/worklenz-frontend/public/locales/zh/kanban-board.json +++ b/worklenz-frontend/public/locales/zh/kanban-board.json @@ -21,5 +21,7 @@ "deleteTaskConfirm": "删除", "deleteTaskCancel": "取消", "deleteStatusTitle": "删除状态", - "deleteStatusContent": "您确定要删除此状态吗?此操作无法撤销。" + "deleteStatusContent": "您确定要删除此状态吗?此操作无法撤销。", + "deletePhaseTitle": "删除阶段", + "deletePhaseContent": "您确定要删除此阶段吗?此操作无法撤销。" } \ No newline at end of file diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx index 927bfd3c..9338b48b 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx @@ -231,6 +231,18 @@ const KanbanGroup: React.FC = memo(({ await handleDeleteSection(); }, }); + } else if (groupBy === IGroupBy.PHASE) { + Modal.confirm({ + title: t('deletePhaseTitle'), + content: t('deletePhaseContent'), + okText: t('deleteTaskConfirm'), + okType: 'danger', + cancelText: t('deleteTaskCancel'), + centered: true, + onOk: async () => { + await handleDeleteSection(); + }, + }); } else { Modal.confirm({ title: t('deleteConfirmationTitle'), From f03f6e6f5d542492e5ff2e36db18c2904c27a4a0 Mon Sep 17 00:00:00 2001 From: shancds Date: Tue, 15 Jul 2025 18:17:32 +0530 Subject: [PATCH 04/21] Implement task order updates and socket emissions in EnhancedKanbanBoardNativeDnD - Added a utility function to recalculate task orders for all groups based on the specified grouping criteria (status, priority, phase). - Updated task drag-and-drop logic to handle reordering within the same group and across different groups. - Enhanced socket emissions to send full task order updates, including task details and indices, improving synchronization with the backend. --- .../EnhancedKanbanBoardNativeDnD.tsx | 62 +++++++++++++------ 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx index ae1e3c35..8431073f 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx @@ -118,6 +118,26 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project setDragType(null); }; + // Utility to recalculate all task orders for all groups + function getAllTaskUpdates(allGroups, groupBy) { + const taskUpdates = []; + let currentSortOrder = 0; + for (const group of allGroups) { + for (const task of group.tasks) { + const update = { + task_id: task.id, + sort_order: currentSortOrder, + }; + if (groupBy === 'status') update.status_id = group.id; + else if (groupBy === 'priority') update.priority_id = group.id; + else if (groupBy === 'phase') update.phase_id = group.id; + taskUpdates.push(update); + currentSortOrder++; + } + } + return taskUpdates; + } + // Task drag handlers const handleTaskDragStart = (e: React.DragEvent, taskId: string, groupId: string) => { setDraggedTaskId(taskId); @@ -168,6 +188,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project let insertIdx = hoveredTaskIdx; // Handle same group reordering + let newTaskGroups = [...taskGroups]; if (sourceGroup.id === targetGroup.id) { // Create a single updated array for the same group const updatedTasks = [...sourceGroup.tasks]; @@ -201,6 +222,8 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project updatedSourceTasks: updatedTasks, updatedTargetTasks: updatedTasks, }) as any); + // Update newTaskGroups for socket emit + newTaskGroups = newTaskGroups.map(g => g.id === sourceGroup.id ? { ...g, tasks: updatedTasks } : g); } else { // Handle cross-group reordering const updatedSourceTasks = [...sourceGroup.tasks]; @@ -229,34 +252,33 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project updatedSourceTasks, updatedTargetTasks, }) as any); + // Update newTaskGroups for socket emit + newTaskGroups = newTaskGroups.map(g => { + if (g.id === sourceGroup.id) return { ...g, tasks: updatedSourceTasks }; + if (g.id === targetGroup.id) return { ...g, tasks: updatedTargetTasks }; + return g; + }); } - // Socket emit for task order + // Socket emit for full task order if (socket && projectId && teamId && movedTask) { - let toSortOrder = -1; - let toLastIndex = false; - if (insertIdx === targetGroup.tasks.length) { - toSortOrder = -1; - toLastIndex = true; - } else if (targetGroup.tasks[insertIdx]) { - const sortOrder = targetGroup.tasks[insertIdx].sort_order; - toSortOrder = typeof sortOrder === 'number' ? sortOrder : 0; - toLastIndex = false; - } else if (targetGroup.tasks.length > 0) { - const lastSortOrder = targetGroup.tasks[targetGroup.tasks.length - 1].sort_order; - toSortOrder = typeof lastSortOrder === 'number' ? lastSortOrder : 0; - toLastIndex = false; - } + const taskUpdates = getAllTaskUpdates(newTaskGroups, groupBy); socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { project_id: projectId, - from_index: movedTask.sort_order ?? 0, - to_index: toSortOrder, - to_last_index: toLastIndex, + group_by: groupBy || 'status', + task_updates: taskUpdates, from_group: sourceGroup.id, to_group: targetGroup.id, - group_by: groupBy || 'status', - task: movedTask, team_id: teamId, + from_index: taskIdx, + to_index: insertIdx, + to_last_index: insertIdx === (targetGroup.id === sourceGroup.id ? newTaskGroups.find(g => g.id === targetGroup.id)?.tasks.length - 1 : targetGroup.tasks.length), + task: { + id: movedTask.id, + project_id: movedTask.project_id || projectId, + status: movedTask.status || '', + priority: movedTask.priority || '', + } }); // Emit progress update if status changed From 72269322476ace1a7ac0b445209c62eae1c63165 Mon Sep 17 00:00:00 2001 From: shancds Date: Thu, 17 Jul 2025 10:56:06 +0530 Subject: [PATCH 05/21] Enhance EnhancedKanbanBoardNativeDnD to support phase reordering - Integrated phase reordering functionality within the EnhancedKanbanBoardNativeDnD component. - Added logic to fetch phases by project ID and update phase order through API calls. - Updated drag-and-drop handling to accommodate reordering of phases alongside existing status-based reordering. --- .../EnhancedKanbanBoardNativeDnD.tsx | 27 +- .../projectView/board/project-view-board.tsx | 583 ++++++++++++++++++ 2 files changed, 608 insertions(+), 2 deletions(-) create mode 100644 worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx index 8431073f..48219464 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx @@ -21,6 +21,9 @@ import alertService from '@/services/alerts/alertService'; import logger from '@/utils/errorLogger'; import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status'; import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; +import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service'; +import { ITaskListGroup } from '@/types/tasks/taskList.types'; +import { fetchPhasesByProjectId, updatePhaseListOrder } from '@/features/projects/singleProject/phase/phases.slice'; const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ projectId }) => { const dispatch = useDispatch(); @@ -34,6 +37,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project loadingGroups, error, } = useSelector((state: RootState) => state.enhancedKanbanReducer); + const { phaseList, loadingPhases } = useAppSelector(state => state.phaseReducer); const [draggedGroupId, setDraggedGroupId] = useState(null); const [draggedTaskId, setDraggedTaskId] = useState(null); const [draggedTaskGroupId, setDraggedTaskGroupId] = useState(null); @@ -56,6 +60,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project if (!statusCategories.length) { dispatch(fetchStatusesCategories() as any); } + if ( groupBy === 'phase' && !phaseList.length) { + dispatch(fetchPhasesByProjectId(projectId) as any); + } }, [dispatch, projectId]); // Reset drag state if taskGroups changes (e.g., real-time update) useEffect(() => { @@ -90,9 +97,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project reorderedGroups.splice(toIdx, 0, moved); dispatch(reorderGroups({ fromIndex: fromIdx, toIndex: toIdx, reorderedGroups })); dispatch(reorderEnhancedKanbanGroups({ fromIndex: fromIdx, toIndex: toIdx, reorderedGroups }) as any); - // API call for group order try { + if (groupBy === 'status') { const columnOrder = reorderedGroups.map(group => group.id); const requestBody = { status_order: columnOrder }; const response = await statusApiService.updateStatusOrder(requestBody, projectId); @@ -104,6 +111,22 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups })); alertService.error('Failed to update column order', 'Please try again'); } + } else if (groupBy === 'phase') { + const newPhaseList = [...phaseList]; + const [movedItem] = newPhaseList.splice(fromIdx, 1); + newPhaseList.splice(toIdx, 0, movedItem); + dispatch(updatePhaseListOrder(newPhaseList)); + const requestBody = { + from_index: fromIdx, + to_index: toIdx, + phases: newPhaseList, + project_id: projectId, + }; + const response = await phasesApiService.updatePhaseOrder(projectId, requestBody); + if (!response.done) { + alertService.error('Failed to update phase order', 'Please try again'); + } + } } catch (error) { // Revert the change if API call fails const revertedGroups = [...reorderedGroups]; @@ -119,7 +142,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project }; // Utility to recalculate all task orders for all groups - function getAllTaskUpdates(allGroups, groupBy) { + function getAllTaskUpdates(allGroups: ITaskListGroup[], groupBy: string) { const taskUpdates = []; let currentSortOrder = 0; for (const group of allGroups) { diff --git a/worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx b/worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx new file mode 100644 index 00000000..4bea53db --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/board/project-view-board.tsx @@ -0,0 +1,583 @@ +import { useEffect, useState, useRef, useMemo, useCallback } from 'react'; +import { useAppSelector } from '@/hooks/useAppSelector'; + +import TaskListFilters from '../taskList/task-list-filters/task-list-filters'; +import { Flex, Skeleton } from 'antd'; +import BoardSectionCardContainer from './board-section/board-section-container'; +import { + fetchBoardTaskGroups, + reorderTaskGroups, + moveTaskBetweenGroups, + IGroupBy, + updateTaskProgress, +} from '@features/board/board-slice'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { + DndContext, + DragEndEvent, + DragOverEvent, + DragStartEvent, + closestCenter, + DragOverlay, + MouseSensor, + TouchSensor, + useSensor, + useSensors, + getFirstCollision, + pointerWithin, + rectIntersection, + UniqueIdentifier, +} from '@dnd-kit/core'; +import BoardViewTaskCard from './board-section/board-task-card/board-view-task-card'; +import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; +import useTabSearchParam from '@/hooks/useTabSearchParam'; +import { useSocket } from '@/socket/socketContext'; +import { useAuthService } from '@/hooks/useAuth'; +import { SocketEvents } from '@/shared/socket-events'; +import alertService from '@/services/alerts/alertService'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { evt_project_board_visit, evt_project_task_list_drag_and_move } from '@/shared/worklenz-analytics-events'; +import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request'; +import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; +import logger from '@/utils/errorLogger'; +import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status'; +import { debounce } from 'lodash'; +import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types'; +import { updateTaskPriority as updateBoardTaskPriority } from '@/features/board/board-slice'; + +interface DroppableContainer { + id: UniqueIdentifier; + data: { + current?: { + type?: string; + }; + }; +} + +const ProjectViewBoard = () => { + const dispatch = useAppDispatch(); + const { projectView } = useTabSearchParam(); + const { socket } = useSocket(); + const authService = useAuthService(); + const currentSession = authService.getCurrentSession(); + const { trackMixpanelEvent } = useMixpanelTracking(); + const [currentTaskIndex, setCurrentTaskIndex] = useState(-1); + // Add local loading state to immediately show skeleton + const [isLoading, setIsLoading] = useState(true); + + const { projectId } = useAppSelector(state => state.projectReducer); + const { taskGroups, groupBy, loadingGroups, search, archived } = useAppSelector(state => state.boardReducer); + const { statusCategories, loading: loadingStatusCategories } = useAppSelector( + state => state.taskStatusReducer + ); + const [activeItem, setActiveItem] = useState(null); + + // Store the original source group ID when drag starts + const originalSourceGroupIdRef = useRef(null); + const lastOverId = useRef(null); + const recentlyMovedToNewContainer = useRef(false); + const [clonedItems, setClonedItems] = useState(null); + const isDraggingRef = useRef(false); + + // Update loading state based on all loading conditions + useEffect(() => { + setIsLoading(loadingGroups || loadingStatusCategories); + }, [loadingGroups, loadingStatusCategories]); + + // Load data efficiently with async/await and Promise.all + useEffect(() => { + const loadData = async () => { + if (projectId && groupBy && projectView === 'kanban') { + const promises = []; + + if (!loadingGroups) { + promises.push(dispatch(fetchBoardTaskGroups(projectId))); + } + + if (!statusCategories.length) { + promises.push(dispatch(fetchStatusesCategories())); + } + + // Wait for all data to load + await Promise.all(promises); + } + }; + + loadData(); + }, [dispatch, projectId, groupBy, projectView, search, archived]); + + // Create sensors with memoization to prevent unnecessary re-renders + const sensors = useSensors( + useSensor(MouseSensor, { + activationConstraint: { + distance: 10, + delay: 100, + tolerance: 5, + }, + }), + useSensor(TouchSensor, { + activationConstraint: { + delay: 250, + tolerance: 5, + }, + }) + ); + + const collisionDetectionStrategy = useCallback( + (args: { + active: { id: UniqueIdentifier; data: { current?: { type?: string } } }; + droppableContainers: DroppableContainer[]; + }) => { + if (activeItem?.type === 'section') { + return closestCenter({ + ...args, + droppableContainers: args.droppableContainers.filter( + (container: DroppableContainer) => container.data.current?.type === 'section' + ), + }); + } + + // Start by finding any intersecting droppable + const pointerIntersections = pointerWithin(args); + const intersections = + pointerIntersections.length > 0 + ? pointerIntersections + : rectIntersection(args); + let overId = getFirstCollision(intersections, 'id'); + + if (overId !== null) { + const overContainer = args.droppableContainers.find( + (container: DroppableContainer) => container.id === overId + ); + + if (overContainer?.data.current?.type === 'section') { + const containerItems = taskGroups.find( + (group) => group.id === overId + )?.tasks || []; + + if (containerItems.length > 0) { + overId = closestCenter({ + ...args, + droppableContainers: args.droppableContainers.filter( + (container: DroppableContainer) => + container.id !== overId && + container.data.current?.type === 'task' + ), + })[0]?.id; + } + } + + lastOverId.current = overId; + return [{ id: overId }]; + } + + if (recentlyMovedToNewContainer.current) { + lastOverId.current = activeItem?.id; + } + + return lastOverId.current ? [{ id: lastOverId.current }] : []; + }, + [activeItem, taskGroups] + ); + + const handleTaskProgress = (data: { + id: string; + status: string; + complete_ratio: number; + completed_count: number; + total_tasks_count: number; + parent_task: string; + }) => { + dispatch(updateTaskProgress(data)); + }; + + // Debounced move task function to prevent rapid updates + const debouncedMoveTask = useCallback( + debounce((taskId: string, sourceGroupId: string, targetGroupId: string, targetIndex: number) => { + dispatch( + moveTaskBetweenGroups({ + taskId, + sourceGroupId, + targetGroupId, + targetIndex, + }) + ); + }, 100), + [dispatch] + ); + + const handleDragStart = (event: DragStartEvent) => { + const { active } = event; + isDraggingRef.current = true; + setActiveItem(active.data.current); + setCurrentTaskIndex(active.data.current?.sortable.index); + if (active.data.current?.type === 'task') { + originalSourceGroupIdRef.current = active.data.current.sectionId; + } + setClonedItems(taskGroups); + }; + + const findGroupForId = (id: string) => { + // If id is a sectionId + if (taskGroups.some(group => group.id === id)) return id; + // If id is a taskId, find the group containing it + const group = taskGroups.find(g => g.tasks.some(t => t.id === id)); + return group?.id; + }; + + const handleDragOver = (event: DragOverEvent) => { + try { + if (!isDraggingRef.current) return; + + const { active, over } = event; + if (!over) return; + + // Get the ids + const activeId = active.id; + const overId = over.id; + + // Find the group (section) for each + const activeGroupId = findGroupForId(activeId as string); + const overGroupId = findGroupForId(overId as string); + + // Only move if both groups exist and are different, and the active is a task + if ( + activeGroupId && + overGroupId && + active.data.current?.type === 'task' + ) { + // Find the target index in the over group + const targetGroup = taskGroups.find(g => g.id === overGroupId); + let targetIndex = 0; + if (targetGroup) { + // If over is a task, insert before it; if over is a section, append to end + if (over.data.current?.type === 'task') { + targetIndex = targetGroup.tasks.findIndex(t => t.id === overId); + if (targetIndex === -1) targetIndex = targetGroup.tasks.length; + } else { + targetIndex = targetGroup.tasks.length; + } + } + // Use debounced move task to prevent rapid updates + debouncedMoveTask( + activeId as string, + activeGroupId, + overGroupId, + targetIndex + ); + } + } catch (error) { + console.error('handleDragOver error:', error); + } + }; + + const handlePriorityChange = (taskId: string, priorityId: string) => { + if (!taskId || !priorityId || !socket) return; + + const payload = { + task_id: taskId, + priority_id: priorityId, + team_id: currentSession?.team_id, + }; + + socket.emit(SocketEvents.TASK_PRIORITY_CHANGE.toString(), JSON.stringify(payload)); + socket.once(SocketEvents.TASK_PRIORITY_CHANGE.toString(), (data: ITaskListPriorityChangeResponse) => { + dispatch(updateBoardTaskPriority(data)); + }); + }; + + const handleDragEnd = async (event: DragEndEvent) => { + isDraggingRef.current = false; + const { active, over } = event; + + if (!over || !projectId) { + setActiveItem(null); + originalSourceGroupIdRef.current = null; + setClonedItems(null); + return; + } + + const isActiveTask = active.data.current?.type === 'task'; + const isActiveSection = active.data.current?.type === 'section'; + + // Handle task dragging between columns + if (isActiveTask) { + const task = active.data.current?.task; + + // Use the original source group ID from ref instead of the potentially modified one + const sourceGroupId = originalSourceGroupIdRef.current || active.data.current?.sectionId; + + // Fix: Ensure we correctly identify the target group ID + let targetGroupId; + if (over.data.current?.type === 'task') { + // If dropping on a task, get its section ID + targetGroupId = over.data.current?.sectionId; + } else if (over.data.current?.type === 'section') { + // If dropping directly on a section + targetGroupId = over.id; + } else { + // Fallback to the over ID if type is not specified + targetGroupId = over.id; + } + + // Find source and target groups + const sourceGroup = taskGroups.find(group => group.id === sourceGroupId); + const targetGroup = taskGroups.find(group => group.id === targetGroupId); + + if (!sourceGroup || !targetGroup || !task) { + logger.error('Could not find source or target group, or task is undefined'); + setActiveItem(null); + originalSourceGroupIdRef.current = null; // Reset the ref + return; + } + + if (targetGroupId !== sourceGroupId) { + const canContinue = await checkTaskDependencyStatus(task.id, targetGroupId); + if (!canContinue) { + alertService.error( + 'Task is not completed', + 'Please complete the task dependencies before proceeding' + ); + dispatch( + moveTaskBetweenGroups({ + taskId: task.id, + sourceGroupId: targetGroupId, // Current group (where it was moved optimistically) + targetGroupId: sourceGroupId, // Move it back to the original source group + targetIndex: currentTaskIndex !== -1 ? currentTaskIndex : 0, // Original position or append to end + }) + ); + + setActiveItem(null); + originalSourceGroupIdRef.current = null; + return; + } + } + + // Find indices + let fromIndex = sourceGroup.tasks.findIndex(t => t.id === task.id); + // Handle case where task is not found in source group (might have been moved already in UI) + if (fromIndex === -1) { + logger.info('Task not found in source group. Using task sort_order from task object.'); + + // Use the sort_order from the task object itself + const fromSortOrder = task.sort_order; + + // Calculate target index and position + let toIndex = -1; + if (over.data.current?.type === 'task') { + const overTaskId = over.data.current?.task.id; + toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId); + } else { + // If dropping on a section, append to the end + toIndex = targetGroup.tasks.length; + } + + // Calculate toPos similar to Angular implementation + const toPos = targetGroup.tasks[toIndex]?.sort_order || + targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order || + -1; + + // Prepare socket event payload + const body = { + project_id: projectId, + from_index: fromSortOrder, + to_index: toPos, + to_last_index: !toPos, + from_group: sourceGroupId, + to_group: targetGroupId, + group_by: groupBy || 'status', + task, + team_id: currentSession?.team_id + }; + + // Emit socket event + if (socket) { + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body); + + // Set up listener for task progress update + socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => { + if (task.is_sub_task) { + socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id); + } else { + socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id); + } + }); + + // Handle priority change if groupBy is priority + if (groupBy === IGroupBy.PRIORITY) { + handlePriorityChange(task.id, targetGroupId); + } + } + + // Track analytics event + trackMixpanelEvent(evt_project_task_list_drag_and_move); + + setActiveItem(null); + originalSourceGroupIdRef.current = null; // Reset the ref + return; + } + + // Calculate target index and position + let toIndex = -1; + if (over.data.current?.type === 'task') { + const overTaskId = over.data.current?.task.id; + toIndex = targetGroup.tasks.findIndex(t => t.id === overTaskId); + } else { + // If dropping on a section, append to the end + toIndex = targetGroup.tasks.length; + } + + // Calculate toPos similar to Angular implementation + const toPos = targetGroup.tasks[toIndex]?.sort_order || + targetGroup.tasks[targetGroup.tasks.length - 1]?.sort_order || + -1; + // Prepare socket event payload + const body = { + project_id: projectId, + from_index: sourceGroup.tasks[fromIndex].sort_order, + to_index: toPos, + to_last_index: !toPos, + from_group: sourceGroupId, // Use the direct IDs instead of group objects + to_group: targetGroupId, // Use the direct IDs instead of group objects + group_by: groupBy || 'status', // Use the current groupBy value + task, + team_id: currentSession?.team_id + }; + // Emit socket event + if (socket) { + socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), body); + + // Set up listener for task progress update + socket.once(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), () => { + if (task.is_sub_task) { + socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id); + } else { + socket.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id); + } + }); + } + // Track analytics event + trackMixpanelEvent(evt_project_task_list_drag_and_move); + } + // Handle column reordering + else if (isActiveSection) { + // Don't allow reordering if groupBy is phases + if (groupBy === IGroupBy.PHASE) { + setActiveItem(null); + originalSourceGroupIdRef.current = null; + return; + } + + const sectionId = active.id; + const fromIndex = taskGroups.findIndex(group => group.id === sectionId); + const toIndex = taskGroups.findIndex(group => group.id === over.id); + + if (fromIndex !== -1 && toIndex !== -1) { + // Create a new array with the reordered groups + const reorderedGroups = [...taskGroups]; + const [movedGroup] = reorderedGroups.splice(fromIndex, 1); + reorderedGroups.splice(toIndex, 0, movedGroup); + + // Dispatch action to reorder columns with the new array + dispatch(reorderTaskGroups(reorderedGroups)); + + // Prepare column order for API + const columnOrder = reorderedGroups.map(group => group.id); + + // Call API to update status order + try { + // Use the correct API endpoint based on the Angular code + const requestBody: ITaskStatusCreateRequest = { + status_order: columnOrder + }; + + const response = await statusApiService.updateStatusOrder(requestBody, projectId); + if (!response.done) { + const revertedGroups = [...reorderedGroups]; + const [movedBackGroup] = revertedGroups.splice(toIndex, 1); + revertedGroups.splice(fromIndex, 0, movedBackGroup); + dispatch(reorderTaskGroups(revertedGroups)); + alertService.error('Failed to update column order', 'Please try again'); + } + } catch (error) { + // Revert the change if API call fails + const revertedGroups = [...reorderedGroups]; + const [movedBackGroup] = revertedGroups.splice(toIndex, 1); + revertedGroups.splice(fromIndex, 0, movedBackGroup); + dispatch(reorderTaskGroups(revertedGroups)); + alertService.error('Failed to update column order', 'Please try again'); + } + } + } + + setActiveItem(null); + originalSourceGroupIdRef.current = null; // Reset the ref + }; + + const handleDragCancel = () => { + isDraggingRef.current = false; + if (clonedItems) { + dispatch(reorderTaskGroups(clonedItems)); + } + setActiveItem(null); + setClonedItems(null); + originalSourceGroupIdRef.current = null; + }; + + // Reset the recently moved flag after animation frame + useEffect(() => { + requestAnimationFrame(() => { + recentlyMovedToNewContainer.current = false; + }); + }, [taskGroups]); + + useEffect(() => { + if (socket) { + socket.on(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress); + } + + return () => { + socket?.off(SocketEvents.GET_TASK_PROGRESS.toString(), handleTaskProgress); + }; + }, [socket]); + + // Track analytics event on component mount + useEffect(() => { + trackMixpanelEvent(evt_project_board_visit); + }, []); + + // Cleanup debounced function on unmount + useEffect(() => { + return () => { + debouncedMoveTask.cancel(); + }; + }, [debouncedMoveTask]); + + return ( + + + + + + + {activeItem?.type === 'task' && ( + + )} + + + + + ); +}; + +export default ProjectViewBoard; \ No newline at end of file From fa08463e65efaa2483b71a9844c87953cf6c32d7 Mon Sep 17 00:00:00 2001 From: shancds Date: Thu, 17 Jul 2025 11:10:23 +0530 Subject: [PATCH 06/21] Enhance localization support in Kanban board - Added new localized messages for error handling, task management, and filter loading across multiple languages (Albanian, German, English, Spanish, Portuguese, Chinese). - Updated the EnhancedKanbanBoardNativeDnD component to utilize these localized messages for improved user experience during task operations and error notifications. --- .../public/locales/alb/kanban-board.json | 14 +++++- .../public/locales/de/kanban-board.json | 14 +++++- .../public/locales/en/kanban-board.json | 11 +++- .../public/locales/es/kanban-board.json | 14 +++++- .../public/locales/pt/kanban-board.json | 14 +++++- .../public/locales/zh/kanban-board.json | 21 +++++++- .../EnhancedKanbanBoardNativeDnD.tsx | 50 ++++++++++++------- 7 files changed, 114 insertions(+), 24 deletions(-) diff --git a/worklenz-frontend/public/locales/alb/kanban-board.json b/worklenz-frontend/public/locales/alb/kanban-board.json index 48987f10..50835235 100644 --- a/worklenz-frontend/public/locales/alb/kanban-board.json +++ b/worklenz-frontend/public/locales/alb/kanban-board.json @@ -37,5 +37,17 @@ "noDueDate": "Pa datë përfundimi", "save": "Ruaj", "clear": "Pastro", - "nextWeek": "Javën e ardhshme" + "nextWeek": "Javën e ardhshme", + "noSubtasks": "Pa nëndetyra", + "showSubtasks": "Shfaq nëndetyrat", + "hideSubtasks": "Fshih nëndetyrat", + + "errorLoadingTasks": "Gabim gjatë ngarkimit të detyrave", + "noTasksFound": "Nuk u gjetën detyra", + "loadingFilters": "Duke ngarkuar filtra...", + "failedToUpdateColumnOrder": "Dështoi përditësimi i rendit të kolonave", + "failedToUpdatePhaseOrder": "Dështoi përditësimi i rendit të fazave", + "pleaseTryAgain": "Ju lutemi provoni përsëri", + "taskNotCompleted": "Detyra nuk është përfunduar", + "completeTaskDependencies": "Ju lutemi përfundoni varësitë e detyrës para se të vazhdoni" } diff --git a/worklenz-frontend/public/locales/de/kanban-board.json b/worklenz-frontend/public/locales/de/kanban-board.json index 9d748e30..10b58b95 100644 --- a/worklenz-frontend/public/locales/de/kanban-board.json +++ b/worklenz-frontend/public/locales/de/kanban-board.json @@ -37,5 +37,17 @@ "noDueDate": "Kein Fälligkeitsdatum", "save": "Speichern", "clear": "Löschen", - "nextWeek": "Nächste Woche" + "nextWeek": "Nächste Woche", + "noSubtasks": "Keine Unteraufgaben", + "showSubtasks": "Unteraufgaben anzeigen", + "hideSubtasks": "Unteraufgaben ausblenden", + + "errorLoadingTasks": "Fehler beim Laden der Aufgaben", + "noTasksFound": "Keine Aufgaben gefunden", + "loadingFilters": "Filter werden geladen...", + "failedToUpdateColumnOrder": "Fehler beim Aktualisieren der Spaltenreihenfolge", + "failedToUpdatePhaseOrder": "Fehler beim Aktualisieren der Phasenreihenfolge", + "pleaseTryAgain": "Bitte versuchen Sie es erneut", + "taskNotCompleted": "Aufgabe ist nicht abgeschlossen", + "completeTaskDependencies": "Bitte schließen Sie die Aufgabenabhängigkeiten ab, bevor Sie fortfahren" } diff --git a/worklenz-frontend/public/locales/en/kanban-board.json b/worklenz-frontend/public/locales/en/kanban-board.json index 018d52a9..77659152 100644 --- a/worklenz-frontend/public/locales/en/kanban-board.json +++ b/worklenz-frontend/public/locales/en/kanban-board.json @@ -40,5 +40,14 @@ "nextWeek": "Next week", "noSubtasks": "No subtasks", "showSubtasks": "Show subtasks", - "hideSubtasks": "Hide subtasks" + "hideSubtasks": "Hide subtasks", + + "errorLoadingTasks": "Error loading tasks", + "noTasksFound": "No tasks found", + "loadingFilters": "Loading filters...", + "failedToUpdateColumnOrder": "Failed to update column order", + "failedToUpdatePhaseOrder": "Failed to update phase order", + "pleaseTryAgain": "Please try again", + "taskNotCompleted": "Task is not completed", + "completeTaskDependencies": "Please complete the task dependencies before proceeding" } diff --git a/worklenz-frontend/public/locales/es/kanban-board.json b/worklenz-frontend/public/locales/es/kanban-board.json index c49a02ca..df4f2b1e 100644 --- a/worklenz-frontend/public/locales/es/kanban-board.json +++ b/worklenz-frontend/public/locales/es/kanban-board.json @@ -37,5 +37,17 @@ "noDueDate": "Sin fecha de vencimiento", "save": "Guardar", "clear": "Limpiar", - "nextWeek": "Próxima semana" + "nextWeek": "Próxima semana", + "noSubtasks": "Sin subtareas", + "showSubtasks": "Mostrar subtareas", + "hideSubtasks": "Ocultar subtareas", + + "errorLoadingTasks": "Error al cargar tareas", + "noTasksFound": "No se encontraron tareas", + "loadingFilters": "Cargando filtros...", + "failedToUpdateColumnOrder": "Error al actualizar el orden de las columnas", + "failedToUpdatePhaseOrder": "Error al actualizar el orden de las fases", + "pleaseTryAgain": "Por favor, inténtalo de nuevo", + "taskNotCompleted": "La tarea no está completada", + "completeTaskDependencies": "Por favor, completa las dependencias de la tarea antes de continuar" } diff --git a/worklenz-frontend/public/locales/pt/kanban-board.json b/worklenz-frontend/public/locales/pt/kanban-board.json index f290258e..5bac3adb 100644 --- a/worklenz-frontend/public/locales/pt/kanban-board.json +++ b/worklenz-frontend/public/locales/pt/kanban-board.json @@ -37,5 +37,17 @@ "noDueDate": "Sem data de vencimento", "save": "Salvar", "clear": "Limpar", - "nextWeek": "Próxima semana" + "nextWeek": "Próxima semana", + "noSubtasks": "Sem subtarefas", + "showSubtasks": "Mostrar subtarefas", + "hideSubtasks": "Ocultar subtarefas", + + "errorLoadingTasks": "Erro ao carregar tarefas", + "noTasksFound": "Nenhuma tarefa encontrada", + "loadingFilters": "Carregando filtros...", + "failedToUpdateColumnOrder": "Falha ao atualizar a ordem das colunas", + "failedToUpdatePhaseOrder": "Falha ao atualizar a ordem das fases", + "pleaseTryAgain": "Por favor, tente novamente", + "taskNotCompleted": "Tarefa não está concluída", + "completeTaskDependencies": "Por favor, complete as dependências da tarefa antes de prosseguir" } diff --git a/worklenz-frontend/public/locales/zh/kanban-board.json b/worklenz-frontend/public/locales/zh/kanban-board.json index 4c51dfa8..20c7cb08 100644 --- a/worklenz-frontend/public/locales/zh/kanban-board.json +++ b/worklenz-frontend/public/locales/zh/kanban-board.json @@ -23,5 +23,24 @@ "deleteStatusTitle": "删除状态", "deleteStatusContent": "您确定要删除此状态吗?此操作无法撤销。", "deletePhaseTitle": "删除阶段", - "deletePhaseContent": "您确定要删除此阶段吗?此操作无法撤销。" + "deletePhaseContent": "您确定要删除此阶段吗?此操作无法撤销。", + "untitledSection": "未命名部分", + "unmapped": "未映射", + "clickToChangeDate": "点击更改日期", + "noDueDate": "无截止日期", + "save": "保存", + "clear": "清除", + "nextWeek": "下周", + "noSubtasks": "无子任务", + "showSubtasks": "显示子任务", + "hideSubtasks": "隐藏子任务", + + "errorLoadingTasks": "加载任务时出错", + "noTasksFound": "未找到任务", + "loadingFilters": "正在加载过滤器...", + "failedToUpdateColumnOrder": "更新列顺序失败", + "failedToUpdatePhaseOrder": "更新阶段顺序失败", + "pleaseTryAgain": "请重试", + "taskNotCompleted": "任务未完成", + "completeTaskDependencies": "请先完成任务依赖项,然后再继续" } \ No newline at end of file diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx index 48219464..2b542eeb 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx @@ -24,8 +24,10 @@ import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service'; import { ITaskListGroup } from '@/types/tasks/taskList.types'; import { fetchPhasesByProjectId, updatePhaseListOrder } from '@/features/projects/singleProject/phase/phases.slice'; +import { useTranslation } from 'react-i18next'; const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ projectId }) => { + const { t } = useTranslation('kanban-board'); const dispatch = useDispatch(); const authService = useAuthService(); const { socket } = useSocket(); @@ -103,14 +105,14 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project const columnOrder = reorderedGroups.map(group => group.id); const requestBody = { status_order: columnOrder }; const response = await statusApiService.updateStatusOrder(requestBody, projectId); - if (!response.done) { - // Revert the change if API call fails - const revertedGroups = [...reorderedGroups]; - const [movedBackGroup] = revertedGroups.splice(toIdx, 1); - revertedGroups.splice(fromIdx, 0, movedBackGroup); - dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups })); - alertService.error('Failed to update column order', 'Please try again'); - } + if (!response.done) { + // Revert the change if API call fails + const revertedGroups = [...reorderedGroups]; + const [movedBackGroup] = revertedGroups.splice(toIdx, 1); + revertedGroups.splice(fromIdx, 0, movedBackGroup); + dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups })); + alertService.error(t('failedToUpdateColumnOrder'), t('pleaseTryAgain')); + } } else if (groupBy === 'phase') { const newPhaseList = [...phaseList]; const [movedItem] = newPhaseList.splice(fromIdx, 1); @@ -124,7 +126,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project }; const response = await phasesApiService.updatePhaseOrder(projectId, requestBody); if (!response.done) { - alertService.error('Failed to update phase order', 'Please try again'); + alertService.error(t('failedToUpdatePhaseOrder'), t('pleaseTryAgain')); } } } catch (error) { @@ -133,7 +135,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project const [movedBackGroup] = revertedGroups.splice(toIdx, 1); revertedGroups.splice(fromIdx, 0, movedBackGroup); dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups })); - alertService.error('Failed to update column order', 'Please try again'); + alertService.error(t('failedToUpdateColumnOrder'), t('pleaseTryAgain')); logger.error('Failed to update column order', error); } @@ -143,11 +145,23 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project // Utility to recalculate all task orders for all groups function getAllTaskUpdates(allGroups: ITaskListGroup[], groupBy: string) { - const taskUpdates = []; + const taskUpdates: Array<{ + task_id: string | undefined; + sort_order: number; + status_id?: string; + priority_id?: string; + phase_id?: string; + }> = []; let currentSortOrder = 0; for (const group of allGroups) { for (const task of group.tasks) { - const update = { + const update: { + task_id: string | undefined; + sort_order: number; + status_id?: string; + priority_id?: string; + phase_id?: string; + } = { task_id: task.id, sort_order: currentSortOrder, }; @@ -200,8 +214,8 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project const canContinue = await checkTaskDependencyStatus(movedTask.id, targetGroupId); if (!canContinue) { alertService.error( - 'Task is not completed', - 'Please complete the task dependencies before proceeding' + t('taskNotCompleted'), + t('completeTaskDependencies') ); return; } @@ -295,7 +309,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project team_id: teamId, from_index: taskIdx, to_index: insertIdx, - to_last_index: insertIdx === (targetGroup.id === sourceGroup.id ? newTaskGroups.find(g => g.id === targetGroup.id)?.tasks.length - 1 : targetGroup.tasks.length), + to_last_index: insertIdx === (targetGroup.id === sourceGroup.id ? (newTaskGroups.find(g => g.id === targetGroup.id)?.tasks.length || 0) - 1 : targetGroup.tasks.length), task: { id: movedTask.id, project_id: movedTask.project_id || projectId, @@ -336,7 +350,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project if (error) { return ( - + ); } @@ -344,7 +358,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project return ( <>
- Loading filters...
}> + {t('loadingFilters')}
}>
@@ -358,7 +372,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
) : taskGroups.length === 0 ? ( - + ) : (
From 22d2023e2a3ee3f3bfa51fb798839422d0b617b2 Mon Sep 17 00:00:00 2001 From: shancds Date: Thu, 17 Jul 2025 11:54:33 +0530 Subject: [PATCH 07/21] Update phase handling in EnhancedKanbanBoardNativeDnD component - Modified phase update logic to prevent setting phase_id for 'Unmapped' phases, ensuring only valid phases are processed. - Cleaned up unnecessary whitespace in the task reordering section for improved code clarity. --- .../EnhancedKanbanBoardNativeDnD.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx index 2b542eeb..52703297 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx @@ -167,7 +167,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project }; if (groupBy === 'status') update.status_id = group.id; else if (groupBy === 'priority') update.priority_id = group.id; - else if (groupBy === 'phase') update.phase_id = group.id; + else if (groupBy === 'phase' && group.name !== 'Unmapped') update.phase_id = group.id; taskUpdates.push(update); currentSortOrder++; } @@ -240,7 +240,6 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project if (insertIdx > updatedTasks.length) insertIdx = updatedTasks.length; updatedTasks.splice(insertIdx, 0, movedTask); // Insert at new position - dispatch(reorderTasks({ activeGroupId: sourceGroup.id, overGroupId: targetGroup.id, From 7f71e8952b6aed40d0b78e2e000b466e73c9e499 Mon Sep 17 00:00:00 2001 From: shancds Date: Thu, 17 Jul 2025 12:52:40 +0530 Subject: [PATCH 08/21] Enhance EnhancedKanbanBoardNativeDnD with task priority updates and socket integration - Added functionality to handle task priority changes, emitting updates via socket for real-time synchronization. - Updated the EnhancedKanbanBoardNativeDnD component to include new logic for managing task priorities within the drag-and-drop interface. - Cleaned up console log statements in the useTaskSocketHandlers hook for improved performance monitoring. --- .../EnhancedKanbanBoardNativeDnD.tsx | 41 +++++++++++++------ .../enhanced-kanban/enhanced-kanban.slice.ts | 2 - .../src/hooks/useTaskSocketHandlers.ts | 35 ---------------- .../utils/enhanced-performance-monitoring.ts | 6 +-- 4 files changed, 32 insertions(+), 52 deletions(-) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx index 52703297..6f8bfe4c 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx @@ -8,7 +8,7 @@ import ImprovedTaskFilters from '../../task-management/improved-task-filters'; import Card from 'antd/es/card'; import Spin from 'antd/es/spin'; import Empty from 'antd/es/empty'; -import { reorderGroups, reorderEnhancedKanbanGroups, reorderTasks, reorderEnhancedKanbanTasks, fetchEnhancedKanbanLabels, fetchEnhancedKanbanGroups, fetchEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { reorderGroups, reorderEnhancedKanbanGroups, reorderTasks, reorderEnhancedKanbanTasks, fetchEnhancedKanbanLabels, fetchEnhancedKanbanGroups, fetchEnhancedKanbanTaskAssignees, updateEnhancedKanbanTaskPriority } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; import { useAppSelector } from '@/hooks/useAppSelector'; import KanbanGroup from './KanbanGroup'; @@ -25,6 +25,7 @@ import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service import { ITaskListGroup } from '@/types/tasks/taskList.types'; import { fetchPhasesByProjectId, updatePhaseListOrder } from '@/features/projects/singleProject/phase/phases.slice'; import { useTranslation } from 'react-i18next'; +import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types'; const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ projectId }) => { const { t } = useTranslation('kanban-board'); @@ -62,7 +63,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project if (!statusCategories.length) { dispatch(fetchStatusesCategories() as any); } - if ( groupBy === 'phase' && !phaseList.length) { + if (groupBy === 'phase' && !phaseList.length) { dispatch(fetchPhasesByProjectId(projectId) as any); } }, [dispatch, projectId]); @@ -102,10 +103,10 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project // API call for group order try { if (groupBy === 'status') { - const columnOrder = reorderedGroups.map(group => group.id); - const requestBody = { status_order: columnOrder }; - const response = await statusApiService.updateStatusOrder(requestBody, projectId); - if (!response.done) { + const columnOrder = reorderedGroups.map(group => group.id); + const requestBody = { status_order: columnOrder }; + const response = await statusApiService.updateStatusOrder(requestBody, projectId); + if (!response.done) { // Revert the change if API call fails const revertedGroups = [...reorderedGroups]; const [movedBackGroup] = revertedGroups.splice(toIdx, 1); @@ -329,6 +330,22 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project }) ); } + if (groupBy === 'priority' && movedTask.id) { + socket?.emit( + SocketEvents.TASK_PRIORITY_CHANGE.toString(), + JSON.stringify({ + task_id: movedTask.id, + priority_id: targetGroupId, + team_id: teamId, + }) + ); + socket?.once( + SocketEvents.TASK_PRIORITY_CHANGE.toString(), + (data: ITaskListPriorityChangeResponse) => { + dispatch(updateEnhancedKanbanTaskPriority(data)); + } + ); + } } setDraggedTaskId(null); @@ -363,12 +380,12 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project
{loadingGroups ? ( -
-
-
-
-
-
+
+
+
+
+
+
) : taskGroups.length === 0 ? ( diff --git a/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts b/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts index 3ccac5d2..9bbf5694 100644 --- a/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts +++ b/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts @@ -575,7 +575,6 @@ const enhancedKanbanSlice = createSlice({ action: PayloadAction ) => { const { id, priority_id, color_code, color_code_dark } = action.payload; - // Find the task in any group const taskInfo = findTaskInAllGroups(state.taskGroups, id); if (!taskInfo || !priority_id) return; @@ -603,7 +602,6 @@ const enhancedKanbanSlice = createSlice({ // Update cache state.taskCache[id] = task; }, - // Enhanced Kanban assignee update (for use in task drawer dropdown) updateEnhancedKanbanTaskAssignees: ( state, diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index 2cb419a9..598b7040 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -285,23 +285,7 @@ export const useTaskSocketHandlers = () => { ); } - console.log('🔄 Status change group movement debug:', { - taskId: response.id, - newStatusValue, - currentGroupId: currentGroup?.id, - currentGroupValue: currentGroup?.groupValue, - currentGroupTitle: currentGroup?.title, - targetGroupId: targetGroup?.id, - targetGroupValue: targetGroup?.groupValue, - targetGroupTitle: targetGroup?.title, - allGroups: groups.map(g => ({ id: g.id, title: g.title, groupValue: g.groupValue })) - }); - if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) { - console.log('✅ Moving task between groups:', { - from: currentGroup.title, - to: targetGroup.title - }); // Use the action to move task between groups dispatch( moveTaskBetweenGroups({ @@ -448,12 +432,6 @@ export const useTaskSocketHandlers = () => { } if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) { - console.log('🔄 Moving task between priority groups:', { - taskId: response.id, - from: currentGroup.title, - to: targetGroup.title, - newPriorityValue - }); dispatch( moveTaskBetweenGroups({ taskId: response.id, @@ -603,12 +581,6 @@ export const useTaskSocketHandlers = () => { } if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) { - console.log('🔄 Moving task between phase groups:', { - taskId, - from: currentGroup.title, - to: targetGroup.title, - newPhaseValue - }); dispatch( moveTaskBetweenGroups({ taskId: taskId, @@ -925,10 +897,6 @@ export const useTaskSocketHandlers = () => { // Handler for TASK_ASSIGNEES_CHANGE (fallback event with limited data) const handleTaskAssigneesChange = useCallback((data: { assigneeIds: string[] }) => { if (!data || !data.assigneeIds) return; - - // This event only provides assignee IDs, so we update what we can - // The full assignee data will come from QUICK_ASSIGNEES_UPDATE - // console.log('🔄 Task assignees change (limited data):', data); }, []); // Handler for timer start events @@ -994,9 +962,6 @@ export const useTaskSocketHandlers = () => { try { if (!Array.isArray(data) || data.length === 0) return; - // DEBUG: Log the data received from the backend - console.log('[TASK_SORT_ORDER_CHANGE] Received data:', data); - // Get canonical lists from Redux const state = store.getState(); const priorityList = state.priorityReducer?.priorities || []; diff --git a/worklenz-frontend/src/utils/enhanced-performance-monitoring.ts b/worklenz-frontend/src/utils/enhanced-performance-monitoring.ts index 9e50424e..c30d189f 100644 --- a/worklenz-frontend/src/utils/enhanced-performance-monitoring.ts +++ b/worklenz-frontend/src/utils/enhanced-performance-monitoring.ts @@ -86,7 +86,7 @@ export class EnhancedPerformanceMonitor { this.collectInitialMetrics(); this.startPeriodicCollection(); - console.log('🚀 Enhanced performance monitoring started'); + // console.log('🚀 Enhanced performance monitoring started'); } // Stop monitoring and cleanup @@ -97,7 +97,7 @@ export class EnhancedPerformanceMonitor { this.cleanupObservers(); this.clearIntervals(); - console.log('🛑 Enhanced performance monitoring stopped'); + // console.log('🛑 Enhanced performance monitoring stopped'); } // Setup performance observers @@ -357,7 +357,7 @@ export class EnhancedPerformanceMonitor { const recent = this.metrics.slice(-10); // Last 10 metrics const report = this.analyzeMetrics(recent); - console.log('📊 Performance Report:', report); + // console.log('📊 Performance Report:', report); // Check for performance issues this.checkPerformanceIssues(report); From 1709fad733b14a707f4057e9f93079073ffa4cf2 Mon Sep 17 00:00:00 2001 From: shancds Date: Thu, 17 Jul 2025 15:51:23 +0530 Subject: [PATCH 09/21] Add drag-and-drop data transfer support in EnhancedKanbanBoardNativeDnD --- .../EnhancedKanbanBoardNativeDnD.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx index 6f8bfe4c..ef587923 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx @@ -82,6 +82,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project setDraggedGroupId(groupId); setDragType('group'); e.dataTransfer.effectAllowed = 'move'; + try { + e.dataTransfer.setData('text/plain', groupId); + } catch {} }; const handleGroupDragOver = (e: React.DragEvent) => { if (dragType !== 'group') return; @@ -182,6 +185,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project setDraggedTaskGroupId(groupId); setDragType('task'); e.dataTransfer.effectAllowed = 'move'; + try { + e.dataTransfer.setData('text/plain', taskId); + } catch {} }; const handleTaskDragOver = (e: React.DragEvent, groupId: string, taskIdx: number | null) => { if (dragType !== 'task') return; From 78d960bf01596dc0d26b879e42e8a47da10ea560 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Sun, 20 Jul 2025 22:05:42 +0530 Subject: [PATCH 10/21] feat(gantt): introduce advanced Gantt chart components and demo page - Added new components for an advanced Gantt chart, including AdvancedGanttChart, GanttGrid, DraggableTaskBar, and TimelineMarkers. - Implemented a demo page (GanttDemoPage) to showcase the functionality of the new Gantt chart components. - Enhanced project roadmap features with ProjectRoadmapGantt and related components for better project management visualization. - Introduced sample data for testing and demonstration purposes, improving the user experience in the Gantt chart interface. - Updated main routes to include the new Gantt demo page for easy access. --- .../src/app/routes/main-routes.tsx | 9 + .../advanced-gantt/AdvancedGanttChart.tsx | 612 ++++++++++++++++ .../advanced-gantt/AdvancedGanttDemo.tsx | 668 ++++++++++++++++++ .../advanced-gantt/DraggableTaskBar.tsx | 304 ++++++++ .../components/advanced-gantt/GanttGrid.tsx | 492 +++++++++++++ .../advanced-gantt/TimelineMarkers.tsx | 295 ++++++++ .../advanced-gantt/VirtualScrollContainer.tsx | 372 ++++++++++ .../src/components/advanced-gantt/index.ts | 17 + .../project-roadmap-gantt/PhaseModal.tsx | 406 +++++++++++ .../ProjectRoadmapGantt.tsx | 333 +++++++++ .../project-roadmap-gantt/RoadmapDemo.tsx | 112 +++ .../project-roadmap-gantt/gantt-theme.css | 221 ++++++ .../components/project-roadmap-gantt/index.ts | 5 + .../project-roadmap-gantt/sample-data.ts | 317 +++++++++ worklenz-frontend/src/pages/GanttDemoPage.tsx | 8 + .../src/pages/projects/ProjectGanttView.tsx | 63 ++ .../src/types/advanced-gantt.types.ts | 307 ++++++++ .../src/types/project-roadmap.types.ts | 62 ++ .../src/utils/gantt-performance.ts | 408 +++++++++++ 19 files changed, 5011 insertions(+) create mode 100644 worklenz-frontend/src/components/advanced-gantt/AdvancedGanttChart.tsx create mode 100644 worklenz-frontend/src/components/advanced-gantt/AdvancedGanttDemo.tsx create mode 100644 worklenz-frontend/src/components/advanced-gantt/DraggableTaskBar.tsx create mode 100644 worklenz-frontend/src/components/advanced-gantt/GanttGrid.tsx create mode 100644 worklenz-frontend/src/components/advanced-gantt/TimelineMarkers.tsx create mode 100644 worklenz-frontend/src/components/advanced-gantt/VirtualScrollContainer.tsx create mode 100644 worklenz-frontend/src/components/advanced-gantt/index.ts create mode 100644 worklenz-frontend/src/components/project-roadmap-gantt/PhaseModal.tsx create mode 100644 worklenz-frontend/src/components/project-roadmap-gantt/ProjectRoadmapGantt.tsx create mode 100644 worklenz-frontend/src/components/project-roadmap-gantt/RoadmapDemo.tsx create mode 100644 worklenz-frontend/src/components/project-roadmap-gantt/gantt-theme.css create mode 100644 worklenz-frontend/src/components/project-roadmap-gantt/index.ts create mode 100644 worklenz-frontend/src/components/project-roadmap-gantt/sample-data.ts create mode 100644 worklenz-frontend/src/pages/GanttDemoPage.tsx create mode 100644 worklenz-frontend/src/pages/projects/ProjectGanttView.tsx create mode 100644 worklenz-frontend/src/types/advanced-gantt.types.ts create mode 100644 worklenz-frontend/src/types/project-roadmap.types.ts create mode 100644 worklenz-frontend/src/utils/gantt-performance.ts diff --git a/worklenz-frontend/src/app/routes/main-routes.tsx b/worklenz-frontend/src/app/routes/main-routes.tsx index 8ec8cb9a..4c96c8f9 100644 --- a/worklenz-frontend/src/app/routes/main-routes.tsx +++ b/worklenz-frontend/src/app/routes/main-routes.tsx @@ -17,6 +17,7 @@ const ProjectTemplateEditView = lazy( const LicenseExpired = lazy(() => import('@/pages/license-expired/license-expired')); const ProjectView = lazy(() => import('@/pages/projects/projectView/project-view')); const Unauthorized = lazy(() => import('@/pages/unauthorized/unauthorized')); +const GanttDemoPage = lazy(() => import('@/pages/GanttDemoPage')); // Define AdminGuard component with defensive programming const AdminGuard = ({ children }: { children: React.ReactNode }) => { @@ -106,6 +107,14 @@ const mainRoutes: RouteObject[] = [ ), }, + { + path: 'gantt-demo', + element: ( + }> + + + ), + }, ...settingsRoutes, ...adminCenterRoutes, ], diff --git a/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttChart.tsx b/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttChart.tsx new file mode 100644 index 00000000..b1858c92 --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttChart.tsx @@ -0,0 +1,612 @@ +import React, { useReducer, useMemo, useCallback, useRef, useEffect, useState } from 'react'; +import { + GanttTask, + ColumnConfig, + TimelineConfig, + VirtualScrollConfig, + ZoomLevel, + GanttState, + GanttAction, + AdvancedGanttProps, + SelectionState, + GanttViewState, + DragState +} from '../../types/advanced-gantt.types'; +import GanttGrid from './GanttGrid'; +import DraggableTaskBar from './DraggableTaskBar'; +import TimelineMarkers, { holidayPresets, workingDayPresets } from './TimelineMarkers'; +import VirtualScrollContainer, { VirtualTimeline } from './VirtualScrollContainer'; +import { + usePerformanceMonitoring, + useTaskCalculations, + useDateCalculations, + useDebounce, + useThrottle +} from '../../utils/gantt-performance'; +import { useAppSelector } from '../../hooks/useAppSelector'; +import { themeWiseColor } from '../../utils/themeWiseColor'; + +// Default configurations +const defaultColumns: ColumnConfig[] = [ + { + field: 'name', + title: 'Task Name', + width: 250, + minWidth: 150, + resizable: true, + sortable: true, + fixed: true, + editor: 'text' + }, + { + field: 'startDate', + title: 'Start Date', + width: 120, + minWidth: 100, + resizable: true, + sortable: true, + fixed: true, + editor: 'date' + }, + { + field: 'endDate', + title: 'End Date', + width: 120, + minWidth: 100, + resizable: true, + sortable: true, + fixed: true, + editor: 'date' + }, + { + field: 'duration', + title: 'Duration', + width: 80, + minWidth: 60, + resizable: true, + sortable: false, + fixed: true + }, + { + field: 'progress', + title: 'Progress', + width: 100, + minWidth: 80, + resizable: true, + sortable: true, + fixed: true, + editor: 'number' + }, +]; + +const defaultTimelineConfig: TimelineConfig = { + topTier: { unit: 'month', format: 'MMM yyyy', height: 30 }, + bottomTier: { unit: 'day', format: 'dd', height: 25 }, + showWeekends: true, + showNonWorkingDays: true, + holidays: holidayPresets.US, + workingDays: workingDayPresets.standard, + workingHours: { start: 9, end: 17 }, + dayWidth: 30, +}; + +const defaultVirtualScrollConfig: VirtualScrollConfig = { + enableRowVirtualization: true, + enableTimelineVirtualization: true, + bufferSize: 10, + itemHeight: 40, + overscan: 5, +}; + +const defaultZoomLevels: ZoomLevel[] = [ + { + name: 'Year', + dayWidth: 2, + scale: 0.1, + topTier: { unit: 'year', format: 'yyyy' }, + bottomTier: { unit: 'month', format: 'MMM' } + }, + { + name: 'Month', + dayWidth: 8, + scale: 0.5, + topTier: { unit: 'month', format: 'MMM yyyy' }, + bottomTier: { unit: 'week', format: 'w' } + }, + { + name: 'Week', + dayWidth: 25, + scale: 1, + topTier: { unit: 'week', format: 'MMM dd' }, + bottomTier: { unit: 'day', format: 'dd' } + }, + { + name: 'Day', + dayWidth: 50, + scale: 2, + topTier: { unit: 'day', format: 'MMM dd' }, + bottomTier: { unit: 'hour', format: 'HH' } + }, +]; + +// Gantt state reducer +function ganttReducer(state: GanttState, action: GanttAction): GanttState { + switch (action.type) { + case 'SET_TASKS': + return { ...state, tasks: action.payload }; + + case 'UPDATE_TASK': + return { + ...state, + tasks: state.tasks.map(task => + task.id === action.payload.id + ? { ...task, ...action.payload.updates } + : task + ), + }; + + case 'ADD_TASK': + return { ...state, tasks: [...state.tasks, action.payload] }; + + case 'DELETE_TASK': + return { + ...state, + tasks: state.tasks.filter(task => task.id !== action.payload), + }; + + case 'SET_SELECTION': + return { + ...state, + selectionState: { ...state.selectionState, selectedTasks: action.payload }, + }; + + case 'SET_DRAG_STATE': + return { ...state, dragState: action.payload }; + + case 'SET_ZOOM_LEVEL': + const newZoomLevel = Math.max(0, Math.min(state.zoomLevels.length - 1, action.payload)); + return { + ...state, + viewState: { ...state.viewState, zoomLevel: newZoomLevel }, + timelineConfig: { + ...state.timelineConfig, + dayWidth: state.zoomLevels[newZoomLevel].dayWidth, + topTier: state.zoomLevels[newZoomLevel].topTier, + bottomTier: state.zoomLevels[newZoomLevel].bottomTier, + }, + }; + + case 'SET_SCROLL_POSITION': + return { + ...state, + viewState: { ...state.viewState, scrollPosition: action.payload }, + }; + + case 'SET_SPLITTER_POSITION': + return { + ...state, + viewState: { ...state.viewState, splitterPosition: action.payload }, + }; + + case 'TOGGLE_TASK_EXPANSION': + return { + ...state, + tasks: state.tasks.map(task => + task.id === action.payload + ? { ...task, isExpanded: !task.isExpanded } + : task + ), + }; + + case 'SET_VIEW_STATE': + return { + ...state, + viewState: { ...state.viewState, ...action.payload }, + }; + + case 'UPDATE_COLUMN_WIDTH': + return { + ...state, + columns: state.columns.map(col => + col.field === action.payload.field + ? { ...col, width: action.payload.width } + : col + ), + }; + + default: + return state; + } +} + +const AdvancedGanttChart: React.FC = ({ + tasks: initialTasks, + columns = defaultColumns, + timelineConfig = {}, + virtualScrollConfig = {}, + zoomLevels = defaultZoomLevels, + initialViewState = {}, + initialSelection = [], + onTaskUpdate, + onTaskCreate, + onTaskDelete, + onTaskMove, + onTaskResize, + onProgressChange, + onSelectionChange, + onColumnResize, + onDependencyCreate, + onDependencyDelete, + className = '', + style = {}, + theme = 'auto', + enableDragDrop = true, + enableResize = true, + enableProgressEdit = true, + enableInlineEdit = true, + enableVirtualScrolling = true, + enableDebouncing = true, + debounceDelay = 300, + maxVisibleTasks = 1000, +}) => { + const containerRef = useRef(null); + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + const themeMode = useAppSelector(state => state.themeReducer.mode); + const { startMeasure, endMeasure, metrics } = usePerformanceMonitoring(); + const { getDaysBetween } = useDateCalculations(); + + // Initialize state + const initialState: GanttState = { + tasks: initialTasks, + columns, + timelineConfig: { ...defaultTimelineConfig, ...timelineConfig }, + virtualScrollConfig: { ...defaultVirtualScrollConfig, ...virtualScrollConfig }, + dragState: null, + selectionState: { + selectedTasks: initialSelection, + selectedRows: [], + focusedTask: undefined, + }, + viewState: { + zoomLevel: 2, // Week view by default + scrollPosition: { x: 0, y: 0 }, + viewportSize: { width: 0, height: 0 }, + splitterPosition: 40, // 40% for grid, 60% for timeline + showCriticalPath: false, + showBaseline: false, + showProgress: true, + showDependencies: true, + autoSchedule: false, + readOnly: false, + ...initialViewState, + }, + zoomLevels, + performanceMetrics: { + renderTime: 0, + taskCount: initialTasks.length, + visibleTaskCount: 0, + }, + }; + + const [state, dispatch] = useReducer(ganttReducer, initialState); + const { taskMap, parentChildMap, totalTasks } = useTaskCalculations(state.tasks); + + // Calculate project timeline bounds + const projectBounds = useMemo(() => { + if (state.tasks.length === 0) { + const today = new Date(); + return { + start: new Date(today.getFullYear(), today.getMonth(), 1), + end: new Date(today.getFullYear(), today.getMonth() + 3, 0), + }; + } + + const startDates = state.tasks.map(task => task.startDate); + const endDates = state.tasks.map(task => task.endDate); + const minStart = new Date(Math.min(...startDates.map(d => d.getTime()))); + const maxEnd = new Date(Math.max(...endDates.map(d => d.getTime()))); + + // Add some padding + minStart.setDate(minStart.getDate() - 7); + maxEnd.setDate(maxEnd.getDate() + 7); + + return { start: minStart, end: maxEnd }; + }, [state.tasks]); + + // Debounced event handlers + const debouncedTaskUpdate = useDebounce( + useCallback((taskId: string, updates: Partial) => { + dispatch({ type: 'UPDATE_TASK', payload: { id: taskId, updates } }); + onTaskUpdate?.(taskId, updates); + }, [onTaskUpdate]), + enableDebouncing ? debounceDelay : 0 + ); + + const debouncedTaskMove = useDebounce( + useCallback((taskId: string, newDates: { start: Date; end: Date }) => { + dispatch({ type: 'UPDATE_TASK', payload: { + id: taskId, + updates: { startDate: newDates.start, endDate: newDates.end } + }}); + onTaskMove?.(taskId, newDates); + }, [onTaskMove]), + enableDebouncing ? debounceDelay : 0 + ); + + const debouncedProgressChange = useDebounce( + useCallback((taskId: string, progress: number) => { + dispatch({ type: 'UPDATE_TASK', payload: { id: taskId, updates: { progress } }}); + onProgressChange?.(taskId, progress); + }, [onProgressChange]), + enableDebouncing ? debounceDelay : 0 + ); + + // Throttled scroll handler + const throttledScrollHandler = useThrottle( + useCallback((scrollLeft: number, scrollTop: number) => { + dispatch({ type: 'SET_SCROLL_POSITION', payload: { x: scrollLeft, y: scrollTop } }); + }, []), + 16 // 60fps + ); + + // Container size observer + useEffect(() => { + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + setContainerSize({ width, height }); + dispatch({ + type: 'SET_VIEW_STATE', + payload: { viewportSize: { width, height } } + }); + } + }); + + if (containerRef.current) { + observer.observe(containerRef.current); + } + + return () => observer.disconnect(); + }, []); + + // Calculate grid and timeline dimensions + const gridWidth = useMemo(() => { + return Math.floor(containerSize.width * (state.viewState.splitterPosition / 100)); + }, [containerSize.width, state.viewState.splitterPosition]); + + const timelineWidth = useMemo(() => { + return containerSize.width - gridWidth; + }, [containerSize.width, gridWidth]); + + // Handle zoom changes + const handleZoomChange = useCallback((direction: 'in' | 'out') => { + const currentZoom = state.viewState.zoomLevel; + const newZoom = direction === 'in' + ? Math.min(state.zoomLevels.length - 1, currentZoom + 1) + : Math.max(0, currentZoom - 1); + + dispatch({ type: 'SET_ZOOM_LEVEL', payload: newZoom }); + }, [state.viewState.zoomLevel, state.zoomLevels.length]); + + // Theme-aware colors + const colors = useMemo(() => ({ + background: themeWiseColor('#ffffff', '#1f2937', themeMode), + border: themeWiseColor('#e5e7eb', '#4b5563', themeMode), + timelineBackground: themeWiseColor('#f8f9fa', '#374151', themeMode), + }), [themeMode]); + + // Render timeline header + const renderTimelineHeader = () => { + const currentZoom = state.zoomLevels[state.viewState.zoomLevel]; + const totalDays = getDaysBetween(projectBounds.start, projectBounds.end); + const totalWidth = totalDays * state.timelineConfig.dayWidth; + + return ( +
+ + {(date, index, style) => ( +
+
+ {formatDateForUnit(date, currentZoom.topTier.unit)} +
+
+ {formatDateForUnit(date, currentZoom.bottomTier.unit)} +
+
+ )} +
+
+ ); + }; + + // Render timeline content + const renderTimelineContent = () => { + const headerHeight = (state.zoomLevels[state.viewState.zoomLevel].topTier.height || 30) + + (state.zoomLevels[state.viewState.zoomLevel].bottomTier.height || 25); + const contentHeight = containerSize.height - headerHeight; + + return ( +
+ {/* Timeline markers (weekends, holidays, etc.) */} + + + {/* Task bars */} + + {(task, index, style) => ( + + )} + +
+ ); + }; + + // Render toolbar + const renderToolbar = () => ( +
+
+ + + {state.zoomLevels[state.viewState.zoomLevel].name} + + +
+ +
+ Tasks: {state.tasks.length} + + Render: {Math.round(metrics.renderTime)}ms +
+
+ ); + + // Performance monitoring + useEffect(() => { + startMeasure('render'); + return () => endMeasure('render'); + }); + + return ( +
+ {/* Toolbar */} + {renderToolbar()} + + {/* Main content */} +
+ {/* Grid */} +
+ { + // Handle task selection + const newSelection = { ...state.selectionState, selectedTasks: [task.id] }; + dispatch({ type: 'SET_SELECTION', payload: [task.id] }); + onSelectionChange?.(newSelection); + }} + onTaskExpand={(taskId) => { + dispatch({ type: 'TOGGLE_TASK_EXPANSION', payload: taskId }); + }} + onColumnResize={(field, width) => { + dispatch({ type: 'UPDATE_COLUMN_WIDTH', payload: { field, width } }); + onColumnResize?.(field, width); + }} + onTaskUpdate={debouncedTaskUpdate} + /> +
+ + {/* Timeline */} +
+ {renderTimelineHeader()} + {renderTimelineContent()} +
+
+
+ ); +}; + +// Helper function to format dates based on unit +function formatDateForUnit(date: Date, unit: string): string { + switch (unit) { + case 'year': + return date.getFullYear().toString(); + case 'month': + return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); + case 'week': + return `W${getWeekNumber(date)}`; + case 'day': + return date.getDate().toString(); + case 'hour': + return date.getHours().toString().padStart(2, '0'); + default: + return ''; + } +} + +function getWeekNumber(date: Date): number { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); +} + +export default AdvancedGanttChart; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttDemo.tsx b/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttDemo.tsx new file mode 100644 index 00000000..64b10de8 --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttDemo.tsx @@ -0,0 +1,668 @@ +import React, { useState, useMemo } from 'react'; +import { Button, Space, message, Card } from 'antd'; +import AdvancedGanttChart from './AdvancedGanttChart'; +import { GanttTask, ColumnConfig } from '../../types/advanced-gantt.types'; +import { useAppSelector } from '../../hooks/useAppSelector'; +import { holidayPresets, workingDayPresets } from './TimelineMarkers'; + +// Enhanced sample data with more realistic project structure +const generateSampleTasks = (): GanttTask[] => { + const baseDate = new Date(2024, 11, 1); // December 1, 2024 + + return [ + // Project Phase 1: Planning & Design + { + id: 'project-1', + name: '🚀 Web Platform Development', + startDate: new Date(2024, 11, 1), + endDate: new Date(2025, 2, 31), + progress: 45, + type: 'project', + status: 'in-progress', + priority: 'high', + color: '#1890ff', + hasChildren: true, + isExpanded: true, + level: 0, + }, + { + id: 'planning-phase', + name: '📋 Planning & Analysis Phase', + startDate: new Date(2024, 11, 1), + endDate: new Date(2024, 11, 20), + progress: 85, + type: 'project', + status: 'in-progress', + priority: 'high', + parent: 'project-1', + color: '#52c41a', + hasChildren: true, + isExpanded: true, + level: 1, + }, + { + id: 'requirements-analysis', + name: 'Requirements Gathering & Analysis', + startDate: new Date(2024, 11, 1), + endDate: new Date(2024, 11, 8), + progress: 100, + type: 'task', + status: 'completed', + priority: 'high', + parent: 'planning-phase', + assignee: { + id: 'user-1', + name: 'Alice Johnson', + avatar: 'https://ui-avatars.com/api/?name=Alice+Johnson&background=1890ff&color=fff', + }, + tags: ['research', 'documentation'], + level: 2, + }, + { + id: 'technical-architecture', + name: 'Technical Architecture Design', + startDate: new Date(2024, 11, 8), + endDate: new Date(2024, 11, 18), + progress: 75, + type: 'task', + status: 'in-progress', + priority: 'high', + parent: 'planning-phase', + assignee: { + id: 'user-2', + name: 'Bob Smith', + avatar: 'https://ui-avatars.com/api/?name=Bob+Smith&background=52c41a&color=fff', + }, + dependencies: ['requirements-analysis'], + tags: ['architecture', 'design'], + level: 2, + }, + { + id: 'ui-ux-design', + name: 'UI/UX Design & Prototyping', + startDate: new Date(2024, 11, 10), + endDate: new Date(2024, 11, 20), + progress: 60, + type: 'task', + status: 'in-progress', + priority: 'medium', + parent: 'planning-phase', + assignee: { + id: 'user-3', + name: 'Carol Davis', + avatar: 'https://ui-avatars.com/api/?name=Carol+Davis&background=faad14&color=fff', + }, + dependencies: ['requirements-analysis'], + tags: ['design', 'prototype'], + level: 2, + }, + { + id: 'milestone-planning-complete', + name: '🎯 Planning Phase Complete', + startDate: new Date(2024, 11, 20), + endDate: new Date(2024, 11, 20), + progress: 0, + type: 'milestone', + status: 'not-started', + priority: 'critical', + parent: 'planning-phase', + dependencies: ['technical-architecture', 'ui-ux-design'], + level: 2, + }, + + // Development Phase + { + id: 'development-phase', + name: '⚡ Development Phase', + startDate: new Date(2024, 11, 21), + endDate: new Date(2025, 1, 28), + progress: 35, + type: 'project', + status: 'in-progress', + priority: 'high', + parent: 'project-1', + color: '#722ed1', + hasChildren: true, + isExpanded: true, + level: 1, + }, + { + id: 'backend-development', + name: 'Backend API Development', + startDate: new Date(2024, 11, 21), + endDate: new Date(2025, 1, 15), + progress: 45, + type: 'task', + status: 'in-progress', + priority: 'high', + parent: 'development-phase', + assignee: { + id: 'user-4', + name: 'David Wilson', + avatar: 'https://ui-avatars.com/api/?name=David+Wilson&background=722ed1&color=fff', + }, + dependencies: ['milestone-planning-complete'], + tags: ['backend', 'api'], + level: 2, + }, + { + id: 'frontend-development', + name: 'Frontend React Application', + startDate: new Date(2025, 0, 5), + endDate: new Date(2025, 1, 25), + progress: 25, + type: 'task', + status: 'in-progress', + priority: 'high', + parent: 'development-phase', + assignee: { + id: 'user-5', + name: 'Eva Brown', + avatar: 'https://ui-avatars.com/api/?name=Eva+Brown&background=ff7a45&color=fff', + }, + dependencies: ['backend-development'], + tags: ['frontend', 'react'], + level: 2, + }, + { + id: 'database-setup', + name: 'Database Schema & Migration', + startDate: new Date(2024, 11, 21), + endDate: new Date(2025, 0, 10), + progress: 80, + type: 'task', + status: 'in-progress', + priority: 'medium', + parent: 'development-phase', + assignee: { + id: 'user-6', + name: 'Frank Miller', + avatar: 'https://ui-avatars.com/api/?name=Frank+Miller&background=13c2c2&color=fff', + }, + dependencies: ['milestone-planning-complete'], + tags: ['database', 'migration'], + level: 2, + }, + + // Testing Phase + { + id: 'testing-phase', + name: '🧪 Testing & QA Phase', + startDate: new Date(2025, 2, 1), + endDate: new Date(2025, 2, 20), + progress: 0, + type: 'project', + status: 'not-started', + priority: 'high', + parent: 'project-1', + color: '#fa8c16', + hasChildren: true, + isExpanded: false, + level: 1, + }, + { + id: 'unit-testing', + name: 'Unit Testing Implementation', + startDate: new Date(2025, 2, 1), + endDate: new Date(2025, 2, 10), + progress: 0, + type: 'task', + status: 'not-started', + priority: 'high', + parent: 'testing-phase', + assignee: { + id: 'user-7', + name: 'Grace Lee', + avatar: 'https://ui-avatars.com/api/?name=Grace+Lee&background=fa8c16&color=fff', + }, + dependencies: ['frontend-development'], + tags: ['testing', 'unit'], + level: 2, + }, + { + id: 'integration-testing', + name: 'Integration & E2E Testing', + startDate: new Date(2025, 2, 8), + endDate: new Date(2025, 2, 18), + progress: 0, + type: 'task', + status: 'not-started', + priority: 'high', + parent: 'testing-phase', + assignee: { + id: 'user-8', + name: 'Henry Clark', + avatar: 'https://ui-avatars.com/api/?name=Henry+Clark&background=eb2f96&color=fff', + }, + dependencies: ['unit-testing'], + tags: ['testing', 'integration'], + level: 2, + }, + { + id: 'milestone-beta-ready', + name: '🎯 Beta Release Ready', + startDate: new Date(2025, 2, 20), + endDate: new Date(2025, 2, 20), + progress: 0, + type: 'milestone', + status: 'not-started', + priority: 'critical', + parent: 'testing-phase', + dependencies: ['integration-testing'], + level: 2, + }, + + // Deployment Phase + { + id: 'deployment-phase', + name: '🚀 Deployment & Launch', + startDate: new Date(2025, 2, 21), + endDate: new Date(2025, 2, 31), + progress: 0, + type: 'project', + status: 'not-started', + priority: 'critical', + parent: 'project-1', + color: '#f5222d', + hasChildren: true, + isExpanded: false, + level: 1, + }, + { + id: 'production-deployment', + name: 'Production Environment Setup', + startDate: new Date(2025, 2, 21), + endDate: new Date(2025, 2, 25), + progress: 0, + type: 'task', + status: 'not-started', + priority: 'critical', + parent: 'deployment-phase', + assignee: { + id: 'user-9', + name: 'Ivy Taylor', + avatar: 'https://ui-avatars.com/api/?name=Ivy+Taylor&background=f5222d&color=fff', + }, + dependencies: ['milestone-beta-ready'], + tags: ['deployment', 'production'], + level: 2, + }, + { + id: 'go-live', + name: 'Go Live & Monitoring', + startDate: new Date(2025, 2, 26), + endDate: new Date(2025, 2, 31), + progress: 0, + type: 'task', + status: 'not-started', + priority: 'critical', + parent: 'deployment-phase', + assignee: { + id: 'user-10', + name: 'Jack Anderson', + avatar: 'https://ui-avatars.com/api/?name=Jack+Anderson&background=2f54eb&color=fff', + }, + dependencies: ['production-deployment'], + tags: ['launch', 'monitoring'], + level: 2, + }, + { + id: 'milestone-project-complete', + name: '🎉 Project Launch Complete', + startDate: new Date(2025, 2, 31), + endDate: new Date(2025, 2, 31), + progress: 0, + type: 'milestone', + status: 'not-started', + priority: 'critical', + parent: 'deployment-phase', + dependencies: ['go-live'], + level: 2, + }, + ]; +}; + +// Enhanced column configuration +const sampleColumns: ColumnConfig[] = [ + { + field: 'name', + title: 'Task / Phase Name', + width: 300, + minWidth: 200, + resizable: true, + sortable: true, + fixed: true, + editor: 'text' + }, + { + field: 'assignee', + title: 'Assignee', + width: 150, + minWidth: 120, + resizable: true, + sortable: true, + fixed: true + }, + { + field: 'startDate', + title: 'Start Date', + width: 120, + minWidth: 100, + resizable: true, + sortable: true, + fixed: true, + editor: 'date' + }, + { + field: 'endDate', + title: 'End Date', + width: 120, + minWidth: 100, + resizable: true, + sortable: true, + fixed: true, + editor: 'date' + }, + { + field: 'duration', + title: 'Duration', + width: 80, + minWidth: 60, + resizable: true, + sortable: false, + fixed: true, + align: 'center' + }, + { + field: 'progress', + title: 'Progress', + width: 120, + minWidth: 100, + resizable: true, + sortable: true, + fixed: true, + editor: 'number' + }, + { + field: 'status', + title: 'Status', + width: 100, + minWidth: 80, + resizable: true, + sortable: true, + fixed: true, + editor: 'select', + editorOptions: [ + { value: 'not-started', label: 'Not Started' }, + { value: 'in-progress', label: 'In Progress' }, + { value: 'completed', label: 'Completed' }, + { value: 'on-hold', label: 'On Hold' }, + { value: 'overdue', label: 'Overdue' }, + ] + }, + { + field: 'priority', + title: 'Priority', + width: 100, + minWidth: 80, + resizable: true, + sortable: true, + fixed: true, + editor: 'select', + editorOptions: [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'critical', label: 'Critical' }, + ] + }, +]; + +const AdvancedGanttDemo: React.FC = () => { + const [tasks, setTasks] = useState(generateSampleTasks()); + const [selectedTasks, setSelectedTasks] = useState([]); + const themeMode = useAppSelector(state => state.themeReducer.mode); + + const handleTaskUpdate = (taskId: string, updates: Partial) => { + setTasks(prevTasks => + prevTasks.map(task => + task.id === taskId ? { ...task, ...updates } : task + ) + ); + message.success(`Task "${tasks.find(t => t.id === taskId)?.name}" updated`); + }; + + const handleTaskMove = (taskId: string, newDates: { start: Date; end: Date }) => { + setTasks(prevTasks => + prevTasks.map(task => + task.id === taskId + ? { ...task, startDate: newDates.start, endDate: newDates.end } + : task + ) + ); + message.info(`Task moved: ${newDates.start.toLocaleDateString()} - ${newDates.end.toLocaleDateString()}`); + }; + + const handleProgressChange = (taskId: string, progress: number) => { + setTasks(prevTasks => + prevTasks.map(task => + task.id === taskId ? { ...task, progress } : task + ) + ); + message.info(`Progress updated: ${Math.round(progress)}%`); + }; + + const handleSelectionChange = (selection: any) => { + setSelectedTasks(selection.selectedTasks); + }; + + const resetToSampleData = () => { + setTasks(generateSampleTasks()); + setSelectedTasks([]); + message.info('Gantt chart reset to sample data'); + }; + + const addSampleTask = () => { + const newTask: GanttTask = { + id: `task-${Date.now()}`, + name: `New Task ${tasks.length + 1}`, + startDate: new Date(), + endDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +7 days + progress: 0, + type: 'task', + status: 'not-started', + priority: 'medium', + level: 0, + }; + setTasks(prev => [...prev, newTask]); + message.success('New task added'); + }; + + const deleteSelectedTasks = () => { + if (selectedTasks.length === 0) { + message.warning('No tasks selected'); + return; + } + + setTasks(prev => prev.filter(task => !selectedTasks.includes(task.id))); + setSelectedTasks([]); + message.success(`${selectedTasks.length} task(s) deleted`); + }; + + const taskStats = useMemo(() => { + const total = tasks.length; + const completed = tasks.filter(t => t.status === 'completed').length; + const inProgress = tasks.filter(t => t.status === 'in-progress').length; + const overdue = tasks.filter(t => t.status === 'overdue').length; + const avgProgress = tasks.reduce((sum, t) => sum + t.progress, 0) / total; + + return { total, completed, inProgress, overdue, avgProgress }; + }, [tasks]); + + return ( +
+ {/* Header */} +
+
+
+
+

+ 🚀 Advanced Gantt Chart Demo +

+

+ Professional Gantt chart with draggable tasks, virtual scrolling, holiday markers, + and performance optimizations for modern project management. +

+
+ +
+ + + + + +
+
+ + {/* Project Statistics */} +
+
+
Total Tasks
+
{taskStats.total}
+
+
+
Completed
+
{taskStats.completed}
+
+
+
In Progress
+
{taskStats.inProgress}
+
+
+
Avg Progress
+
+ {Math.round(taskStats.avgProgress)}% +
+
+
+
+
+ + {/* Gantt Chart */} +
+ +
+ + {/* Feature List */} +
+
+

+ ✨ Advanced Features Demonstrated +

+
+
+

Performance & UX

+
    +
  • • Virtual scrolling for 1000+ tasks
  • +
  • • Smooth 60fps drag & drop
  • +
  • • Debounced updates
  • +
  • • Memory-optimized rendering
  • +
  • • Responsive design
  • +
+
+
+

Gantt Features

+
    +
  • • Draggable task bars
  • +
  • • Resizable task duration
  • +
  • • Progress editing
  • +
  • • Multi-level hierarchy
  • +
  • • Task dependencies
  • +
+
+
+

Timeline & Markers

+
    +
  • • Weekend & holiday markers
  • +
  • • Working day indicators
  • +
  • • Today line
  • +
  • • Multi-tier timeline
  • +
  • • Zoom levels (Year/Month/Week/Day)
  • +
+
+
+

Grid Features

+
    +
  • • Fixed columns layout
  • +
  • • Inline editing
  • +
  • • Column resizing
  • +
  • • Multi-select
  • +
  • • Hierarchical tree view
  • +
+
+
+

UI/UX

+
    +
  • • Dark/Light theme support
  • +
  • • Tailwind CSS styling
  • +
  • • Consistent with Worklenz
  • +
  • • Accessibility features
  • +
  • • Mobile responsive
  • +
+
+
+

Architecture

+
    +
  • • Modern React patterns
  • +
  • • TypeScript safety
  • +
  • • Optimized performance
  • +
  • • Enterprise features
  • +
  • • Best practices 2025
  • +
+
+
+
+
+
+ ); +}; + +export default AdvancedGanttDemo; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/DraggableTaskBar.tsx b/worklenz-frontend/src/components/advanced-gantt/DraggableTaskBar.tsx new file mode 100644 index 00000000..f8d930ca --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/DraggableTaskBar.tsx @@ -0,0 +1,304 @@ +import React, { useState, useRef, useCallback, useMemo } from 'react'; +import { GanttTask, DragState } from '../../types/advanced-gantt.types'; +import { useAppSelector } from '../../hooks/useAppSelector'; +import { themeWiseColor } from '../../utils/themeWiseColor'; +import { useDateCalculations } from '../../utils/gantt-performance'; + +interface DraggableTaskBarProps { + task: GanttTask; + timelineStart: Date; + dayWidth: number; + rowHeight: number; + index: number; + onTaskMove?: (taskId: string, newDates: { start: Date; end: Date }) => void; + onTaskResize?: (taskId: string, newDates: { start: Date; end: Date }) => void; + onProgressChange?: (taskId: string, progress: number) => void; + onTaskClick?: (task: GanttTask) => void; + onTaskDoubleClick?: (task: GanttTask) => void; + enableDragDrop?: boolean; + enableResize?: boolean; + enableProgressEdit?: boolean; + readOnly?: boolean; +} + +const DraggableTaskBar: React.FC = ({ + task, + timelineStart, + dayWidth, + rowHeight, + index, + onTaskMove, + onTaskResize, + onProgressChange, + onTaskClick, + onTaskDoubleClick, + enableDragDrop = true, + enableResize = true, + enableProgressEdit = true, + readOnly = false, +}) => { + const [dragState, setDragState] = useState(null); + const [hoverState, setHoverState] = useState(null); + const taskBarRef = useRef(null); + const themeMode = useAppSelector(state => state.themeReducer.mode); + const { getDaysBetween, addDays } = useDateCalculations(); + + // Calculate task position and dimensions + const taskPosition = useMemo(() => { + const startDays = getDaysBetween(timelineStart, task.startDate); + const duration = getDaysBetween(task.startDate, task.endDate); + + return { + x: startDays * dayWidth, + width: Math.max(dayWidth * 0.5, duration * dayWidth), + y: index * rowHeight + 8, // 8px padding + height: rowHeight - 16, // 16px total padding + }; + }, [task.startDate, task.endDate, timelineStart, dayWidth, rowHeight, index, getDaysBetween]); + + // Theme-aware colors + const colors = useMemo(() => { + const baseColor = task.color || getDefaultTaskColor(task.status); + return { + background: themeWiseColor(baseColor, adjustColorForDarkMode(baseColor), themeMode), + border: themeWiseColor(darkenColor(baseColor, 0.2), lightenColor(baseColor, 0.2), themeMode), + progress: themeWiseColor('#52c41a', '#34d399', themeMode), + text: themeWiseColor('#ffffff', '#f9fafb', themeMode), + hover: themeWiseColor(lightenColor(baseColor, 0.1), darkenColor(baseColor, 0.1), themeMode), + }; + }, [task.color, task.status, themeMode]); + + // Mouse event handlers + const handleMouseDown = useCallback((e: React.MouseEvent, dragType: DragState['dragType']) => { + if (readOnly || !enableDragDrop) return; + + e.preventDefault(); + e.stopPropagation(); + + const rect = taskBarRef.current?.getBoundingClientRect(); + if (!rect) return; + + setDragState({ + isDragging: true, + dragType, + taskId: task.id, + initialPosition: { x: e.clientX, y: e.clientY }, + currentPosition: { x: e.clientX, y: e.clientY }, + initialDates: { start: task.startDate, end: task.endDate }, + initialProgress: task.progress, + snapToGrid: true, + }); + + // Add global mouse event listeners + const handleMouseMove = (moveEvent: MouseEvent) => { + handleMouseMove_Internal(moveEvent, dragType); + }; + + const handleMouseUp = () => { + handleMouseUp_Internal(); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, [readOnly, enableDragDrop, task]); + + const handleMouseMove_Internal = useCallback((e: MouseEvent, dragType: DragState['dragType']) => { + if (!dragState) return; + + const deltaX = e.clientX - dragState.initialPosition.x; + const deltaDays = Math.round(deltaX / dayWidth); + + let newStartDate = task.startDate; + let newEndDate = task.endDate; + + switch (dragType) { + case 'move': + newStartDate = addDays(dragState.initialDates.start, deltaDays); + newEndDate = addDays(dragState.initialDates.end, deltaDays); + break; + + case 'resize-start': + newStartDate = addDays(dragState.initialDates.start, deltaDays); + // Ensure minimum duration + if (newStartDate >= newEndDate) { + newStartDate = addDays(newEndDate, -1); + } + break; + + case 'resize-end': + newEndDate = addDays(dragState.initialDates.end, deltaDays); + // Ensure minimum duration + if (newEndDate <= newStartDate) { + newEndDate = addDays(newStartDate, 1); + } + break; + + case 'progress': + if (enableProgressEdit) { + const progressDelta = deltaX / taskPosition.width; + const newProgress = Math.max(0, Math.min(100, (dragState.initialProgress || 0) + progressDelta * 100)); + onProgressChange?.(task.id, newProgress); + } + return; + } + + // Update drag state + setDragState(prev => prev ? { + ...prev, + currentPosition: { x: e.clientX, y: e.clientY }, + } : null); + + // Call appropriate handler + if (dragType === 'move') { + onTaskMove?.(task.id, { start: newStartDate, end: newEndDate }); + } else if (dragType.startsWith('resize')) { + onTaskResize?.(task.id, { start: newStartDate, end: newEndDate }); + } + }, [dragState, dayWidth, task, taskPosition.width, enableProgressEdit, onTaskMove, onTaskResize, onProgressChange, addDays]); + + const handleMouseUp_Internal = useCallback(() => { + setDragState(null); + }, []); + + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onTaskClick?.(task); + }, [task, onTaskClick]); + + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onTaskDoubleClick?.(task); + }, [task, onTaskDoubleClick]); + + // Render task bar with handles + const renderTaskBar = () => { + const isSelected = false; // TODO: Get from selection state + const isDragging = dragState?.isDragging || false; + + return ( +
setHoverState('task')} + onMouseLeave={() => setHoverState(null)} + onMouseDown={(e) => handleMouseDown(e, 'move')} + > + {/* Progress bar */} +
+ + {/* Task content */} +
+ + {task.name} + + + {/* Duration display for smaller tasks */} + {taskPosition.width < 100 && ( + + {getDaysBetween(task.startDate, task.endDate)}d + + )} +
+ + {/* Resize handles */} + {enableResize && !readOnly && hoverState === 'task' && ( + <> + {/* Left resize handle */} +
handleMouseDown(e, 'resize-start')} + onMouseEnter={() => setHoverState('resize-start')} + /> + + {/* Right resize handle */} +
handleMouseDown(e, 'resize-end')} + onMouseEnter={() => setHoverState('resize-end')} + /> + + )} + + {/* Progress handle */} + {enableProgressEdit && !readOnly && hoverState === 'task' && ( +
handleMouseDown(e, 'progress')} + onMouseEnter={() => setHoverState('progress')} + /> + )} + + {/* Task type indicator */} + {task.type === 'milestone' && ( +
+ )} +
+ ); + }; + + return renderTaskBar(); +}; + +// Helper functions +function getDefaultTaskColor(status: GanttTask['status']): string { + switch (status) { + case 'completed': return '#52c41a'; + case 'in-progress': return '#1890ff'; + case 'overdue': return '#ff4d4f'; + case 'on-hold': return '#faad14'; + default: return '#d9d9d9'; + } +} + +function darkenColor(color: string, amount: number): string { + // Simple color darkening - in a real app, use a proper color manipulation library + return color; +} + +function lightenColor(color: string, amount: number): string { + // Simple color lightening - in a real app, use a proper color manipulation library + return color; +} + +function adjustColorForDarkMode(color: string): string { + // Adjust color for dark mode - in a real app, use a proper color manipulation library + return color; +} + +export default DraggableTaskBar; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/GanttGrid.tsx b/worklenz-frontend/src/components/advanced-gantt/GanttGrid.tsx new file mode 100644 index 00000000..0d5aaed5 --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/GanttGrid.tsx @@ -0,0 +1,492 @@ +import React, { useMemo, useRef, useState, useCallback } from 'react'; +import { GanttTask, ColumnConfig, SelectionState } from '../../types/advanced-gantt.types'; +import { useAppSelector } from '../../hooks/useAppSelector'; +import { themeWiseColor } from '../../utils/themeWiseColor'; +import { ChevronRightIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; +import { CalendarIcon, UserIcon, FlagIcon } from '@heroicons/react/24/solid'; + +interface GanttGridProps { + tasks: GanttTask[]; + columns: ColumnConfig[]; + rowHeight: number; + containerHeight: number; + selection: SelectionState; + enableInlineEdit?: boolean; + enableMultiSelect?: boolean; + onTaskClick?: (task: GanttTask, event: React.MouseEvent) => void; + onTaskDoubleClick?: (task: GanttTask) => void; + onTaskExpand?: (taskId: string) => void; + onSelectionChange?: (selection: SelectionState) => void; + onColumnResize?: (columnField: string, newWidth: number) => void; + onTaskUpdate?: (taskId: string, field: string, value: any) => void; + className?: string; +} + +const GanttGrid: React.FC = ({ + tasks, + columns, + rowHeight, + containerHeight, + selection, + enableInlineEdit = true, + enableMultiSelect = true, + onTaskClick, + onTaskDoubleClick, + onTaskExpand, + onSelectionChange, + onColumnResize, + onTaskUpdate, + className = '', +}) => { + const [editingCell, setEditingCell] = useState<{ taskId: string; field: string } | null>(null); + const [columnWidths, setColumnWidths] = useState>( + columns.reduce((acc, col) => ({ ...acc, [col.field]: col.width }), {}) + ); + const gridRef = useRef(null); + const themeMode = useAppSelector(state => state.themeReducer.mode); + + // Theme-aware colors + const colors = useMemo(() => ({ + background: themeWiseColor('#ffffff', '#1f2937', themeMode), + alternateRow: themeWiseColor('#f9fafb', '#374151', themeMode), + border: themeWiseColor('#e5e7eb', '#4b5563', themeMode), + text: themeWiseColor('#111827', '#f9fafb', themeMode), + textSecondary: themeWiseColor('#6b7280', '#d1d5db', themeMode), + selected: themeWiseColor('#eff6ff', '#1e3a8a', themeMode), + hover: themeWiseColor('#f3f4f6', '#4b5563', themeMode), + headerBg: themeWiseColor('#f8f9fa', '#374151', themeMode), + }), [themeMode]); + + // Calculate total grid width + const totalWidth = useMemo(() => { + return columns.reduce((sum, col) => sum + columnWidths[col.field], 0); + }, [columns, columnWidths]); + + // Handle column resize + const handleColumnResize = useCallback((columnField: string, deltaX: number) => { + const column = columns.find(col => col.field === columnField); + if (!column) return; + + const currentWidth = columnWidths[columnField]; + const newWidth = Math.max(column.minWidth || 60, Math.min(column.maxWidth || 400, currentWidth + deltaX)); + + setColumnWidths(prev => ({ ...prev, [columnField]: newWidth })); + onColumnResize?.(columnField, newWidth); + }, [columns, columnWidths, onColumnResize]); + + // Handle task selection + const handleTaskSelection = useCallback((task: GanttTask, event: React.MouseEvent) => { + const { ctrlKey, shiftKey } = event; + let newSelectedTasks = [...selection.selectedTasks]; + + if (shiftKey && enableMultiSelect && selection.selectedTasks.length > 0) { + // Range selection + const lastSelectedIndex = tasks.findIndex(t => t.id === selection.selectedTasks[selection.selectedTasks.length - 1]); + const currentIndex = tasks.findIndex(t => t.id === task.id); + const [start, end] = [Math.min(lastSelectedIndex, currentIndex), Math.max(lastSelectedIndex, currentIndex)]; + + newSelectedTasks = tasks.slice(start, end + 1).map(t => t.id); + } else if (ctrlKey && enableMultiSelect) { + // Multi selection + if (newSelectedTasks.includes(task.id)) { + newSelectedTasks = newSelectedTasks.filter(id => id !== task.id); + } else { + newSelectedTasks.push(task.id); + } + } else { + // Single selection + newSelectedTasks = [task.id]; + } + + onSelectionChange?.({ + ...selection, + selectedTasks: newSelectedTasks, + focusedTask: task.id, + }); + + onTaskClick?.(task, event); + }, [tasks, selection, enableMultiSelect, onSelectionChange, onTaskClick]); + + // Handle cell editing + const handleCellDoubleClick = useCallback((task: GanttTask, column: ColumnConfig) => { + if (!enableInlineEdit || !column.editor) return; + + setEditingCell({ taskId: task.id, field: column.field }); + }, [enableInlineEdit]); + + const handleCellEditComplete = useCallback((value: any) => { + if (!editingCell) return; + + onTaskUpdate?.(editingCell.taskId, editingCell.field, value); + setEditingCell(null); + }, [editingCell, onTaskUpdate]); + + // Render cell content + const renderCellContent = useCallback((task: GanttTask, column: ColumnConfig) => { + const value = task[column.field as keyof GanttTask]; + const isEditing = editingCell?.taskId === task.id && editingCell?.field === column.field; + + if (isEditing) { + return renderCellEditor(value, column, handleCellEditComplete); + } + + if (column.renderer) { + return column.renderer(value, task); + } + + // Default renderers + switch (column.field) { + case 'name': + return ( +
+ {task.hasChildren && ( + + )} +
+ {getTaskTypeIcon(task.type)} + {task.name} +
+
+ ); + + case 'startDate': + case 'endDate': + return ( +
+ + {(value as Date)?.toLocaleDateString() || '-'} +
+ ); + + case 'assignee': + return task.assignee ? ( +
+ {task.assignee.avatar ? ( + {task.assignee.name} + ) : ( + + )} + {task.assignee.name} +
+ ) : ( + Unassigned + ); + + case 'progress': + return ( +
+
+
+
+ {task.progress}% +
+ ); + + case 'status': + return ( + + {task.status.replace('-', ' ')} + + ); + + case 'priority': + return ( +
+ + {task.priority} +
+ ); + + case 'duration': + const duration = task.duration || Math.ceil((task.endDate.getTime() - task.startDate.getTime()) / (1000 * 60 * 60 * 24)); + return {duration}d; + + default: + return {String(value || '')}; + } + }, [editingCell, onTaskExpand, handleCellEditComplete]); + + // Render header + const renderHeader = () => ( +
+ {columns.map((column, index) => ( +
+ + {column.title} + + + {/* Resize handle */} + {column.resizable && ( + handleColumnResize(column.field, deltaX)} + className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-blue-500 opacity-0 group-hover:opacity-100" + /> + )} +
+ ))} +
+ ); + + // Render task rows + const renderRows = () => ( +
+ {tasks.map((task, rowIndex) => { + const isSelected = selection.selectedTasks.includes(task.id); + const isFocused = selection.focusedTask === task.id; + + return ( +
handleTaskSelection(task, e)} + onDoubleClick={() => onTaskDoubleClick?.(task)} + > + {columns.map((column) => ( +
handleCellDoubleClick(task, column)} + > + {renderCellContent(task, column)} +
+ ))} +
+ ); + })} +
+ ); + + return ( +
+ {renderHeader()} +
+ {renderRows()} +
+
+ ); +}; + +// Resize handle component +interface ResizeHandleProps { + onResize: (deltaX: number) => void; + className?: string; +} + +const ResizeHandle: React.FC = ({ onResize, className }) => { + const [isDragging, setIsDragging] = useState(false); + const startXRef = useRef(0); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + startXRef.current = e.clientX; + + const handleMouseMove = (moveEvent: MouseEvent) => { + const deltaX = moveEvent.clientX - startXRef.current; + onResize(deltaX); + startXRef.current = moveEvent.clientX; + }; + + const handleMouseUp = () => { + setIsDragging(false); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, [onResize]); + + return ( +
+ ); +}; + +// Cell editor component +const renderCellEditor = (value: any, column: ColumnConfig, onComplete: (value: any) => void) => { + const [editValue, setEditValue] = useState(value); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onComplete(editValue); + } else if (e.key === 'Escape') { + onComplete(value); // Cancel editing + } + }; + + const handleBlur = () => { + onComplete(editValue); + }; + + switch (column.editor) { + case 'text': + return ( + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + className="w-full px-1 py-0.5 border rounded text-sm" + autoFocus + /> + ); + + case 'date': + return ( + setEditValue(new Date(e.target.value))} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + className="w-full px-1 py-0.5 border rounded text-sm" + autoFocus + /> + ); + + case 'number': + return ( + setEditValue(parseFloat(e.target.value))} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + className="w-full px-1 py-0.5 border rounded text-sm" + autoFocus + /> + ); + + case 'select': + return ( + + ); + + default: + return {String(value)}; + } +}; + +// Helper functions +const getTaskTypeIcon = (type: GanttTask['type']) => { + switch (type) { + case 'project': + return
; + case 'milestone': + return
; + default: + return
; + } +}; + +const getStatusColor = (status: GanttTask['status']) => { + switch (status) { + case 'completed': + return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'; + case 'in-progress': + return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'; + case 'overdue': + return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'; + case 'on-hold': + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'; + default: + return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'; + } +}; + +const getPriorityColor = (priority: GanttTask['priority']) => { + switch (priority) { + case 'critical': + return 'text-red-600'; + case 'high': + return 'text-orange-500'; + case 'medium': + return 'text-yellow-500'; + case 'low': + return 'text-green-500'; + default: + return 'text-gray-400'; + } +}; + +export default GanttGrid; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/TimelineMarkers.tsx b/worklenz-frontend/src/components/advanced-gantt/TimelineMarkers.tsx new file mode 100644 index 00000000..4dd3a9ad --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/TimelineMarkers.tsx @@ -0,0 +1,295 @@ +import React, { useMemo } from 'react'; +import { Holiday, TimelineConfig } from '../../types/advanced-gantt.types'; +import { useAppSelector } from '../../hooks/useAppSelector'; +import { themeWiseColor } from '../../utils/themeWiseColor'; +import { useDateCalculations } from '../../utils/gantt-performance'; + +interface TimelineMarkersProps { + startDate: Date; + endDate: Date; + dayWidth: number; + containerHeight: number; + timelineConfig: TimelineConfig; + holidays?: Holiday[]; + showWeekends?: boolean; + showHolidays?: boolean; + showToday?: boolean; + className?: string; +} + +const TimelineMarkers: React.FC = ({ + startDate, + endDate, + dayWidth, + containerHeight, + timelineConfig, + holidays = [], + showWeekends = true, + showHolidays = true, + showToday = true, + className = '', +}) => { + const themeMode = useAppSelector(state => state.themeReducer.mode); + const { getDaysBetween, isWeekend, isWorkingDay } = useDateCalculations(); + + // Generate all dates in the timeline + const timelineDates = useMemo(() => { + const dates: Date[] = []; + const totalDays = getDaysBetween(startDate, endDate); + + for (let i = 0; i <= totalDays; i++) { + const date = new Date(startDate); + date.setDate(date.getDate() + i); + dates.push(date); + } + + return dates; + }, [startDate, endDate, getDaysBetween]); + + // Theme-aware colors + const colors = useMemo(() => ({ + weekend: themeWiseColor('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)', themeMode), + nonWorkingDay: themeWiseColor('rgba(0, 0, 0, 0.03)', 'rgba(255, 255, 255, 0.03)', themeMode), + holiday: themeWiseColor('rgba(255, 107, 107, 0.1)', 'rgba(255, 107, 107, 0.15)', themeMode), + today: themeWiseColor('rgba(24, 144, 255, 0.15)', 'rgba(64, 169, 255, 0.2)', themeMode), + todayLine: themeWiseColor('#1890ff', '#40a9ff', themeMode), + holidayBorder: themeWiseColor('#ff6b6b', '#ff8787', themeMode), + }), [themeMode]); + + // Check if a date is a holiday + const isHoliday = (date: Date): Holiday | undefined => { + return holidays.find(holiday => { + if (holiday.recurring) { + return holiday.date.getMonth() === date.getMonth() && + holiday.date.getDate() === date.getDate(); + } + return holiday.date.toDateString() === date.toDateString(); + }); + }; + + // Check if date is today + const isToday = (date: Date): boolean => { + const today = new Date(); + return date.toDateString() === today.toDateString(); + }; + + // Render weekend markers + const renderWeekendMarkers = () => { + if (!showWeekends) return null; + + return timelineDates.map((date, index) => { + if (!isWeekend(date)) return null; + + return ( +
+ ); + }); + }; + + // Render non-working day markers + const renderNonWorkingDayMarkers = () => { + return timelineDates.map((date, index) => { + if (isWorkingDay(date, timelineConfig.workingDays)) return null; + + return ( +
+ ); + }); + }; + + // Render holiday markers + const renderHolidayMarkers = () => { + if (!showHolidays) return null; + + return timelineDates.map((date, index) => { + const holiday = isHoliday(date); + if (!holiday) return null; + + const holidayColor = holiday.color || colors.holiday; + + return ( +
+ {/* Holiday tooltip */} +
+
{holiday.name}
+
{date.toLocaleDateString()}
+
+
+ + {/* Holiday icon */} +
+
+
+
+ ); + }); + }; + + // Render today marker + const renderTodayMarker = () => { + if (!showToday) return null; + + const todayIndex = timelineDates.findIndex(date => isToday(date)); + if (todayIndex === -1) return null; + + return ( +
+ {/* Today line */} +
+ + {/* Today label */} +
+ Today +
+
+ ); + }; + + // Render time period markers (quarters, months, etc.) + const renderTimePeriodMarkers = () => { + const markers: React.ReactNode[] = []; + const currentDate = new Date(startDate); + currentDate.setDate(1); // Start of month + + while (currentDate <= endDate) { + const daysSinceStart = getDaysBetween(startDate, currentDate); + const isQuarterStart = currentDate.getMonth() % 3 === 0 && currentDate.getDate() === 1; + const isYearStart = currentDate.getMonth() === 0 && currentDate.getDate() === 1; + + if (isYearStart) { + markers.push( +
+
+ {currentDate.getFullYear()} +
+
+ ); + } else if (isQuarterStart) { + markers.push( +
+
+ Q{Math.floor(currentDate.getMonth() / 3) + 1} +
+
+ ); + } + + // Move to next month + currentDate.setMonth(currentDate.getMonth() + 1); + } + + return markers; + }; + + return ( +
+ {renderNonWorkingDayMarkers()} + {renderWeekendMarkers()} + {renderHolidayMarkers()} + {renderTodayMarker()} + {renderTimePeriodMarkers()} +
+ ); +}; + +// Holiday presets for common countries +export const holidayPresets = { + US: [ + { date: new Date(2024, 0, 1), name: "New Year's Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 0, 15), name: "Martin Luther King Jr. Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 1, 19), name: "Presidents' Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 4, 27), name: "Memorial Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 5, 19), name: "Juneteenth", type: 'national' as const, recurring: true }, + { date: new Date(2024, 6, 4), name: "Independence Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 8, 2), name: "Labor Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 9, 14), name: "Columbus Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 10, 11), name: "Veterans Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 10, 28), name: "Thanksgiving", type: 'national' as const, recurring: true }, + { date: new Date(2024, 11, 25), name: "Christmas Day", type: 'national' as const, recurring: true }, + ], + + UK: [ + { date: new Date(2024, 0, 1), name: "New Year's Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 2, 29), name: "Good Friday", type: 'religious' as const, recurring: false }, + { date: new Date(2024, 3, 1), name: "Easter Monday", type: 'religious' as const, recurring: false }, + { date: new Date(2024, 4, 6), name: "Early May Bank Holiday", type: 'national' as const, recurring: true }, + { date: new Date(2024, 4, 27), name: "Spring Bank Holiday", type: 'national' as const, recurring: true }, + { date: new Date(2024, 7, 26), name: "Summer Bank Holiday", type: 'national' as const, recurring: true }, + { date: new Date(2024, 11, 25), name: "Christmas Day", type: 'religious' as const, recurring: true }, + { date: new Date(2024, 11, 26), name: "Boxing Day", type: 'national' as const, recurring: true }, + ], +}; + +// Working day presets +export const workingDayPresets = { + standard: [1, 2, 3, 4, 5], // Monday to Friday + middle_east: [0, 1, 2, 3, 4], // Sunday to Thursday + six_day: [1, 2, 3, 4, 5, 6], // Monday to Saturday + four_day: [1, 2, 3, 4], // Monday to Thursday +}; + +export default TimelineMarkers; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/VirtualScrollContainer.tsx b/worklenz-frontend/src/components/advanced-gantt/VirtualScrollContainer.tsx new file mode 100644 index 00000000..0e855bc3 --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/VirtualScrollContainer.tsx @@ -0,0 +1,372 @@ +import React, { useRef, useEffect, useState, useCallback, ReactNode } from 'react'; +import { useThrottle, usePerformanceMonitoring } from '../../utils/gantt-performance'; +import { useAppSelector } from '../../hooks/useAppSelector'; + +interface VirtualScrollContainerProps { + items: any[]; + itemHeight: number; + containerHeight: number; + containerWidth?: number; + overscan?: number; + horizontal?: boolean; + children: (item: any, index: number, style: React.CSSProperties) => ReactNode; + onScroll?: (scrollLeft: number, scrollTop: number) => void; + className?: string; + style?: React.CSSProperties; +} + +const VirtualScrollContainer: React.FC = ({ + items, + itemHeight, + containerHeight, + containerWidth = 0, + overscan = 5, + horizontal = false, + children, + onScroll, + className = '', + style = {}, +}) => { + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [scrollLeft, setScrollLeft] = useState(0); + const { startMeasure, endMeasure, recordMetric } = usePerformanceMonitoring(); + const themeMode = useAppSelector(state => state.themeReducer.mode); + + // Calculate visible range + const totalHeight = items.length * itemHeight; + const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); + const endIndex = Math.min( + items.length - 1, + Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan + ); + const visibleItems = items.slice(startIndex, endIndex + 1); + const offsetY = startIndex * itemHeight; + + // Throttled scroll handler + const throttledScrollHandler = useThrottle( + useCallback((event: Event) => { + const target = event.target as HTMLDivElement; + const newScrollTop = target.scrollTop; + const newScrollLeft = target.scrollLeft; + + setScrollTop(newScrollTop); + setScrollLeft(newScrollLeft); + onScroll?.(newScrollLeft, newScrollTop); + }, [onScroll]), + 16 // ~60fps + ); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + container.addEventListener('scroll', throttledScrollHandler, { passive: true }); + + return () => { + container.removeEventListener('scroll', throttledScrollHandler); + }; + }, [throttledScrollHandler]); + + // Performance monitoring + useEffect(() => { + startMeasure('virtualScroll'); + recordMetric('visibleTaskCount', visibleItems.length); + recordMetric('taskCount', items.length); + endMeasure('virtualScroll'); + }, [visibleItems.length, items.length, startMeasure, endMeasure, recordMetric]); + + const renderVisibleItems = () => { + return visibleItems.map((item, virtualIndex) => { + const actualIndex = startIndex + virtualIndex; + const itemStyle: React.CSSProperties = { + position: 'absolute', + top: horizontal ? 0 : actualIndex * itemHeight, + left: horizontal ? actualIndex * itemHeight : 0, + height: horizontal ? '100%' : itemHeight, + width: horizontal ? itemHeight : '100%', + transform: horizontal ? 'none' : `translateY(${offsetY}px)`, + }; + + return ( +
+ {children(item, actualIndex, itemStyle)} +
+ ); + }); + }; + + return ( +
+ {/* Spacer to maintain scroll height */} +
+ {/* Visible items container */} +
+ {renderVisibleItems()} +
+
+
+ ); +}; + +// Grid virtual scrolling component for both rows and columns +interface VirtualGridProps { + data: any[][]; + rowHeight: number; + columnWidth: number | number[]; + containerHeight: number; + containerWidth: number; + overscan?: number; + children: (item: any, rowIndex: number, colIndex: number, style: React.CSSProperties) => ReactNode; + onScroll?: (scrollLeft: number, scrollTop: number) => void; + className?: string; +} + +export const VirtualGrid: React.FC = ({ + data, + rowHeight, + columnWidth, + containerHeight, + containerWidth, + overscan = 3, + children, + onScroll, + className = '', +}) => { + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [scrollLeft, setScrollLeft] = useState(0); + + const rowCount = data.length; + const colCount = data[0]?.length || 0; + + // Calculate column positions for variable width columns + const columnWidths = Array.isArray(columnWidth) ? columnWidth : new Array(colCount).fill(columnWidth); + const columnPositions = columnWidths.reduce((acc, width, index) => { + acc[index] = index === 0 ? 0 : acc[index - 1] + columnWidths[index - 1]; + return acc; + }, {} as Record); + + const totalWidth = columnWidths.reduce((sum, width) => sum + width, 0); + const totalHeight = rowCount * rowHeight; + + // Calculate visible ranges + const startRowIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan); + const endRowIndex = Math.min(rowCount - 1, Math.ceil((scrollTop + containerHeight) / rowHeight) + overscan); + + const startColIndex = Math.max(0, findColumnIndex(scrollLeft) - overscan); + const endColIndex = Math.min(colCount - 1, findColumnIndex(scrollLeft + containerWidth) + overscan); + + function findColumnIndex(position: number): number { + for (let i = 0; i < colCount; i++) { + if (columnPositions[i] <= position && position < columnPositions[i] + columnWidths[i]) { + return i; + } + } + return colCount - 1; + } + + const throttledScrollHandler = useThrottle( + useCallback((event: Event) => { + const target = event.target as HTMLDivElement; + const newScrollTop = target.scrollTop; + const newScrollLeft = target.scrollLeft; + + setScrollTop(newScrollTop); + setScrollLeft(newScrollLeft); + onScroll?.(newScrollLeft, newScrollTop); + }, [onScroll]), + 16 + ); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + container.addEventListener('scroll', throttledScrollHandler, { passive: true }); + + return () => { + container.removeEventListener('scroll', throttledScrollHandler); + }; + }, [throttledScrollHandler]); + + const renderVisibleCells = () => { + const cells: ReactNode[] = []; + + for (let rowIndex = startRowIndex; rowIndex <= endRowIndex; rowIndex++) { + for (let colIndex = startColIndex; colIndex <= endColIndex; colIndex++) { + const item = data[rowIndex]?.[colIndex]; + if (!item) continue; + + const cellStyle: React.CSSProperties = { + position: 'absolute', + top: rowIndex * rowHeight, + left: columnPositions[colIndex], + height: rowHeight, + width: columnWidths[colIndex], + }; + + cells.push( +
+ {children(item, rowIndex, colIndex, cellStyle)} +
+ ); + } + } + + return cells; + }; + + return ( +
+
+ {renderVisibleCells()} +
+
+ ); +}; + +// Timeline virtual scrolling component +interface VirtualTimelineProps { + startDate: Date; + endDate: Date; + dayWidth: number; + containerWidth: number; + containerHeight: number; + overscan?: number; + children: (date: Date, index: number, style: React.CSSProperties) => ReactNode; + onScroll?: (scrollLeft: number) => void; + className?: string; +} + +export const VirtualTimeline: React.FC = ({ + startDate, + endDate, + dayWidth, + containerWidth, + containerHeight, + overscan = 10, + children, + onScroll, + className = '', +}) => { + const containerRef = useRef(null); + const [scrollLeft, setScrollLeft] = useState(0); + + const totalDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); + const totalWidth = totalDays * dayWidth; + + const startDayIndex = Math.max(0, Math.floor(scrollLeft / dayWidth) - overscan); + const endDayIndex = Math.min(totalDays - 1, Math.ceil((scrollLeft + containerWidth) / dayWidth) + overscan); + + const throttledScrollHandler = useThrottle( + useCallback((event: Event) => { + const target = event.target as HTMLDivElement; + const newScrollLeft = target.scrollLeft; + setScrollLeft(newScrollLeft); + onScroll?.(newScrollLeft); + }, [onScroll]), + 16 + ); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + container.addEventListener('scroll', throttledScrollHandler, { passive: true }); + + return () => { + container.removeEventListener('scroll', throttledScrollHandler); + }; + }, [throttledScrollHandler]); + + const renderVisibleDays = () => { + const days: ReactNode[] = []; + + for (let dayIndex = startDayIndex; dayIndex <= endDayIndex; dayIndex++) { + const date = new Date(startDate); + date.setDate(date.getDate() + dayIndex); + + const dayStyle: React.CSSProperties = { + position: 'absolute', + left: dayIndex * dayWidth, + top: 0, + width: dayWidth, + height: '100%', + }; + + days.push( +
+ {children(date, dayIndex, dayStyle)} +
+ ); + } + + return days; + }; + + return ( +
+
+ {renderVisibleDays()} +
+
+ ); +}; + +export default VirtualScrollContainer; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/index.ts b/worklenz-frontend/src/components/advanced-gantt/index.ts new file mode 100644 index 00000000..7d27b5bc --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/index.ts @@ -0,0 +1,17 @@ +// Main Components +export { default as AdvancedGanttChart } from './AdvancedGanttChart'; +export { default as AdvancedGanttDemo } from './AdvancedGanttDemo'; + +// Core Components +export { default as GanttGrid } from './GanttGrid'; +export { default as DraggableTaskBar } from './DraggableTaskBar'; +export { default as TimelineMarkers, holidayPresets, workingDayPresets } from './TimelineMarkers'; + +// Utility Components +export { default as VirtualScrollContainer, VirtualGrid, VirtualTimeline } from './VirtualScrollContainer'; + +// Types +export * from '../../types/advanced-gantt.types'; + +// Performance Utilities +export * from '../../utils/gantt-performance'; \ No newline at end of file diff --git a/worklenz-frontend/src/components/project-roadmap-gantt/PhaseModal.tsx b/worklenz-frontend/src/components/project-roadmap-gantt/PhaseModal.tsx new file mode 100644 index 00000000..c921d2f6 --- /dev/null +++ b/worklenz-frontend/src/components/project-roadmap-gantt/PhaseModal.tsx @@ -0,0 +1,406 @@ +import React, { useState } from 'react'; +import { + Modal, + Tabs, + Progress, + Tag, + List, + Avatar, + Badge, + Space, + Button, + Statistic, + Row, + Col, + Timeline, + Input, + Form, + DatePicker, + Select +} from 'antd'; +import { + CalendarOutlined, + TeamOutlined, + CheckCircleOutlined, + ClockCircleOutlined, + FlagOutlined, + ExclamationCircleOutlined, + EditOutlined, + SaveOutlined, + CloseOutlined +} from '@ant-design/icons'; +import { PhaseModalData, ProjectPhase, PhaseTask, PhaseMilestone } from '../../types/project-roadmap.types'; +import { useAppSelector } from '../../hooks/useAppSelector'; +import { themeWiseColor } from '../../utils/themeWiseColor'; +import dayjs from 'dayjs'; + +const { TabPane } = Tabs; +const { TextArea } = Input; + +interface PhaseModalProps { + visible: boolean; + phase: PhaseModalData | null; + onClose: () => void; + onUpdate?: (updates: Partial) => void; +} + +const PhaseModal: React.FC = ({ + visible, + phase, + onClose, + onUpdate, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [form] = Form.useForm(); + + // Theme support + const themeMode = useAppSelector(state => state.themeReducer.mode); + const isDarkMode = themeMode === 'dark'; + + if (!phase) return null; + + const handleEdit = () => { + setIsEditing(true); + form.setFieldsValue({ + name: phase.name, + description: phase.description, + startDate: dayjs(phase.startDate), + endDate: dayjs(phase.endDate), + status: phase.status, + }); + }; + + const handleSave = async () => { + try { + const values = await form.validateFields(); + const updates: Partial = { + name: values.name, + description: values.description, + startDate: values.startDate.toDate(), + endDate: values.endDate.toDate(), + status: values.status, + }; + + onUpdate?.(updates); + setIsEditing(false); + } catch (error) { + console.error('Validation failed:', error); + } + }; + + const handleCancel = () => { + setIsEditing(false); + form.resetFields(); + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'completed': return 'success'; + case 'in-progress': return 'processing'; + case 'on-hold': return 'warning'; + default: return 'default'; + } + }; + + const getPriorityColor = (priority: string) => { + switch (priority) { + case 'high': return 'red'; + case 'medium': return 'orange'; + case 'low': return 'green'; + default: return 'default'; + } + }; + + const getTaskStatusIcon = (status: string) => { + switch (status) { + case 'done': return ; + case 'in-progress': return ; + default: return ; + } + }; + + return ( + + + + {isEditing ? ( + + + + ) : ( +

+ {phase.name} +

+ )} +
+ + {isEditing ? ( + <> + + + + ) : ( + + )} + +
+ } + open={visible} + onCancel={onClose} + width={800} + footer={null} + className="dark:bg-gray-800" + > +
+
+ {isEditing ? ( + Description}> +