diff --git a/worklenz-frontend/public/locales/alb/kanban-board.json b/worklenz-frontend/public/locales/alb/kanban-board.json index c7306620..def705aa 100644 --- a/worklenz-frontend/public/locales/alb/kanban-board.json +++ b/worklenz-frontend/public/locales/alb/kanban-board.json @@ -21,5 +21,10 @@ "newTaskNamePlaceholder": "Shkruaj emrin e detyrës", "newSubtaskNamePlaceholder": "Shkruaj emrin e nëndetyrës", "untitledSection": "Seksion pa titull", - "unmapped": "Pa hartë" + "unmapped": "Pa hartë", + "clickToChangeDate": "Klikoni për të ndryshuar datën", + "noDueDate": "Pa datë përfundimi", + "save": "Ruaj", + "clear": "Pastro", + "nextWeek": "Javën e ardhshme" } diff --git a/worklenz-frontend/public/locales/de/kanban-board.json b/worklenz-frontend/public/locales/de/kanban-board.json index 3c8e83ae..70e1f6ca 100644 --- a/worklenz-frontend/public/locales/de/kanban-board.json +++ b/worklenz-frontend/public/locales/de/kanban-board.json @@ -21,5 +21,10 @@ "newTaskNamePlaceholder": "Aufgabenname eingeben", "newSubtaskNamePlaceholder": "Unteraufgabenname eingeben", "untitledSection": "Unbenannter Abschnitt", - "unmapped": "Nicht zugeordnet" + "unmapped": "Nicht zugeordnet", + "clickToChangeDate": "Klicken Sie, um das Datum zu ändern", + "noDueDate": "Kein Fälligkeitsdatum", + "save": "Speichern", + "clear": "Löschen", + "nextWeek": "Nächste Woche" } diff --git a/worklenz-frontend/public/locales/en/kanban-board.json b/worklenz-frontend/public/locales/en/kanban-board.json index 20db06d0..bc9b372a 100644 --- a/worklenz-frontend/public/locales/en/kanban-board.json +++ b/worklenz-frontend/public/locales/en/kanban-board.json @@ -21,5 +21,10 @@ "newTaskNamePlaceholder": "Write a task Name", "newSubtaskNamePlaceholder": "Write a subtask Name", "untitledSection": "Untitled section", - "unmapped": "Unmapped" + "unmapped": "Unmapped", + "clickToChangeDate": "Click to change date", + "noDueDate": "No due date", + "save": "Save", + "clear": "Clear", + "nextWeek": "Next week" } diff --git a/worklenz-frontend/public/locales/es/kanban-board.json b/worklenz-frontend/public/locales/es/kanban-board.json index b143d55f..6e8d5975 100644 --- a/worklenz-frontend/public/locales/es/kanban-board.json +++ b/worklenz-frontend/public/locales/es/kanban-board.json @@ -21,5 +21,10 @@ "newTaskNamePlaceholder": "Escribe un nombre de tarea", "newSubtaskNamePlaceholder": "Escribe un nombre de subtarea", "untitledSection": "Sección sin título", - "unmapped": "Sin asignar" + "unmapped": "Sin asignar", + "clickToChangeDate": "Haz clic para cambiar la fecha", + "noDueDate": "Sin fecha de vencimiento", + "save": "Guardar", + "clear": "Limpiar", + "nextWeek": "Próxima semana" } diff --git a/worklenz-frontend/public/locales/pt/kanban-board.json b/worklenz-frontend/public/locales/pt/kanban-board.json index cb5df9e1..a2034daa 100644 --- a/worklenz-frontend/public/locales/pt/kanban-board.json +++ b/worklenz-frontend/public/locales/pt/kanban-board.json @@ -21,5 +21,10 @@ "newTaskNamePlaceholder": "Escreva um nome de tarefa", "newSubtaskNamePlaceholder": "Escreva um nome de subtarefa", "untitledSection": "Seção sem título", - "unmapped": "Não mapeado" + "unmapped": "Não mapeado", + "clickToChangeDate": "Clique para alterar a data", + "noDueDate": "Sem data de vencimento", + "save": "Salvar", + "clear": "Limpar", + "nextWeek": "Próxima semana" } diff --git a/worklenz-frontend/src/components/AssigneeSelector.tsx b/worklenz-frontend/src/components/AssigneeSelector.tsx index 1ca0cdf8..d0467552 100644 --- a/worklenz-frontend/src/components/AssigneeSelector.tsx +++ b/worklenz-frontend/src/components/AssigneeSelector.tsx @@ -22,12 +22,14 @@ interface AssigneeSelectorProps { task: IProjectTask; groupId?: string | null; isDarkMode?: boolean; + kanbanMode?: boolean; // <-- Add this prop } const AssigneeSelector: React.FC = ({ task, groupId = null, isDarkMode = false, + kanbanMode = false, // <-- Default to false }) => { const [isOpen, setIsOpen] = useState(false); const [searchQuery, setSearchQuery] = useState(''); @@ -212,6 +214,9 @@ const AssigneeSelector: React.FC = ({ assigneeIds: newAssigneeIds, assigneeNames: updatedAssigneeNames, })); + if (kanbanMode) { + dispatch(updateEnhancedKanbanTaskAssignees(data)); + } }); // Remove from pending changes after a short delay (optimistic) diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx index f9600aa8..e8068e07 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx @@ -216,18 +216,17 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project toSortOrder = -1; toLastIndex = true; } else if (targetGroup.tasks[insertIdx]) { - toSortOrder = typeof targetGroup.tasks[insertIdx].sort_order === 'number' - ? targetGroup.tasks[insertIdx].sort_order - : -1; + 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 : -1; + toSortOrder = typeof lastSortOrder === 'number' ? lastSortOrder : 0; toLastIndex = false; } socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { project_id: projectId, - from_index: movedTask.sort_order, + from_index: movedTask.sort_order ?? 0, to_index: toSortOrder, to_last_index: toLastIndex, from_group: sourceGroup.id, diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx index 237cd80b..3a538463 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx @@ -1,4 +1,4 @@ -import React, { memo, useCallback } from 'react'; +import React, { memo, useCallback, useState, useRef, useEffect } from 'react'; import { useSelector } from 'react-redux'; import { RootState } from '@/app/store'; import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; @@ -8,6 +8,19 @@ import { useTranslation } from 'react-i18next'; import AvatarGroup from '@/components/AvatarGroup'; import LazyAssigneeSelectorWrapper from '@/components/task-management/lazy-assignee-selector'; import { format } from 'date-fns'; +import logger from '@/utils/errorLogger'; +import { createPortal } from 'react-dom'; +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'; + +// Simple Portal component +const Portal: React.FC<{ children: React.ReactNode }> = ({ children }) => { + const portalRoot = document.getElementById('portal-root') || document.body; + return createPortal(children, portalRoot); +}; interface TaskCardProps { task: IProjectTask; @@ -19,6 +32,14 @@ interface TaskCardProps { idx: number; } +function getDaysInMonth(year: number, month: number) { + return new Date(year, month + 1, 0).getDate(); +} + +function getFirstDayOfWeek(year: number, month: number) { + return new Date(year, month, 1).getDay(); +} + const TaskCard: React.FC = memo(({ task, onTaskDragStart, @@ -28,19 +49,156 @@ const TaskCard: React.FC = memo(({ isDropIndicator, idx }) => { + const { socket } = useSocket(); const themeMode = useSelector((state: RootState) => state.themeReducer.mode); + const { projectId } = useSelector((state: RootState) => state.projectReducer); const background = themeMode === 'dark' ? '#23272f' : '#fff'; const color = themeMode === 'dark' ? '#fff' : '#23272f'; const dispatch = useAppDispatch(); const { t } = useTranslation('kanban-board'); + const [showDatePicker, setShowDatePicker] = useState(false); + const [selectedDate, setSelectedDate] = useState( + task.end_date ? new Date(task.end_date) : null + ); + const [isUpdating, setIsUpdating] = useState(false); + const datePickerRef = useRef(null); + const dateButtonRef = useRef(null); + const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null); + const [calendarMonth, setCalendarMonth] = useState(() => { + const d = selectedDate || new Date(); + return new Date(d.getFullYear(), d.getMonth(), 1); + }); + const [showSubtasks, setShowSubtasks] = useState(false); + + useEffect(() => { + setSelectedDate(task.end_date ? new Date(task.end_date) : null); + }, [task.end_date]); + + // Close date picker when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (datePickerRef.current && !datePickerRef.current.contains(event.target as Node)) { + setShowDatePicker(false); + } + }; + + if (showDatePicker) { + document.addEventListener('mousedown', handleClickOutside); + } + + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, [showDatePicker]); + + useEffect(() => { + if (showDatePicker && dateButtonRef.current) { + const rect = dateButtonRef.current.getBoundingClientRect(); + setDropdownPosition({ + top: rect.bottom + window.scrollY, + left: rect.left + window.scrollX, + }); + } + }, [showDatePicker]); + const handleCardClick = useCallback((e: React.MouseEvent, id: string) => { - // Prevent the event from propagating to parent elements e.stopPropagation(); dispatch(setSelectedTaskId(id)); dispatch(setShowTaskDrawer(true)); }, [dispatch]); + const handleDateClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + setShowDatePicker(true); + }, []); + + const handleDateChange = useCallback( + (date: Date | null) => { + if (!task.id || !projectId) return; + setIsUpdating(true); + try { + setSelectedDate(date); + socket?.emit( + SocketEvents.TASK_END_DATE_CHANGE.toString(), + JSON.stringify({ + task_id: task.id, + end_date: date, + parent_task: task.parent_task_id, + time_zone: getUserSession()?.timezone_name + ? getUserSession()?.timezone_name + : Intl.DateTimeFormat().resolvedOptions().timeZone, + }) + ); + } catch (error) { + logger.error('Failed to update due date:', error); + } finally { + setIsUpdating(false); + setShowDatePicker(false); + } + }, + [task.id, projectId, socket] + ); + + const handleClearDate = useCallback(() => { + handleDateChange(null); + }, [handleDateChange]); + + const handleToday = useCallback(() => { + handleDateChange(new Date()); + }, [handleDateChange]); + + const handleTomorrow = useCallback(() => { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + handleDateChange(tomorrow); + }, [handleDateChange]); + + const handleNextWeek = useCallback(() => { + const nextWeek = new Date(); + nextWeek.setDate(nextWeek.getDate() + 7); + handleDateChange(nextWeek); + }, [handleDateChange]); + + const handleSubTaskExpand = useCallback(() => { + if (task && task.id && projectId) { + if (task.sub_tasks && task.sub_tasks.length > 0 && task.sub_tasks_count && task.sub_tasks_count > 0) { + dispatch(toggleTaskExpansion(task.id)); + } else if (task.sub_tasks_count && task.sub_tasks_count > 0) { + dispatch(toggleTaskExpansion(task.id)); + dispatch(fetchBoardSubTasks({ taskId: task.id, projectId })); + } else { + dispatch(toggleTaskExpansion(task.id)); + } + } + }, [task, projectId, dispatch]); + + const handleSubtaskButtonClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + handleSubTaskExpand(); + }, [handleSubTaskExpand]); + + // Calendar rendering helpers + const year = calendarMonth.getFullYear(); + const month = calendarMonth.getMonth(); + const daysInMonth = getDaysInMonth(year, month); + const firstDayOfWeek = (getFirstDayOfWeek(year, month) + 6) % 7; // Make Monday first + const today = new Date(); + + const weeks: (Date | null)[][] = []; + let week: (Date | null)[] = Array(firstDayOfWeek).fill(null); + for (let day = 1; day <= daysInMonth; day++) { + week.push(new Date(year, month, day)); + if (week.length === 7) { + weeks.push(week); + week = []; + } + } + if (week.length > 0) { + while (week.length < 7) week.push(null); + weeks.push(week); + } + return ( <> {isDropIndicator && ( @@ -56,60 +214,262 @@ const TaskCard: React.FC = memo(({ onDrop={e => onTaskDrop(e, groupId, idx)} /> )} -
onTaskDragStart(e, task.id!, groupId)} - onDragOver={e => onTaskDragOver(e, groupId, idx)} - onDrop={e => onTaskDrop(e, groupId, idx)} - style={{ background, color }} - onClick={e => handleCardClick(e, task.id!)} - > -
-
- {task.labels?.map(label => ( -
- {label.name} -
- ))} -
-
-
-
{task.name}
-
+
+
onTaskDragStart(e, task.id!, groupId)} + onDragOver={e => onTaskDragOver(e, groupId, idx)} + onDrop={e => onTaskDrop(e, groupId, idx)} -
-
- {task.end_date ? format(new Date(task.end_date), 'MMM d, yyyy') : ''} + onClick={e => handleCardClick(e, task.id!)} + > +
+
+ {task.labels?.map(label => ( +
+ {label.name} +
+ ))}
-
- - +
+ +
{task.name}
+
+ +
+
+
+ {isUpdating ? ( +
+ ) : ( + selectedDate ? format(selectedDate, 'MMM d, yyyy') : t('noDueDate') + )} +
+ {/* Custom Calendar Popup */} + {showDatePicker && dropdownPosition && ( + +
e.stopPropagation()} + > +
+ + + {calendarMonth.toLocaleString('default', { month: 'long' })} {year} + + +
+
+ {['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => ( +
{d}
+ ))} + {weeks.map((week, i) => ( + + {week.map((date, j) => { + const isSelected = date && selectedDate && date.toDateString() === selectedDate.toDateString(); + const isToday = date && date.toDateString() === today.toDateString(); + return ( + + ); + })} + + ))} +
+
+ + +
+
+ + +
+
+
+ )} +
+
+ + + {(task.sub_tasks_count ?? 0) > 0 && ( + + )} +
+ {task.show_sub_tasks && ( +
+ {/* Loading state */} + {task.sub_tasks_loading && ( +
Loading...
+ )} + {/* Loaded subtasks */} + {!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"> + {sub.priority_color || sub.priority_color_dark ? ( + + ) : null} + {sub.name} + + {sub.end_date ? format(new Date(sub.end_date), 'MMM d, yyyy') : ''} + + {sub.names && sub.names.length > 0 && ( + + )} +
  • + ))} +
+ )} + {/* Empty state */} + {!task.sub_tasks_loading && (!Array.isArray(task.sub_tasks) || task.sub_tasks.length === 0) && ( +
{t('noSubtasks', 'No subtasks')}
+ )} +
+ )}
); diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.tsx index 8b483107..8ced062a 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanTaskCard.tsx @@ -182,11 +182,7 @@ const EnhancedKanbanTaskCard: React.FC = React.memo isDarkMode={themeMode === 'dark'} size={24} /> - + diff --git a/worklenz-frontend/src/components/task-management/lazy-assignee-selector.tsx b/worklenz-frontend/src/components/task-management/lazy-assignee-selector.tsx index 93266340..aad9af15 100644 --- a/worklenz-frontend/src/components/task-management/lazy-assignee-selector.tsx +++ b/worklenz-frontend/src/components/task-management/lazy-assignee-selector.tsx @@ -11,6 +11,7 @@ interface LazyAssigneeSelectorProps { task: IProjectTask; groupId?: string | null; isDarkMode?: boolean; + kanbanMode?: boolean; // <-- Add this prop } // Lightweight loading placeholder @@ -34,6 +35,7 @@ const LazyAssigneeSelectorWrapper: React.FC = ({ task, groupId = null, isDarkMode = false, + kanbanMode = false, // <-- Default to false }) => { const [hasLoadedOnce, setHasLoadedOnce] = useState(false); const [showComponent, setShowComponent] = useState(false); @@ -74,7 +76,7 @@ const LazyAssigneeSelectorWrapper: React.FC = ({ // Once loaded, show the full component return ( }> - + ); };