diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.css b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.css index e8701e21..1640d576 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.css +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.css @@ -11,7 +11,7 @@ .kanban-groups-container { display: flex; gap: 16px; - min-height: calc(100vh - 200px); + min-height: calc(100vh - 350px); padding-bottom: 16px; } diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx index 4371f8f1..1837be05 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoard.tsx @@ -43,6 +43,7 @@ import { statusApiService } from '@/api/taskAttributes/status/status.api.service import { ITaskStatusCreateRequest } from '@/types/tasks/task-status-create-request'; import alertService from '@/services/alerts/alertService'; import { IGroupBy } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import EnhancedKanbanCreateSection from './EnhancedKanbanCreateSection'; // Import the TaskListFilters component const TaskListFilters = React.lazy(() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters')); @@ -380,9 +381,7 @@ const EnhancedKanbanBoard: React.FC = ({ projectId, cl } return ( -
- {/* Performance Monitor - only show for large datasets */} - {/* {performanceMetrics.totalTasks > 100 && } */} + <> = ({ projectId, cl +
+ {/* Performance Monitor - only show for large datasets */} + {/* {performanceMetrics.totalTasks > 100 && } */} - {loadingGroups ? ( - -
- -
-
- ) : taskGroups.length === 0 ? ( - - - - ) : ( - - -
- {taskGroups.map(group => ( - - ))} + {loadingGroups ? ( + +
+
- - - - {activeTask && ( - - )} - {activeGroup && ( -
-
-

{activeGroup.name}

- ({activeGroup.tasks.length}) -
+ + ) : taskGroups.length === 0 ? ( + + + + ) : ( + + +
+ {taskGroups.map(group => ( + + ))} +
- )} - -
- )} -
+ + + + {activeTask && ( + + )} + {activeGroup && ( +
+
+

{activeGroup.name}

+ ({activeGroup.tasks.length}) +
+
+ )} +
+ + )} +
+ ); }; diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSection.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSection.tsx new file mode 100644 index 00000000..f414efe1 --- /dev/null +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateSection.tsx @@ -0,0 +1,150 @@ +import React from 'react'; +import { Button, Flex } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import { useTranslation } from 'react-i18next'; +import { nanoid } from '@reduxjs/toolkit'; + +import { useAppSelector } from '@/hooks/useAppSelector'; +import { themeWiseColor } from '@/utils/themeWiseColor'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { IGroupBy, fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { statusApiService } from '@/api/taskAttributes/status/status.api.service'; +import { createStatus, fetchStatuses } from '@/features/taskAttributes/taskStatusSlice'; +import { ALPHA_CHANNEL } from '@/shared/constants'; +import logger from '@/utils/errorLogger'; +import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service'; +import { useAuthService } from '@/hooks/useAuth'; +import useIsProjectManager from '@/hooks/useIsProjectManager'; + +const EnhancedKanbanCreateSection: React.FC = () => { + const { t } = useTranslation('kanban-board'); + + const themeMode = useAppSelector((state) => state.themeReducer.mode); + const { projectId } = useAppSelector((state) => state.projectReducer); + const groupBy = useAppSelector((state) => state.enhancedKanbanReducer.groupBy); + const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer); + + const dispatch = useAppDispatch(); + const isOwnerorAdmin = useAuthService().isOwnerOrAdmin(); + const isProjectManager = useIsProjectManager(); + + // Don't show for priority grouping or if user doesn't have permissions + if (groupBy === IGroupBy.PRIORITY || (!isOwnerorAdmin && !isProjectManager)) { + return null; + } + + const getUniqueSectionName = (baseName: string): string => { + // Check if the base name already exists + const existingNames = existingStatuses.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 handleAddSection = async () => { + const sectionId = nanoid(); + const baseNameSection = 'Untitled section'; + const sectionName = getUniqueSectionName(baseNameSection); + + if (groupBy === IGroupBy.STATUS && projectId) { + // Find the "To do" category + const todoCategory = statusCategories.find(category => + category.name?.toLowerCase() === 'to do' || + category.name?.toLowerCase() === 'todo' + ); + + if (todoCategory && todoCategory.id) { + // Create a new status + const body = { + name: sectionName, + project_id: projectId, + category_id: todoCategory.id, + }; + + try { + // Create the status + const response = await dispatch(createStatus({ body, currentProjectId: projectId })).unwrap(); + + if (response.done && response.body) { + // Refresh the board to show the new section + dispatch(fetchEnhancedKanbanGroups(projectId)); + // Refresh statuses + dispatch(fetchStatuses(projectId)); + } + } catch (error) { + logger.error('Failed to create status:', error); + } + } + } + + if (groupBy === IGroupBy.PHASE && projectId) { + const body = { + name: sectionName, + project_id: projectId, + }; + + try { + const response = await phasesApiService.addPhaseOption(projectId); + if (response.done && response.body) { + dispatch(fetchEnhancedKanbanGroups(projectId)); + } + } catch (error) { + logger.error('Failed to create phase:', error); + } + } + }; + + return ( + +
+ +
+
+ ); +}; + +export default React.memo(EnhancedKanbanCreateSection); \ No newline at end of file diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateTaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateTaskCard.tsx new file mode 100644 index 00000000..312361dc --- /dev/null +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanCreateTaskCard.tsx @@ -0,0 +1,134 @@ +import React, { useRef, useState, useEffect } from 'react'; +import { Button, Flex, Input, InputRef } from 'antd'; +import { useTranslation } from 'react-i18next'; +import { nanoid } from '@reduxjs/toolkit'; +import { useAppDispatch } from '@/hooks/useAppDispatch'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import { themeWiseColor } from '@/utils/themeWiseColor'; +import { useSocket } from '@/socket/socketContext'; +import { SocketEvents } from '@/shared/socket-events'; +import { useAuthService } from '@/hooks/useAuth'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { ITaskCreateRequest } from '@/types/tasks/task-create-request'; +import { addTaskToGroup } from '@/features/enhanced-kanban/enhanced-kanban.slice'; + +interface EnhancedKanbanCreateTaskCardProps { + sectionId: string; + setShowNewCard: (x: boolean) => void; + position?: 'top' | 'bottom'; +} + +const EnhancedKanbanCreateTaskCard: React.FC = ({ + sectionId, + setShowNewCard, + position = 'bottom', +}) => { + const { t } = useTranslation('kanban-board'); + const dispatch = useAppDispatch(); + const { socket } = useSocket(); + const currentSession = useAuthService().getCurrentSession(); + + const [newTaskName, setNewTaskName] = useState(''); + const [creatingTask, setCreatingTask] = useState(false); + const cardRef = useRef(null); + const inputRef = useRef(null); + + const themeMode = useAppSelector(state => state.themeReducer.mode); + const projectId = useAppSelector(state => state.projectReducer.projectId); + const groupBy = useAppSelector(state => state.enhancedKanbanReducer.groupBy); + + useEffect(() => { + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + }, []); + + const createRequestBody = (): ITaskCreateRequest | null => { + if (!projectId || !currentSession) return null; + const body: ITaskCreateRequest = { + project_id: projectId, + name: newTaskName.trim(), + reporter_id: currentSession.id, + team_id: currentSession.team_id, + }; + if (groupBy === 'status') body.status_id = sectionId; + else if (groupBy === 'priority') body.priority_id = sectionId; + else if (groupBy === 'phase') body.phase_id = sectionId; + return body; + }; + + const resetForm = () => { + setNewTaskName(''); + setCreatingTask(false); + setShowNewCard(false); + setTimeout(() => { + inputRef.current?.focus(); + }, 100); + }; + + const handleAddTask = async () => { + if (creatingTask || !projectId || !currentSession || newTaskName.trim() === '') return; + setCreatingTask(true); + const body = createRequestBody(); + if (!body) return; + + // Real-time socket event handler + const eventHandler = (task: IProjectTask) => { + setCreatingTask(false); + dispatch(addTaskToGroup({ sectionId, task: { ...task, id: task.id || nanoid(), name: task.name || newTaskName.trim() } })); + socket?.off(SocketEvents.QUICK_TASK.toString(), eventHandler); + resetForm(); + }; + socket?.once(SocketEvents.QUICK_TASK.toString(), eventHandler); + socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body)); + }; + + const handleCancel = () => { + setNewTaskName(''); + setShowNewCard(false); + setCreatingTask(false); + }; + + return ( + + setNewTaskName(e.target.value)} + onPressEnter={handleAddTask} + placeholder={t('newTaskNamePlaceholder')} + style={{ + width: '100%', + borderRadius: 6, + padding: 8, + }} + disabled={creatingTask} + /> + {newTaskName.trim() && ( + + + + + )} + + ); +}; + +export default EnhancedKanbanCreateTaskCard; \ No newline at end of file diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanGroup.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanGroup.tsx index 399e9f77..4dcca867 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanGroup.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanGroup.tsx @@ -19,7 +19,6 @@ import { Typography } from 'antd'; import { Dropdown } from 'antd'; import { Button } from 'antd'; import { PlusOutlined } from '@ant-design/icons/lib/icons'; -import { deleteSection, IGroupBy, setBoardGroupName } from '@/features/board/board-slice'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { useTranslation } from 'react-i18next'; import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types'; @@ -31,6 +30,9 @@ 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'; +import EnhancedKanbanCreateTaskCard from './EnhancedKanbanCreateTaskCard'; + interface EnhancedKanbanGroupProps { group: ITaskListGroup; activeTaskId?: string | null; @@ -57,10 +59,11 @@ const EnhancedKanbanGroup: React.FC = React.memo(({ const themeMode = useAppSelector(state => state.themeReducer.mode); const dispatch = useAppDispatch(); const { projectId } = useAppSelector(state => state.projectReducer); - const { groupBy } = useAppSelector(state => state.boardReducer); + const { groupBy } = useAppSelector(state => state.enhancedKanbanReducer); const { statusCategories, status } = useAppSelector(state => state.taskStatusReducer); const { trackMixpanelEvent } = useMixpanelTracking(); - const [showNewCard, setShowNewCard] = useState(false); + const [showNewCardTop, setShowNewCardTop] = useState(false); + const [showNewCardBottom, setShowNewCardBottom] = useState(false); const { t } = useTranslation('kanban-board'); const { setNodeRef: setDroppableRef, isOver } = useDroppable({ @@ -167,15 +170,7 @@ const EnhancedKanbanGroup: React.FC = React.memo(({ }; const res = await statusApiService.updateStatus(group.id, body, projectId); if (res.done) { - dispatch( - setBoardGroupName({ - groupId: group.id, - name: sectionName ?? '', - colorCode: res.body.color_code ?? '', - colorCodeDark: res.body.color_code_dark ?? '', - categoryId: category, - }) - ); + dispatch(fetchEnhancedKanbanGroups(projectId)); dispatch(fetchStatuses(projectId)); setName(sectionName); } else { @@ -198,8 +193,8 @@ const EnhancedKanbanGroup: React.FC = React.memo(({ }; const handleBlur = async () => { - if (group.name === 'Untitled section') { - dispatch(deleteSection({ sectionId: group.id })); + if (name === 'Untitled section') { + dispatch(fetchEnhancedKanbanGroups(projectId ?? '')); } setIsEditable(false); @@ -218,14 +213,14 @@ const EnhancedKanbanGroup: React.FC = React.memo(({ const res = await phasesApiService.updateNameOfPhase(group.id, body as ITaskPhase, projectId); if (res.done) { trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' }); - // dispatch(fetchPhasesByProjectId(projectId)); + dispatch(fetchEnhancedKanbanGroups(projectId)); } } }; const handlePressEnter = () => { - setShowNewCard(true); - setIsEditable(false); + setShowNewCardTop(true); + setShowNewCardBottom(false); handleBlur(); }; const handleDeleteSection = async () => { @@ -237,7 +232,7 @@ const EnhancedKanbanGroup: React.FC = React.memo(({ 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(deleteSection({ sectionId: group.id })); + dispatch(fetchEnhancedKanbanGroups(projectId)); } else { dispatch(seletedStatusCategory({ id: group.id, name: name, category_id: group.category_id ?? '', message: res.message ?? '' })); dispatch(deleteStatusToggleDrawer()); @@ -245,7 +240,7 @@ const EnhancedKanbanGroup: React.FC = React.memo(({ } else if (groupBy === IGroupBy.PHASE) { const res = await phasesApiService.deletePhaseOption(group.id, projectId); if (res.done) { - dispatch(deleteSection({ sectionId: group.id })); + dispatch(fetchEnhancedKanbanGroups(projectId)); } } } catch (error) { @@ -327,6 +322,9 @@ const EnhancedKanbanGroup: React.FC = React.memo(({ style={{ fontWeight: 600, borderRadius: 6, + width: '100%', + alignItems: 'center', + justifyContent: 'space-between', }} onMouseEnter={() => setIsHover(true)} onMouseLeave={() => setIsHover(false)} @@ -335,9 +333,13 @@ const EnhancedKanbanGroup: React.FC = React.memo(({ gap={6} align="center" style={{ cursor: 'pointer' }} - onClick={() => { + onClick={(e) => { + e.stopPropagation(); if ((isProjectManager || isOwnerOrAdmin) && group.name !== 'Unmapped') setIsEditable(true); }} + onMouseDown={(e) => { + e.stopPropagation(); + }} > = React.memo(({ {isEditable ? ( = React.memo(({ onChange={handleChange} onBlur={handleBlur} onPressEnter={handlePressEnter} + onMouseDown={(e) => { + e.stopPropagation(); + }} + onKeyDown={(e) => { + e.stopPropagation(); + }} + onClick={(e) => { + e.stopPropagation(); + }} /> ) : ( @@ -378,6 +389,17 @@ const EnhancedKanbanGroup: React.FC = React.memo(({ color: themeMode === 'dark' ? '#383838' : '', display: 'inline-block', overflow: 'hidden', + userSelect: 'text', + }} + onMouseDown={(e) => { + e.stopPropagation(); + e.preventDefault(); + }} + onMouseUp={(e) => { + e.stopPropagation(); + }} + onClick={(e) => { + e.stopPropagation(); }} > {name} @@ -392,7 +414,10 @@ const EnhancedKanbanGroup: React.FC = React.memo(({ size="small" shape="circle" style={{ color: themeMode === 'dark' ? '#383838' : '' }} - onClick={() => setShowNewCard(true)} + onClick={() => { + setShowNewCardTop(true); + setShowNewCardBottom(false); + }} > @@ -427,6 +452,10 @@ const EnhancedKanbanGroup: React.FC = React.memo(({
+ {/* Create card at top */} + {showNewCardTop && (isOwnerOrAdmin || isProjectManager) && ( + + )} {group.tasks.length === 0 && isDraggingOver && (
Drop here
@@ -475,6 +504,30 @@ const EnhancedKanbanGroup: React.FC = React.memo(({ ))} )} + {/* Create card at bottom */} + {showNewCardBottom && (isOwnerOrAdmin || isProjectManager) && ( + + )} + {/* Footer Add Task Button */} + {(isOwnerOrAdmin || isProjectManager) && !showNewCardTop && !showNewCardBottom && ( + + )}
); 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 706e2d6d..fe9b1479 100644 --- a/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts +++ b/worklenz-frontend/src/features/enhanced-kanban/enhanced-kanban.slice.ts @@ -440,6 +440,14 @@ const enhancedKanbanSlice = createSlice({ }, {} as Record); state.columnOrder = reorderedGroups.map(group => group.id); }, + + addTaskToGroup: (state, action) => { + const { sectionId, task } = action.payload; + const group = state.taskGroups.find(g => g.id === sectionId); + if (group) { + group.tasks.push(task); + } + }, }, extraReducers: (builder) => { builder @@ -528,6 +536,7 @@ export const { resetState, reorderTasks, reorderGroups, + addTaskToGroup, } = enhancedKanbanSlice.actions; export default enhancedKanbanSlice.reducer; \ No newline at end of file