diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx index e7dd5591..f20ac02a 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx @@ -1,14 +1,30 @@ -import React, { memo, useMemo, useState } from 'react'; +import React, { memo, useMemo, useState, useRef, useEffect } from 'react'; import { useAppSelector } from '@/hooks/useAppSelector'; import { ITaskListGroup } from '@/types/tasks/taskList.types'; import TaskCard from './TaskCard'; import { themeWiseColor } from '@/utils/themeWiseColor'; import EnhancedKanbanCreateTaskCard from '../EnhancedKanbanCreateTaskCard'; -import { PlusOutlined } from '@ant-design/icons'; -import Button from 'antd/es/button'; import { useTranslation } from 'react-i18next'; import { useAuthService } from '@/hooks/useAuth'; import useIsProjectManager from '@/hooks/useIsProjectManager'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types'; +import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; +import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice'; +import logger from '@/utils/errorLogger'; +import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events'; +import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service'; +import { ITaskPhase } from '@/types/tasks/taskPhase.types'; +import { useMixpanelTracking } from '@/hooks/useMixpanelTracking'; +import { + deleteStatusToggleDrawer, + seletedStatusCategory, +} from '@/features/projects/status/DeleteStatusSlice'; +import { + fetchEnhancedKanbanGroups, + IGroupBy, +} from '@/features/enhanced-kanban/enhanced-kanban.slice'; + interface KanbanGroupProps { group: ITaskListGroup; @@ -33,12 +49,28 @@ const KanbanGroup: React.FC = memo(({ hoveredTaskIdx, hoveredGroupId }) => { - const themeMode = useAppSelector(state => state.themeReducer.mode); - const { t } = useTranslation('kanban-board'); + const [isHover, setIsHover] = useState(false); const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin(); + const [isEditable, setIsEditable] = useState(false); const isProjectManager = useIsProjectManager(); + const [isLoading, setIsLoading] = useState(false); + const [name, setName] = useState(group.name); + const inputRef = useRef(null); + const [editName, setEdit] = useState(group.name); + const [isEllipsisActive, setIsEllipsisActive] = useState(false); + const [showDropdown, setShowDropdown] = useState(false); + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + const dropdownRef = useRef(null); + const themeMode = useAppSelector(state => state.themeReducer.mode); + const dispatch = useAppDispatch(); + const { projectId } = useAppSelector(state => state.projectReducer); + const { groupBy } = useAppSelector(state => state.enhancedKanbanReducer); + const { statusCategories, status } = useAppSelector(state => state.taskStatusReducer); + const { trackMixpanelEvent } = useMixpanelTracking(); const [showNewCardTop, setShowNewCardTop] = useState(false); const [showNewCardBottom, setShowNewCardBottom] = useState(false); + const { t } = useTranslation('kanban-board'); + const headerBackgroundColor = useMemo(() => { if (themeMode === 'dark') { return group.color_code_dark || group.color_code || '#1e1e1e'; @@ -46,6 +78,156 @@ const KanbanGroup: React.FC = memo(({ return group.color_code || '#f5f5f5'; }, [themeMode, group.color_code, group.color_code_dark]); + const getUniqueSectionName = (baseName: string): string => { + // Check if the base name already exists + const existingNames = status.map(status => status.name?.toLowerCase()); + + if (!existingNames.includes(baseName.toLowerCase())) { + return baseName; + } + + // If the base name exists, add a number suffix + let counter = 1; + let newName = `${baseName.trim()} (${counter})`; + + while (existingNames.includes(newName.toLowerCase())) { + counter++; + newName = `${baseName.trim()} (${counter})`; + } + + return newName; + }; + + const updateStatus = async (category = group.category_id ?? null) => { + if (!category || !projectId || !group.id) return; + const sectionName = getUniqueSectionName(name); + const body: ITaskStatusUpdateModel = { + name: sectionName, + project_id: projectId, + category_id: category, + }; + const res = await statusApiService.updateStatus(group.id, body, projectId); + if (res.done) { + dispatch(fetchEnhancedKanbanGroups(projectId)); + dispatch(fetchStatuses(projectId)); + setName(sectionName); + } else { + setName(editName); + logger.error('Error updating status', res.message); + } + }; + + const handleChange = async (e: React.ChangeEvent) => { + const taskName = e.target.value; + setName(taskName); + }; + + const handleBlur = async () => { + setIsEditable(false); + if (name === editName) return; + if (name === 'Untitled section') { + dispatch(fetchEnhancedKanbanGroups(projectId ?? '')); + } + + if (!projectId || !group.id) return; + + if (groupBy === IGroupBy.STATUS) { + await updateStatus(); + } + + if (groupBy === IGroupBy.PHASE) { + const body = { + id: group.id, + name: name, + }; + + const res = await phasesApiService.updateNameOfPhase( + group.id, + body as ITaskPhase, + projectId + ); + if (res.done) { + trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' }); + dispatch(fetchEnhancedKanbanGroups(projectId)); + } + } + }; + + const handlePressEnter = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + setShowNewCardTop(true); + setShowNewCardBottom(false); + handleBlur(); + } + }; + + const handleDeleteSection = async () => { + if (!projectId || !group.id) return; + + try { + if (groupBy === IGroupBy.STATUS) { + const replacingStatusId = ''; + const res = await statusApiService.deleteStatus(group.id, projectId, replacingStatusId); + if (res.message === 'At least one status should exists under each category.') return; + if (res.done) { + dispatch(fetchEnhancedKanbanGroups(projectId)); + } else { + dispatch( + seletedStatusCategory({ + id: group.id, + name: name, + category_id: group.category_id ?? '', + message: res.message ?? '', + }) + ); + dispatch(deleteStatusToggleDrawer()); + } + } else if (groupBy === IGroupBy.PHASE) { + const res = await phasesApiService.deletePhaseOption(group.id, projectId); + if (res.done) { + dispatch(fetchEnhancedKanbanGroups(projectId)); + } + } + } catch (error) { + logger.error('Error deleting section', error); + } + }; + + const handleRename = () => { + setIsEditable(true); + setShowDropdown(false); + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + }; + + const handleCategoryChange = (categoryId: string) => { + updateStatus(categoryId); + setShowDropdown(false); + }; + + const handleDelete = () => { + setShowDeleteConfirm(true); + setShowDropdown(false); + }; + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setShowDropdown(false); + } + }; + + if (showDropdown) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [showDropdown]); + return (
= memo(({ onDragOver={onGroupDragOver} onDrop={e => onGroupDrop(e, group.id)} > -

{group.name}

- {group.tasks.length} +
setIsHover(true)} + onMouseLeave={() => setIsHover(false)} + > +
{ + e.stopPropagation(); + if ((isProjectManager || isOwnerOrAdmin) && group.name !== 'Unmapped') + setIsEditable(true); + }} + onMouseDown={e => { + e.stopPropagation(); + }} + > + {isLoading && ( +
+ )} + {isEditable ? ( + { + e.stopPropagation(); + }} + onClick={e => { + e.stopPropagation(); + }} + /> + ) : ( +
{ + e.stopPropagation(); + e.preventDefault(); + }} + onMouseUp={e => { + e.stopPropagation(); + }} + onClick={e => { + e.stopPropagation(); + }} + > + {name} ({group.tasks.length}) +
+ )} +
+ +
+ + + {(isOwnerOrAdmin || isProjectManager) && name !== 'Unmapped' && ( +
+ + + {showDropdown && ( +
+
+ + + {groupBy === IGroupBy.STATUS && statusCategories && ( +
+
+ Change category +
+ {statusCategories.map(status => ( + + ))} +
+ )} + + {groupBy !== IGroupBy.PRIORITY && ( +
+ +
+ )} +
+
+ )} +
+ )} +
+
+ + {/* Simple Delete Confirmation */} + {showDeleteConfirm && ( +
+
+
+
+
+ + + +
+
+

+ {t('deleteConfirmationTitle')} +

+
+
+
+ + +
+
+
+
+ )}
+ {/* Create card at top */} + {showNewCardTop && (isOwnerOrAdmin || isProjectManager) && ( + + )} + {/* If group is empty, render a drop zone */} - {group.tasks.length === 0 && ( + {group.tasks.length === 0 && !showNewCardTop && !showNewCardBottom &&(
= memo(({
)} {(isOwnerOrAdmin || isProjectManager) && !showNewCardTop && !showNewCardBottom && ( - + )} - {showNewCardTop && }
- )} + ) + } + {/* Drop indicator at the top of the group */} {hoveredGroupId === group.id && hoveredTaskIdx === 0 && ( @@ -123,6 +501,7 @@ const KanbanGroup: React.FC = memo(({ {group.tasks.map((task, idx) => ( = memo(({ idx={idx} /> ))} + + {/* Create card at bottom */} + {showNewCardBottom && (isOwnerOrAdmin || isProjectManager) && ( + + )} + + {/* Footer Add Task Button */} {(isOwnerOrAdmin || isProjectManager) && !showNewCardTop && !showNewCardBottom && group.tasks.length > 0 && ( - + )} - {showNewCardBottom && } {/* Drop indicator at the end of the group */} {hoveredGroupId === group.id && hoveredTaskIdx === group.tasks.length && ( @@ -160,6 +545,8 @@ const KanbanGroup: React.FC = memo(({
)} + + ); });