Merge branch 'release-v2.1.4' of https://github.com/Worklenz/worklenz into feature/team-utilization

This commit is contained in:
chamiakJ
2025-07-31 06:57:50 +05:30
14 changed files with 858 additions and 808 deletions

View File

@@ -1,5 +1,4 @@
import React, { useMemo, useCallback, useState } from 'react'; import React, { useMemo, useCallback, useState } from 'react';
import { useDroppable } from '@dnd-kit/core';
// @ts-ignore: Heroicons module types // @ts-ignore: Heroicons module types
import { import {
ChevronDownIcon, ChevronDownIcon,
@@ -382,24 +381,12 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({
t, t,
]); ]);
// Make the group header droppable
const { isOver, setNodeRef } = useDroppable({
id: group.id,
data: {
type: 'group',
group,
},
});
return ( return (
<div className="relative flex items-center"> <div className="relative flex items-center">
<div <div
ref={setNodeRef} className="inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2"
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-t border-b border-gray-200 dark:border-gray-700 rounded-t-md pr-2 ${
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
}`}
style={{ style={{
backgroundColor: isOver ? `${headerBackgroundColor}dd` : headerBackgroundColor, backgroundColor: headerBackgroundColor,
color: headerTextColor, color: headerTextColor,
position: 'sticky', position: 'sticky',
top: 0, top: 0,

View File

@@ -9,8 +9,9 @@ import {
KeyboardSensor, KeyboardSensor,
TouchSensor, TouchSensor,
closestCenter, closestCenter,
useDroppable, Modifier,
} from '@dnd-kit/core'; } from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { import {
SortableContext, SortableContext,
verticalListSortingStrategy, verticalListSortingStrategy,
@@ -67,109 +68,132 @@ import TaskListSkeleton from './components/TaskListSkeleton';
import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer'; import ConvertToSubtaskDrawer from '@/components/task-list-common/convert-to-subtask-drawer/convert-to-subtask-drawer';
import EmptyListPlaceholder from '@/components/EmptyListPlaceholder'; import EmptyListPlaceholder from '@/components/EmptyListPlaceholder';
// Empty Group Drop Zone Component // Drop Spacer Component - creates space between tasks when dragging
const EmptyGroupDropZone: React.FC<{ const DropSpacer: React.FC<{ isVisible: boolean; visibleColumns: any[]; isDarkMode?: boolean }> = ({
groupId: string; isVisible,
visibleColumns: any[]; visibleColumns,
t: (key: string) => string; isDarkMode = false
}> = ({ groupId, visibleColumns, t }) => { }) => {
const { setNodeRef, isOver, active } = useDroppable({
id: `empty-group-${groupId}`,
data: {
type: 'group',
groupId: groupId,
isEmpty: true,
},
});
return (
<div
ref={setNodeRef}
className={`relative w-full transition-colors duration-200 ${
isOver && active ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
>
<div
className="flex items-center min-w-max px-1 border-t border-b border-gray-200 dark:border-gray-700"
style={{ height: '40px' }}
>
{visibleColumns.map((column, index) => {
const emptyColumnStyle = {
width: column.width,
flexShrink: 0,
};
// Show text in the title column
if (column.id === 'title') {
return (
<div
key={`empty-${column.id}`}
className="flex items-center pl-1"
style={emptyColumnStyle}
>
<span className="text-sm text-gray-500 dark:text-gray-400">
No tasks in this group
</span>
</div>
);
}
return (
<div
key={`empty-${column.id}`}
className="border-r border-gray-200 dark:border-gray-700"
style={emptyColumnStyle}
/>
);
})}
</div>
{isOver && active && (
<div className="absolute inset-0 border-2 border-dashed border-blue-400 dark:border-blue-500 rounded-md pointer-events-none" />
)}
</div>
);
};
// Placeholder Drop Indicator Component
const PlaceholderDropIndicator: React.FC<{
isVisible: boolean;
visibleColumns: any[];
}> = ({ isVisible, visibleColumns }) => {
if (!isVisible) return null; if (!isVisible) return null;
return ( return (
<div <div
className="flex items-center min-w-max px-1 border-2 border-dashed border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 rounded-md mx-1 my-1 transition-all duration-200 ease-in-out" className="flex items-center min-w-max px-1 border-2 border-dashed border-blue-400 dark:border-blue-500 bg-blue-50 dark:bg-blue-900/20 transition-all duration-200 ease-in-out"
style={{ minWidth: 'max-content', height: '40px' }} style={{
height: isVisible ? '40px' : '0px',
opacity: isVisible ? 1 : 0,
marginTop: isVisible ? '2px' : '0px',
marginBottom: isVisible ? '2px' : '0px',
overflow: 'hidden',
}}
> >
{visibleColumns.map((column, index) => { {visibleColumns.map((column, index) => {
// Calculate left position for sticky columns
let leftPosition = 0;
if (column.isSticky) {
for (let i = 0; i < index; i++) {
const prevColumn = visibleColumns[i];
if (prevColumn.isSticky) {
leftPosition += parseInt(prevColumn.width.replace('px', ''));
}
}
}
const columnStyle = { const columnStyle = {
width: column.width, width: column.width,
flexShrink: 0, flexShrink: 0,
...(column.isSticky && {
position: 'sticky' as const,
left: leftPosition,
zIndex: 10,
backgroundColor: 'inherit', // Inherit from parent spacer
}),
}; };
if (column.id === 'title') {
return (
<div
key={`spacer-${column.id}`}
className="flex items-center pl-1 border-r border-blue-300 dark:border-blue-600"
style={columnStyle}
>
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
Drop here
</span>
</div>
);
}
return ( return (
<div <div
key={`placeholder-${column.id}`} key={`spacer-${column.id}`}
className="flex items-center justify-center h-full" className="border-r border-blue-300 dark:border-blue-600"
style={columnStyle} style={columnStyle}
> />
{/* Show "Drop task here" message in the title column */}
{column.id === 'title' && (
<div className="text-xs text-blue-600 dark:text-blue-400 font-medium opacity-75">
Drop task here
</div>
)}
{/* Show subtle placeholder content in other columns */}
{column.id !== 'title' && column.id !== 'dragHandle' && (
<div className="w-full h-4 mx-1 bg-white dark:bg-gray-700 rounded opacity-50" />
)}
</div>
); );
})} })}
</div> </div>
); );
}; };
// Empty Group Message Component
const EmptyGroupMessage: React.FC<{ visibleColumns: any[]; isDarkMode?: boolean }> = ({
visibleColumns,
isDarkMode = false
}) => {
return (
<div className="flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700" style={{ height: '40px' }}>
{visibleColumns.map((column, index) => {
// Calculate left position for sticky columns
let leftPosition = 0;
if (column.isSticky) {
for (let i = 0; i < index; i++) {
const prevColumn = visibleColumns[i];
if (prevColumn.isSticky) {
leftPosition += parseInt(prevColumn.width.replace('px', ''));
}
}
}
const emptyColumnStyle = {
width: column.width,
flexShrink: 0,
...(column.isSticky && {
position: 'sticky' as const,
left: leftPosition,
zIndex: 10,
backgroundColor: 'inherit', // Inherit from parent container
}),
};
// Show text in the title column
if (column.id === 'title') {
return (
<div
key={`empty-${column.id}`}
className="flex items-center pl-1 border-r border-gray-200 dark:border-gray-700"
style={emptyColumnStyle}
>
<span className="text-sm text-gray-500 dark:text-gray-400 italic">
No tasks in this group
</span>
</div>
);
}
return (
<div
key={`empty-${column.id}`}
className="border-r border-gray-200 dark:border-gray-700"
style={emptyColumnStyle}
/>
);
})}
</div>
);
};
// Hooks and utilities // Hooks and utilities
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import { useSocket } from '@/socket/socketContext'; import { useSocket } from '@/socket/socketContext';
@@ -211,7 +235,7 @@ const TaskListV2Section: React.FC = () => {
// State hooks // State hooks
const [initializedFromDatabase, setInitializedFromDatabase] = useState(false); const [initializedFromDatabase, setInitializedFromDatabase] = useState(false);
const [addTaskRows, setAddTaskRows] = useState<{ [groupId: string]: string[] }>({}); const [addTaskRows, setAddTaskRows] = useState<{[groupId: string]: string[]}>({});
// Configure sensors for drag and drop // Configure sensors for drag and drop
const sensors = useSensors( const sensors = useSensors(
@@ -232,7 +256,7 @@ const TaskListV2Section: React.FC = () => {
); );
// Custom hooks // Custom hooks
const { activeId, overId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop( const { activeId, overId, dropPosition, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(
allTasks, allTasks,
groups groups
); );
@@ -453,17 +477,17 @@ const TaskListV2Section: React.FC = () => {
const handleTaskAdded = useCallback((rowId: string) => { const handleTaskAdded = useCallback((rowId: string) => {
// Task is now added in real-time via socket, no need to refetch // Task is now added in real-time via socket, no need to refetch
// The global socket handler will handle the real-time update // The global socket handler will handle the real-time update
// Find the group this row belongs to // Find the group this row belongs to
const groupId = rowId.split('-')[2]; // Extract from rowId format: add-task-{groupId}-{index} const groupId = rowId.split('-')[2]; // Extract from rowId format: add-task-{groupId}-{index}
// Add a new add task row to this group // Add a new add task row to this group
setAddTaskRows(prev => { setAddTaskRows(prev => {
const currentRows = prev[groupId] || []; const currentRows = prev[groupId] || [];
const newRowId = `add-task-${groupId}-${currentRows.length + 1}`; const newRowId = `add-task-${groupId}-${currentRows.length + 1}`;
return { return {
...prev, ...prev,
[groupId]: [...currentRows, newRowId], [groupId]: [...currentRows, newRowId]
}; };
}); });
}, []); }, []);
@@ -493,7 +517,7 @@ const TaskListV2Section: React.FC = () => {
// Get add task rows for this group // Get add task rows for this group
const groupAddRows = addTaskRows[group.id] || []; const groupAddRows = addTaskRows[group.id] || [];
const addTaskItems = !isCurrentGroupCollapsed const addTaskItems = !isCurrentGroupCollapsed
? [ ? [
// Default add task row // Default add task row
{ {
@@ -516,7 +540,7 @@ const TaskListV2Section: React.FC = () => {
projectId: urlProjectId, projectId: urlProjectId,
rowId: rowId, rowId: rowId,
autoFocus: index === groupAddRows.length - 1, // Auto-focus the latest row autoFocus: index === groupAddRows.length - 1, // Auto-focus the latest row
})), }))
] ]
: []; : [];
@@ -545,6 +569,7 @@ const TaskListV2Section: React.FC = () => {
return virtuosoGroups.flatMap(group => group.tasks); return virtuosoGroups.flatMap(group => group.tasks);
}, [virtuosoGroups]); }, [virtuosoGroups]);
// Render functions // Render functions
const renderGroup = useCallback( const renderGroup = useCallback(
(groupIndex: number) => { (groupIndex: number) => {
@@ -566,12 +591,12 @@ const TaskListV2Section: React.FC = () => {
projectId={urlProjectId || ''} projectId={urlProjectId || ''}
/> />
{isGroupEmpty && !isGroupCollapsed && ( {isGroupEmpty && !isGroupCollapsed && (
<EmptyGroupDropZone groupId={group.id} visibleColumns={visibleColumns} t={t} /> <EmptyGroupMessage visibleColumns={visibleColumns} isDarkMode={isDarkMode} />
)} )}
</div> </div>
); );
}, },
[virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t] [virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t, isDarkMode]
); );
const renderTask = useCallback( const renderTask = useCallback(
@@ -612,19 +637,40 @@ const TaskListV2Section: React.FC = () => {
const renderColumnHeaders = useCallback( const renderColumnHeaders = useCallback(
() => ( () => (
<div <div
className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700" className="border-b border-gray-200 dark:border-gray-700"
style={{ width: '100%', minWidth: 'max-content' }} style={{
width: '100%',
minWidth: 'max-content',
backgroundColor: isDarkMode ? '#141414' : '#f9fafb'
}}
> >
<div <div
className="flex items-center px-1 py-3 w-full" className="flex items-center px-1 py-3 w-full"
style={{ minWidth: 'max-content', height: '44px' }} style={{ minWidth: 'max-content', height: '44px' }}
> >
{visibleColumns.map((column, index) => { {visibleColumns.map((column, index) => {
// Calculate left position for sticky columns
let leftPosition = 0;
if (column.isSticky) {
for (let i = 0; i < index; i++) {
const prevColumn = visibleColumns[i];
if (prevColumn.isSticky) {
leftPosition += parseInt(prevColumn.width.replace('px', ''));
}
}
}
const columnStyle: ColumnStyle = { const columnStyle: ColumnStyle = {
width: column.width, width: column.width,
flexShrink: 0, flexShrink: 0,
...((column as any).minWidth && { minWidth: (column as any).minWidth }), ...((column as any).minWidth && { minWidth: (column as any).minWidth }),
...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }), ...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }),
...(column.isSticky && {
position: 'sticky' as const,
left: leftPosition,
zIndex: 15,
backgroundColor: isDarkMode ? '#141414' : '#f9fafb', // custom dark header : bg-gray-50
}),
}; };
return ( return (
@@ -701,9 +747,9 @@ const TaskListV2Section: React.FC = () => {
color: '#fbc84c69', color: '#fbc84c69',
actualCount: 0, actualCount: 0,
count: 1, // For the add task row count: 1, // For the add task row
startIndex: 0, startIndex: 0
}; };
return ( return (
<DndContext <DndContext
sensors={sensors} sensors={sensors}
@@ -738,7 +784,7 @@ const TaskListV2Section: React.FC = () => {
> >
{renderColumnHeaders()} {renderColumnHeaders()}
</div> </div>
<div style={{ minWidth: 'max-content' }}> <div style={{ minWidth: 'max-content' }}>
<div className="mt-2"> <div className="mt-2">
<TaskGroupHeader <TaskGroupHeader
@@ -770,7 +816,7 @@ const TaskListV2Section: React.FC = () => {
</DndContext> </DndContext>
); );
} }
// For other groupings, show the empty state message // For other groupings, show the empty state message
return ( return (
<div className="flex flex-col bg-white dark:bg-gray-900 h-full"> <div className="flex flex-col bg-white dark:bg-gray-900 h-full">
@@ -789,13 +835,25 @@ const TaskListV2Section: React.FC = () => {
} }
return ( return (
<DndContext <>
sensors={sensors} {/* CSS for sticky column hover effects */}
collisionDetection={closestCenter} <style>
onDragStart={handleDragStart} {`
onDragOver={handleDragOver} .hover\\:bg-gray-50:hover .sticky-column-hover,
onDragEnd={handleDragEnd} .dark .hover\\:bg-gray-800:hover .sticky-column-hover {
> background-color: var(--hover-bg) !important;
}
`}
</style>
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
modifiers={[restrictToVerticalAxis]}
>
<div className="flex flex-col bg-white dark:bg-gray-900 h-full overflow-hidden"> <div className="flex flex-col bg-white dark:bg-gray-900 h-full overflow-hidden">
{/* Table Container */} {/* Table Container */}
<div <div
@@ -839,63 +897,31 @@ const TaskListV2Section: React.FC = () => {
{renderGroup(groupIndex)} {renderGroup(groupIndex)}
{/* Group Tasks */} {/* Group Tasks */}
{!collapsedGroups.has(group.id) && {!collapsedGroups.has(group.id) && (
(group.tasks.length > 0 group.tasks.length > 0 ? (
? group.tasks.map((task, taskIndex) => { group.tasks.map((task, taskIndex) => {
const globalTaskIndex = const globalTaskIndex =
virtuosoGroups virtuosoGroups.slice(0, groupIndex).reduce((sum, g) => sum + g.count, 0) +
.slice(0, groupIndex) taskIndex;
.reduce((sum, g) => sum + g.count, 0) + taskIndex;
// Check if this is the first actual task in the group (not AddTaskRow) // Check if this is the first actual task in the group (not AddTaskRow)
const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task); const isFirstTaskInGroup = taskIndex === 0 && !('isAddTaskRow' in task);
// Check if we should show drop spacer
const isOverThisTask = activeId && overId === task.id && !('isAddTaskRow' in task);
const showDropSpacerBefore = isOverThisTask && dropPosition === 'before';
const showDropSpacerAfter = isOverThisTask && dropPosition === 'after';
// Check if we should show drop indicators return (
const isTaskBeingDraggedOver = overId === task.id; <div key={task.id || `add-task-${group.id}-${taskIndex}`}>
const isGroupBeingDraggedOver = overId === group.id; {showDropSpacerBefore && <DropSpacer isVisible={true} visibleColumns={visibleColumns} isDarkMode={isDarkMode} />}
const isFirstTaskInGroupBeingDraggedOver = {renderTask(globalTaskIndex, isFirstTaskInGroup)}
isFirstTaskInGroup && isTaskBeingDraggedOver; {showDropSpacerAfter && <DropSpacer isVisible={true} visibleColumns={visibleColumns} isDarkMode={isDarkMode} />}
</div>
return ( );
<div key={task.id || `add-task-${group.id}-${taskIndex}`}> })
{/* Placeholder drop indicator before first task in group */} ) : null
{isFirstTaskInGroupBeingDraggedOver && ( )}
<PlaceholderDropIndicator
isVisible={true}
visibleColumns={visibleColumns}
/>
)}
{/* Placeholder drop indicator between tasks */}
{isTaskBeingDraggedOver && !isFirstTaskInGroup && (
<PlaceholderDropIndicator
isVisible={true}
visibleColumns={visibleColumns}
/>
)}
{renderTask(globalTaskIndex, isFirstTaskInGroup)}
{/* Placeholder drop indicator at end of group when dragging over group */}
{isGroupBeingDraggedOver &&
taskIndex === group.tasks.length - 1 && (
<PlaceholderDropIndicator
isVisible={true}
visibleColumns={visibleColumns}
/>
)}
</div>
);
})
: // Handle empty groups with placeholder drop indicator
overId === group.id && (
<div style={{ minWidth: 'max-content' }}>
<PlaceholderDropIndicator
isVisible={true}
visibleColumns={visibleColumns}
/>
</div>
))}
</div> </div>
))} ))}
</div> </div>
@@ -904,15 +930,15 @@ const TaskListV2Section: React.FC = () => {
</div> </div>
{/* Drag Overlay */} {/* Drag Overlay */}
<DragOverlay dropAnimation={{ duration: 200, easing: 'ease-in-out' }}> <DragOverlay dropAnimation={{ duration: 200, easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)' }}>
{activeId ? ( {activeId ? (
<div <div
className="bg-white dark:bg-gray-800 shadow-lg rounded-md border border-blue-400 dark:border-blue-500 opacity-90" className="bg-white dark:bg-gray-800 shadow-2xl rounded-lg border-2 border-blue-500 dark:border-blue-400 opacity-95"
style={{ width: visibleColumns.find(col => col.id === 'title')?.width || '300px' }} style={{ width: visibleColumns.find(col => col.id === 'title')?.width || '300px' }}
> >
<div className="px-3 py-2"> <div className="px-4 py-3">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<HolderOutlined className="text-gray-400 dark:text-gray-500 text-xs" /> <HolderOutlined className="text-blue-500 dark:text-blue-400 text-sm" />
<div className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1"> <div className="text-sm font-medium text-gray-900 dark:text-white truncate flex-1">
{allTasks.find(task => task.id === activeId)?.name || {allTasks.find(task => task.id === activeId)?.name ||
allTasks.find(task => task.id === activeId)?.title || allTasks.find(task => task.id === activeId)?.title ||
@@ -959,12 +985,13 @@ const TaskListV2Section: React.FC = () => {
{/* Custom Column Modal */} {/* Custom Column Modal */}
{createPortal(<CustomColumnModal />, document.body, 'custom-column-modal')} {createPortal(<CustomColumnModal />, document.body, 'custom-column-modal')}
{/* Convert To Subtask Drawer */} {/* Convert To Subtask Drawer */}
{createPortal(<ConvertToSubtaskDrawer />, document.body, 'convert-to-subtask-drawer')} {createPortal(<ConvertToSubtaskDrawer />, document.body, 'convert-to-subtask-drawer')}
</div> </div>
</DndContext> </DndContext>
</>
); );
}; };
export default TaskListV2Section; export default TaskListV2Section;

View File

@@ -27,116 +27,134 @@ interface TaskRowProps {
depth?: number; depth?: number;
} }
const TaskRow: React.FC<TaskRowProps> = memo( const TaskRow: React.FC<TaskRowProps> = memo(({
({ taskId,
taskId, projectId,
projectId, visibleColumns,
visibleColumns, isSubtask = false,
isSubtask = false, isFirstInGroup = false,
isFirstInGroup = false, updateTaskCustomColumnValue,
updateTaskCustomColumnValue, depth = 0
depth = 0, }) => {
}) => { // Get task data and selection state from Redux
// Get task data and selection state from Redux const task = useAppSelector(state => selectTaskById(state, taskId));
const task = useAppSelector(state => selectTaskById(state, taskId)); const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId));
const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId)); const themeMode = useAppSelector(state => state.themeReducer.mode);
const themeMode = useAppSelector(state => state.themeReducer.mode); const isDarkMode = themeMode === 'dark';
const isDarkMode = themeMode === 'dark';
// Early return if task is not found // Early return if task is not found
if (!task) { if (!task) {
return null; return null;
}
// Use extracted hooks for state management
const {
activeDatePicker,
setActiveDatePicker,
editTaskName,
setEditTaskName,
taskName,
setTaskName,
taskDisplayName,
convertedTask,
formattedDates,
dateValues,
labelsAdapter,
} = useTaskRowState(task);
const { handleCheckboxChange, handleTaskNameSave, handleTaskNameEdit } = useTaskRowActions({
task,
taskId,
taskName,
editTaskName,
setEditTaskName,
});
// Drag and drop functionality - only enable for parent tasks
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: task.id,
data: {
type: 'task',
task,
},
disabled: isSubtask, // Disable drag and drop for subtasks
});
// Use extracted column renderer hook
const { renderColumn } = useTaskRowColumns({
task,
projectId,
isSubtask,
isSelected,
isDarkMode,
visibleColumns,
updateTaskCustomColumnValue,
taskDisplayName,
convertedTask,
formattedDates,
dateValues,
labelsAdapter,
activeDatePicker,
setActiveDatePicker,
editTaskName,
taskName,
setEditTaskName,
setTaskName,
handleCheckboxChange,
handleTaskNameSave,
handleTaskNameEdit,
attributes,
listeners,
depth,
});
// Memoize style object to prevent unnecessary re-renders
const style = useMemo(
() => ({
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0 : 1, // Completely hide the original task while dragging
}),
[transform, transition, isDragging]
);
return (
<div
ref={setNodeRef}
style={{ ...style, height: '40px' }}
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
isFirstInGroup ? 'border-t border-gray-200 dark:border-gray-700' : ''
} ${isDragging ? 'shadow-lg border border-blue-300' : ''}`}
>
{visibleColumns.map((column, index) => (
<React.Fragment key={column.id}>
{renderColumn(column.id, column.width, column.isSticky, index)}
</React.Fragment>
))}
</div>
);
} }
);
// Use extracted hooks for state management
const {
activeDatePicker,
setActiveDatePicker,
editTaskName,
setEditTaskName,
taskName,
setTaskName,
taskDisplayName,
convertedTask,
formattedDates,
dateValues,
labelsAdapter,
} = useTaskRowState(task);
const {
handleCheckboxChange,
handleTaskNameSave,
handleTaskNameEdit,
} = useTaskRowActions({
task,
taskId,
taskName,
editTaskName,
setEditTaskName,
});
// Drag and drop functionality - only enable for parent tasks
const { attributes, listeners, setNodeRef, transform, transition, isDragging, isOver } = useSortable({
id: task.id,
data: {
type: 'task',
task,
},
disabled: isSubtask, // Disable drag and drop for subtasks
});
// Use extracted column renderer hook
const { renderColumn } = useTaskRowColumns({
task,
projectId,
isSubtask,
isSelected,
isDarkMode,
visibleColumns,
updateTaskCustomColumnValue,
taskDisplayName,
convertedTask,
formattedDates,
dateValues,
labelsAdapter,
activeDatePicker,
setActiveDatePicker,
editTaskName,
taskName,
setEditTaskName,
setTaskName,
handleCheckboxChange,
handleTaskNameSave,
handleTaskNameEdit,
attributes,
listeners,
depth,
});
// Memoize style object to prevent unnecessary re-renders
const style = useMemo(() => ({
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.3 : 1, // Make original task slightly transparent while dragging
}), [transform, transition, isDragging]);
return (
<div
ref={setNodeRef}
style={{ ...style, height: '40px' }}
className={`flex items-center min-w-max px-1 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors ${
isFirstInGroup ? 'border-t border-gray-200 dark:border-gray-700' : ''
} ${
isDragging ? 'opacity-50' : ''
} ${
isOver && !isDragging ? 'bg-blue-50 dark:bg-blue-900/20' : ''
}`}
>
{visibleColumns.map((column, index) => {
// Calculate background state for sticky columns - custom dark mode colors
const rowBackgrounds = {
normal: isDarkMode ? '#1e1e1e' : '#ffffff', // custom dark : bg-white
hover: isDarkMode ? '#1f2937' : '#f9fafb', // slightly lighter dark : bg-gray-50
dragOver: isDarkMode ? '#1e3a8a33' : '#dbeafe', // bg-blue-900/20 : bg-blue-50
};
let currentBg = rowBackgrounds.normal;
if (isOver && !isDragging) {
currentBg = rowBackgrounds.dragOver;
}
// Note: hover state is handled by CSS, so we'll use a CSS custom property
return (
<React.Fragment key={column.id}>
{renderColumn(column.id, column.width, column.isSticky, index, currentBg, rowBackgrounds)}
</React.Fragment>
);
})}
</div>
);
});
TaskRow.displayName = 'TaskRow'; TaskRow.displayName = 'TaskRow';
export default TaskRow; export default TaskRow;

View File

@@ -19,10 +19,11 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
const currentSession = useAuthService().getCurrentSession(); const currentSession = useAuthService().getCurrentSession();
const [activeId, setActiveId] = useState<string | null>(null); const [activeId, setActiveId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null); const [overId, setOverId] = useState<string | null>(null);
const [dropPosition, setDropPosition] = useState<'before' | 'after' | null>(null);
// Helper function to emit socket event for persistence // Helper function to emit socket event for persistence (within-group only)
const emitTaskSortChange = useCallback( const emitTaskSortChange = useCallback(
(taskId: string, sourceGroup: TaskGroup, targetGroup: TaskGroup, insertIndex: number) => { (taskId: string, group: TaskGroup, insertIndex: number) => {
if (!socket || !connected || !projectId) { if (!socket || !connected || !projectId) {
logger.warning('Socket not connected or missing project ID'); logger.warning('Socket not connected or missing project ID');
return; return;
@@ -39,55 +40,31 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
// Use new bulk update approach - recalculate ALL task orders to prevent duplicates // Use new bulk update approach - recalculate ALL task orders to prevent duplicates
const taskUpdates: any[] = []; const taskUpdates: any[] = [];
// Create a copy of all groups and perform the move operation // Create a copy of all groups
const updatedGroups = groups.map(group => ({ const updatedGroups = groups.map(g => ({
...group, ...g,
taskIds: [...group.taskIds], taskIds: [...g.taskIds]
})); }));
// Find the source and target groups in our copy // Find the group in our copy
const sourceGroupCopy = updatedGroups.find(g => g.id === sourceGroup.id)!; const groupCopy = updatedGroups.find(g => g.id === group.id)!;
const targetGroupCopy = updatedGroups.find(g => g.id === targetGroup.id)!;
// Reorder within the group
if (sourceGroup.id === targetGroup.id) { const sourceIndex = groupCopy.taskIds.indexOf(taskId);
// Same group - reorder within the group // Remove task from old position
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId); groupCopy.taskIds.splice(sourceIndex, 1);
// Remove task from old position // Insert at new position
sourceGroupCopy.taskIds.splice(sourceIndex, 1); groupCopy.taskIds.splice(insertIndex, 0, taskId);
// Insert at new position
sourceGroupCopy.taskIds.splice(insertIndex, 0, taskId);
} else {
// Different groups - move task between groups
// Remove from source group
const sourceIndex = sourceGroupCopy.taskIds.indexOf(taskId);
sourceGroupCopy.taskIds.splice(sourceIndex, 1);
// Add to target group
targetGroupCopy.taskIds.splice(insertIndex, 0, taskId);
}
// Now assign sequential sort orders to ALL tasks across ALL groups // Now assign sequential sort orders to ALL tasks across ALL groups
let currentSortOrder = 0; let currentSortOrder = 0;
updatedGroups.forEach(group => { updatedGroups.forEach(grp => {
group.taskIds.forEach(id => { grp.taskIds.forEach(id => {
const update: any = { taskUpdates.push({
task_id: id, task_id: id,
sort_order: currentSortOrder, sort_order: currentSortOrder
}; });
// Add group-specific fields for the moved task if it changed groups
if (id === taskId && sourceGroup.id !== targetGroup.id) {
if (currentGrouping === 'status') {
update.status_id = targetGroup.id;
} else if (currentGrouping === 'priority') {
update.priority_id = targetGroup.id;
} else if (currentGrouping === 'phase') {
update.phase_id = targetGroup.id;
}
}
taskUpdates.push(update);
currentSortOrder++; currentSortOrder++;
}); });
}); });
@@ -96,8 +73,8 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
project_id: projectId, project_id: projectId,
group_by: currentGrouping || 'status', group_by: currentGrouping || 'status',
task_updates: taskUpdates, task_updates: taskUpdates,
from_group: sourceGroup.id, from_group: group.id,
to_group: targetGroup.id, to_group: group.id,
task: { task: {
id: task.id, id: task.id,
project_id: projectId, project_id: projectId,
@@ -108,38 +85,6 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
}; };
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData); socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData);
// Also emit the specific grouping field change event for the moved task
if (sourceGroup.id !== targetGroup.id) {
if (currentGrouping === 'phase') {
// Emit phase change event
socket.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
task_id: taskId,
phase_id: targetGroup.id,
parent_task: task.parent_task_id || null,
});
} else if (currentGrouping === 'priority') {
// Emit priority change event
socket.emit(
SocketEvents.TASK_PRIORITY_CHANGE.toString(),
JSON.stringify({
task_id: taskId,
priority_id: targetGroup.id,
team_id: teamId,
})
);
} else if (currentGrouping === 'status') {
// Emit status change event
socket.emit(
SocketEvents.TASK_STATUS_CHANGE.toString(),
JSON.stringify({
task_id: taskId,
status_id: targetGroup.id,
team_id: teamId,
})
);
}
}
}, },
[socket, connected, projectId, allTasks, groups, currentGrouping, currentSession] [socket, connected, projectId, allTasks, groups, currentGrouping, currentSession]
); );
@@ -154,32 +99,38 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
if (!over) { if (!over) {
setOverId(null); setOverId(null);
setDropPosition(null);
return; return;
} }
const activeId = active.id; const activeTask = allTasks.find(task => task.id === active.id);
const overId = over.id; const overTask = allTasks.find(task => task.id === over.id);
// Set the overId for drop indicators if (activeTask && overTask) {
setOverId(overId as string); const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
const overGroup = groups.find(group => group.taskIds.includes(overTask.id));
// Find the active task and the item being dragged over
const activeTask = allTasks.find(task => task.id === activeId); // Only set overId if both tasks are in the same group
if (!activeTask) return; if (activeGroup && overGroup && activeGroup.id === overGroup.id) {
setOverId(over.id as string);
// Check if we're dragging over a task or a group
const overTask = allTasks.find(task => task.id === overId); // Calculate drop position based on task indices
const overGroup = groups.find(group => group.id === overId); const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
const overIndex = activeGroup.taskIds.indexOf(overTask.id);
// Find the groups
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id)); if (activeIndex < overIndex) {
let targetGroup = overGroup; setDropPosition('after');
} else {
if (overTask) { setDropPosition('before');
targetGroup = groups.find(group => group.taskIds.includes(overTask.id)); }
} else {
setOverId(null);
setDropPosition(null);
}
} else {
setOverId(null);
setDropPosition(null);
} }
if (!activeGroup || !targetGroup) return;
}, },
[allTasks, groups] [allTasks, groups]
); );
@@ -189,6 +140,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
const { active, over } = event; const { active, over } = event;
setActiveId(null); setActiveId(null);
setOverId(null); setOverId(null);
setDropPosition(null);
if (!over || active.id === over.id) { if (!over || active.id === over.id) {
return; return;
@@ -204,86 +156,50 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
return; return;
} }
// Find the groups // Find the active task's group
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id)); const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
if (!activeGroup) { if (!activeGroup) {
logger.error('Could not find active group for task:', activeId); logger.error('Could not find active group for task:', activeId);
return; return;
} }
// Check if we're dropping on a task, group, or empty group // Only allow dropping on tasks in the same group
const overTask = allTasks.find(task => task.id === overId); const overTask = allTasks.find(task => task.id === overId);
const overGroup = groups.find(group => group.id === overId); if (!overTask) {
return;
// Check if dropping on empty group drop zone }
const isEmptyGroupDrop = typeof overId === 'string' && overId.startsWith('empty-group-');
const emptyGroupId = isEmptyGroupDrop ? overId.replace('empty-group-', '') : null; const overGroup = groups.find(group => group.taskIds.includes(overTask.id));
const emptyGroup = emptyGroupId ? groups.find(group => group.id === emptyGroupId) : null; if (!overGroup || overGroup.id !== activeGroup.id) {
let targetGroup = overGroup || emptyGroup;
let insertIndex = 0;
if (overTask) {
// Dropping on a task
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
if (targetGroup) {
insertIndex = targetGroup.taskIds.indexOf(overTask.id);
}
} else if (overGroup) {
// Dropping on a group (at the end)
targetGroup = overGroup;
insertIndex = targetGroup.taskIds.length;
} else if (emptyGroup) {
// Dropping on an empty group
targetGroup = emptyGroup;
insertIndex = 0; // First position in empty group
}
if (!targetGroup) {
logger.error('Could not find target group');
return; return;
} }
const isCrossGroup = activeGroup.id !== targetGroup.id;
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id); const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
const overIndex = activeGroup.taskIds.indexOf(overTask.id);
if (isCrossGroup) { if (activeIndex !== overIndex) {
// Moving task between groups // Reorder task within same group
console.log('Moving task between groups:', {
task: activeTask.name || activeTask.title,
from: activeGroup.title,
to: targetGroup.title,
newPosition: insertIndex,
});
// reorderTasksInGroup handles both same-group and cross-group moves
// No need for separate moveTaskBetweenGroups call
dispatch( dispatch(
reorderTasksInGroup({ reorderTasksInGroup({
sourceTaskId: activeId as string, sourceTaskId: activeId as string,
destinationTaskId: over.id as string, destinationTaskId: overId as string,
sourceGroupId: activeGroup.id, sourceGroupId: activeGroup.id,
destinationGroupId: targetGroup.id, destinationGroupId: activeGroup.id,
}) })
); );
// Emit socket event for persistence // Calculate the final index after reordering for socket emission
emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex); let finalIndex = overIndex;
} else { if (activeIndex < overIndex) {
if (activeIndex !== insertIndex) { // When dragging down, the task ends up just after the destination
// Reorder task within same group at drop position finalIndex = overIndex;
dispatch( } else {
reorderTasksInGroup({ // When dragging up, the task ends up at the destination position
sourceTaskId: activeId as string, finalIndex = overIndex;
destinationTaskId: over.id as string,
sourceGroupId: activeGroup.id,
destinationGroupId: activeGroup.id,
})
);
// Emit socket event for persistence
emitTaskSortChange(activeId as string, activeGroup, targetGroup, insertIndex);
} }
// Emit socket event for persistence
emitTaskSortChange(activeId as string, activeGroup, finalIndex);
} }
}, },
[allTasks, groups, dispatch, emitTaskSortChange] [allTasks, groups, dispatch, emitTaskSortChange]
@@ -292,6 +208,7 @@ export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
return { return {
activeId, activeId,
overId, overId,
dropPosition,
handleDragStart, handleDragStart,
handleDragOver, handleDragOver,
handleDragEnd, handleDragEnd,

View File

@@ -89,8 +89,30 @@ export const useTaskRowColumns = ({
listeners, listeners,
depth = 0, depth = 0,
}: UseTaskRowColumnsProps) => { }: UseTaskRowColumnsProps) => {
const renderColumn = useCallback(
(columnId: string, width: string, isSticky?: boolean, index?: number) => { const renderColumn = useCallback((columnId: string, width: string, isSticky?: boolean, index?: number, currentBg?: string, rowBackgrounds?: any) => {
// Calculate left position for sticky columns
let leftPosition = 0;
if (isSticky && typeof index === 'number') {
for (let i = 0; i < index; i++) {
const prevColumn = visibleColumns[i];
if (prevColumn.isSticky) {
leftPosition += parseInt(prevColumn.width.replace('px', ''));
}
}
}
// Create wrapper style for sticky positioning
const wrapperStyle = isSticky ? {
position: 'sticky' as const,
left: leftPosition,
zIndex: 5, // Lower than header but above regular content
backgroundColor: currentBg || (isDarkMode ? '#1e1e1e' : '#ffffff'), // Use dynamic background or fallback
overflow: 'hidden', // Prevent content from spilling over
width: width, // Ensure the wrapper respects column width
} : {};
const renderColumnContent = () => {
switch (columnId) { switch (columnId) {
case 'dragHandle': case 'dragHandle':
return ( return (
@@ -102,172 +124,245 @@ export const useTaskRowColumns = ({
/> />
); );
case 'checkbox': case 'checkbox':
return ( return (
<CheckboxColumn <CheckboxColumn
width={width} width={width}
isSelected={isSelected} isSelected={isSelected}
onCheckboxChange={handleCheckboxChange} onCheckboxChange={handleCheckboxChange}
/> />
); );
case 'taskKey': case 'taskKey':
return <TaskKeyColumn width={width} taskKey={task.task_key || ''} />; return (
<TaskKeyColumn
width={width}
taskKey={task.task_key || ''}
/>
);
case 'title': case 'title':
return (
<TitleColumn
width={width}
task={task}
projectId={projectId}
isSubtask={isSubtask}
taskDisplayName={taskDisplayName}
editTaskName={editTaskName}
taskName={taskName}
onEditTaskName={setEditTaskName}
onTaskNameChange={setTaskName}
onTaskNameSave={handleTaskNameSave}
depth={depth}
/>
);
case 'description':
return (
<DescriptionColumn
width={width}
description={task.description || ''}
/>
);
case 'status':
return (
<StatusColumn
width={width}
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
);
case 'assignees':
return (
<AssigneesColumn
width={width}
task={task}
convertedTask={convertedTask}
isDarkMode={isDarkMode}
/>
);
case 'priority':
return (
<PriorityColumn
width={width}
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
);
case 'dueDate':
return (
<DatePickerColumn
width={width}
task={task}
field="dueDate"
formattedDate={formattedDates.due}
dateValue={dateValues.due}
isDarkMode={isDarkMode}
activeDatePicker={activeDatePicker}
onActiveDatePickerChange={setActiveDatePicker}
/>
);
case 'startDate':
return (
<DatePickerColumn
width={width}
task={task}
field="startDate"
formattedDate={formattedDates.start}
dateValue={dateValues.start}
isDarkMode={isDarkMode}
activeDatePicker={activeDatePicker}
onActiveDatePickerChange={setActiveDatePicker}
/>
);
case 'progress':
return (
<ProgressColumn
width={width}
task={task}
/>
);
case 'labels':
return (
<LabelsColumn
width={width}
task={task}
labelsAdapter={labelsAdapter}
isDarkMode={isDarkMode}
visibleColumns={visibleColumns}
/>
);
case 'phase':
return (
<PhaseColumn
width={width}
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
);
case 'timeTracking':
return (
<TimeTrackingColumn
width={width}
taskId={task.id || ''}
isDarkMode={isDarkMode}
/>
);
case 'estimation':
return (
<EstimationColumn
width={width}
task={task}
/>
);
case 'completedDate':
return (
<DateColumn
width={width}
formattedDate={formattedDates.completed}
/>
);
case 'createdDate':
return (
<DateColumn
width={width}
formattedDate={formattedDates.created}
/>
);
case 'lastUpdated':
return (
<DateColumn
width={width}
formattedDate={formattedDates.updated}
/>
);
case 'reporter':
return (
<ReporterColumn
width={width}
reporter={task.reporter || ''}
/>
);
default:
// Handle custom columns
const column = visibleColumns.find(col => col.id === columnId);
if (column && (column.custom_column || column.isCustom) && updateTaskCustomColumnValue) {
return ( return (
<TitleColumn <DragHandleColumn
width={width} width={width}
task={task}
projectId={projectId}
isSubtask={isSubtask} isSubtask={isSubtask}
taskDisplayName={taskDisplayName} attributes={attributes}
editTaskName={editTaskName} listeners={listeners}
taskName={taskName}
onEditTaskName={setEditTaskName}
onTaskNameChange={setTaskName}
onTaskNameSave={handleTaskNameSave}
depth={depth}
/> />
); );
}
case 'description': return null;
return <DescriptionColumn width={width} description={task.description || ''} />;
case 'status':
return (
<StatusColumn width={width} task={task} projectId={projectId} isDarkMode={isDarkMode} />
);
case 'assignees':
return (
<AssigneesColumn
width={width}
task={task}
convertedTask={convertedTask}
isDarkMode={isDarkMode}
/>
);
case 'priority':
return (
<PriorityColumn
width={width}
task={task}
projectId={projectId}
isDarkMode={isDarkMode}
/>
);
case 'dueDate':
return (
<DatePickerColumn
width={width}
task={task}
field="dueDate"
formattedDate={formattedDates.due}
dateValue={dateValues.due}
isDarkMode={isDarkMode}
activeDatePicker={activeDatePicker}
onActiveDatePickerChange={setActiveDatePicker}
/>
);
case 'startDate':
return (
<DatePickerColumn
width={width}
task={task}
field="startDate"
formattedDate={formattedDates.start}
dateValue={dateValues.start}
isDarkMode={isDarkMode}
activeDatePicker={activeDatePicker}
onActiveDatePickerChange={setActiveDatePicker}
/>
);
case 'progress':
return <ProgressColumn width={width} task={task} />;
case 'labels':
return (
<LabelsColumn
width={width}
task={task}
labelsAdapter={labelsAdapter}
isDarkMode={isDarkMode}
visibleColumns={visibleColumns}
/>
);
case 'phase':
return (
<PhaseColumn width={width} task={task} projectId={projectId} isDarkMode={isDarkMode} />
);
case 'timeTracking':
return (
<TimeTrackingColumn width={width} taskId={task.id || ''} isDarkMode={isDarkMode} />
);
case 'estimation':
return <EstimationColumn width={width} task={task} />;
case 'completedDate':
return <DateColumn width={width} formattedDate={formattedDates.completed} />;
case 'createdDate':
return <DateColumn width={width} formattedDate={formattedDates.created} />;
case 'lastUpdated':
return <DateColumn width={width} formattedDate={formattedDates.updated} />;
case 'reporter':
return <ReporterColumn width={width} reporter={task.reporter || ''} />;
default:
// Handle custom columns
const column = visibleColumns.find(col => col.id === columnId);
if (column && (column.custom_column || column.isCustom) && updateTaskCustomColumnValue) {
return (
<CustomColumn
width={width}
column={column}
task={task}
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
/>
);
}
return null;
} }
}, };
[
task, // Wrap content with sticky positioning if needed
projectId, const content = renderColumnContent();
isSubtask, if (isSticky) {
isSelected, const hoverBg = rowBackgrounds?.hover || (isDarkMode ? '#2a2a2a' : '#f9fafb');
isDarkMode, return (
visibleColumns, <div
updateTaskCustomColumnValue, style={{
taskDisplayName, ...wrapperStyle,
convertedTask, '--hover-bg': hoverBg,
formattedDates, } as React.CSSProperties}
dateValues, className="border-r border-gray-200 dark:border-gray-700 overflow-hidden sticky-column-hover hover:bg-[var(--hover-bg)]"
labelsAdapter, >
activeDatePicker, {content}
setActiveDatePicker, </div>
editTaskName, );
taskName, }
setEditTaskName,
setTaskName, return content;
handleCheckboxChange, }, [
handleTaskNameSave, task,
handleTaskNameEdit, projectId,
attributes, isSubtask,
listeners, isSelected,
] isDarkMode,
); visibleColumns,
updateTaskCustomColumnValue,
taskDisplayName,
convertedTask,
formattedDates,
dateValues,
labelsAdapter,
activeDatePicker,
setActiveDatePicker,
editTaskName,
taskName,
setEditTaskName,
setTaskName,
handleCheckboxChange,
handleTaskNameSave,
handleTaskNameEdit,
attributes,
listeners,
depth,
]);
return { renderColumn }; return { renderColumn };
}; };

View File

@@ -8,6 +8,8 @@ import { Task } from '@/types/task-management.types';
import { import {
updateTask, updateTask,
selectCurrentGroupingV3, selectCurrentGroupingV3,
selectGroups,
moveTaskBetweenGroups,
} from '@/features/task-management/task-management.slice'; } from '@/features/task-management/task-management.slice';
interface TaskStatusDropdownProps { interface TaskStatusDropdownProps {
@@ -30,6 +32,7 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
const statusList = useAppSelector(state => state.taskStatusReducer.status); const statusList = useAppSelector(state => state.taskStatusReducer.status);
const currentGroupingV3 = useAppSelector(selectCurrentGroupingV3); const currentGroupingV3 = useAppSelector(selectCurrentGroupingV3);
const groups = useAppSelector(selectGroups);
// Find current status details // Find current status details
const currentStatus = useMemo(() => { const currentStatus = useMemo(() => {
@@ -44,21 +47,53 @@ const TaskStatusDropdown: React.FC<TaskStatusDropdownProps> = ({
(statusId: string, statusName: string) => { (statusId: string, statusName: string) => {
if (!task.id || !statusId || !connected) return; if (!task.id || !statusId || !connected) return;
console.log('🎯 Status change initiated:', { taskId: task.id, statusId, statusName }); // Optimistic update: immediately update the task status in Redux for instant feedback
const updatedTask = {
...task,
status: statusId,
updatedAt: new Date().toISOString(),
};
dispatch(updateTask(updatedTask));
// Handle group movement if grouping by status
if (currentGroupingV3 === 'status' && groups && groups.length > 0) {
// Find current group containing the task
const currentGroup = groups.find(group => group.taskIds.includes(task.id));
// Find target group based on the new status ID
let targetGroup = groups.find(group => group.id === statusId);
// If not found by status ID, try matching with group value
if (!targetGroup) {
targetGroup = groups.find(group => group.groupValue === statusId);
}
if (currentGroup && targetGroup && currentGroup.id !== targetGroup.id) {
// Move task between groups immediately for instant feedback
dispatch(
moveTaskBetweenGroups({
taskId: task.id,
sourceGroupId: currentGroup.id,
targetGroupId: targetGroup.id,
})
);
}
}
// Emit socket event for server-side update and real-time sync
socket?.emit( socket?.emit(
SocketEvents.TASK_STATUS_CHANGE.toString(), SocketEvents.TASK_STATUS_CHANGE.toString(),
JSON.stringify({ JSON.stringify({
task_id: task.id, task_id: task.id,
status_id: statusId, status_id: statusId,
parent_task: null, // Assuming top-level tasks for now parent_task: task.parent_task_id || null,
team_id: projectId, // Using projectId as teamId team_id: projectId,
}) })
); );
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id); socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
setIsOpen(false); setIsOpen(false);
}, },
[task.id, connected, socket, projectId] [task, connected, socket, projectId, dispatch, currentGroupingV3, groups]
); );
// Calculate dropdown position and handle outside clicks // Calculate dropdown position and handle outside clicks

View File

@@ -706,8 +706,23 @@ const taskManagementSlice = createSlice({
const group = state.groups.find(g => g.id === sourceGroupId); const group = state.groups.find(g => g.id === sourceGroupId);
if (group) { if (group) {
const newTasks = Array.from(group.taskIds); const newTasks = Array.from(group.taskIds);
const [removed] = newTasks.splice(newTasks.indexOf(sourceTaskId), 1); const sourceIndex = newTasks.indexOf(sourceTaskId);
newTasks.splice(newTasks.indexOf(destinationTaskId), 0, removed); const destinationIndex = newTasks.indexOf(destinationTaskId);
// Remove the task from its current position
const [removed] = newTasks.splice(sourceIndex, 1);
// Calculate the insertion index
let insertIndex = destinationIndex;
if (sourceIndex < destinationIndex) {
// When dragging down, we need to insert after the destination
insertIndex = destinationIndex;
} else {
// When dragging up, we insert before the destination
insertIndex = destinationIndex;
}
newTasks.splice(insertIndex, 0, removed);
group.taskIds = newTasks; group.taskIds = newTasks;
// Update order for affected tasks using the appropriate sort field // Update order for affected tasks using the appropriate sort field

View File

@@ -244,47 +244,18 @@ export const useTaskSocketHandlers = () => {
// Find current group containing the task // Find current group containing the task
const currentGroup = groups.find(group => group.taskIds.includes(response.id)); const currentGroup = groups.find(group => group.taskIds.includes(response.id));
// Find target group based on new status value with multiple matching strategies // Find target group based on the actual status ID from response
let targetGroup = groups.find(group => group.groupValue === newStatusValue); let targetGroup = groups.find(group => group.id === response.status_id);
// If not found, try case-insensitive matching // If not found by status ID, try matching with group value
if (!targetGroup) { if (!targetGroup) {
targetGroup = groups.find( targetGroup = groups.find(group => group.groupValue === response.status_id);
group => group.groupValue?.toLowerCase() === newStatusValue.toLowerCase()
);
} }
// If still not found, try matching with title // If still not found, try matching by status name (fallback)
if (!targetGroup) { if (!targetGroup && response.status) {
targetGroup = groups.find( targetGroup = groups.find(group =>
group => group.title?.toLowerCase() === newStatusValue.toLowerCase() group.title?.toLowerCase() === response.status.toLowerCase()
);
}
// If still not found, try matching common status patterns
if (!targetGroup && newStatusValue === 'todo') {
targetGroup = groups.find(
group =>
group.title?.toLowerCase().includes('todo') ||
group.title?.toLowerCase().includes('to do') ||
group.title?.toLowerCase().includes('pending') ||
group.groupValue?.toLowerCase().includes('todo')
);
} else if (!targetGroup && newStatusValue === 'doing') {
targetGroup = groups.find(
group =>
group.title?.toLowerCase().includes('doing') ||
group.title?.toLowerCase().includes('progress') ||
group.title?.toLowerCase().includes('active') ||
group.groupValue?.toLowerCase().includes('doing')
);
} else if (!targetGroup && newStatusValue === 'done') {
targetGroup = groups.find(
group =>
group.title?.toLowerCase().includes('done') ||
group.title?.toLowerCase().includes('complete') ||
group.title?.toLowerCase().includes('finish') ||
group.groupValue?.toLowerCase().includes('done')
); );
} }
@@ -298,14 +269,11 @@ export const useTaskSocketHandlers = () => {
}) })
); );
} else if (!targetGroup) { } else if (!targetGroup) {
console.log('❌ Target group not found for status:', newStatusValue); // Fallback: refetch tasks to ensure consistency
} else if (!currentGroup) { if (projectId) {
console.log('❌ Current group not found for task:', response.id); dispatch(fetchTasksV3(projectId));
} else { }
console.log('🔧 No group movement needed - task already in correct group');
} }
} else {
console.log('🔧 Not grouped by status, skipping group movement');
} }
} }
}, },

View File

@@ -1,60 +1,18 @@
import { Col, ConfigProvider, Layout } from '@/shared/antd-imports'; import { ConfigProvider, Layout } from '@/shared/antd-imports';
import { Outlet } from 'react-router-dom'; import { Outlet, useLocation } from 'react-router-dom';
import { memo, useMemo, useEffect, useRef } from 'react'; import { memo, useMemo } from 'react';
import { useMediaQuery } from 'react-responsive';
import Navbar from '../features/navbar/navbar'; import Navbar from '../features/navbar/navbar';
import { useAppSelector } from '../hooks/useAppSelector'; import { useAppSelector } from '../hooks/useAppSelector';
import { colors } from '../styles/colors'; import { colors } from '../styles/colors';
import { useRenderPerformance } from '@/utils/performance';
import { DynamicCSSLoader, LayoutStabilizer } from '@/utils/css-optimizations';
const MainLayout = memo(() => { const MainLayout = memo(() => {
const themeMode = useAppSelector(state => state.themeReducer.mode); const themeMode = useAppSelector(state => state.themeReducer.mode);
const isDesktop = useMediaQuery({ query: '(min-width: 1024px)' }); const location = useLocation();
const layoutRef = useRef<HTMLDivElement>(null);
const isProjectView = location.pathname.includes('/projects/') &&
!location.pathname.endsWith('/projects');
// Performance monitoring in development
useRenderPerformance('MainLayout');
// Apply layout optimizations
useEffect(() => {
if (layoutRef.current) {
// Prevent layout shifts in main content area
LayoutStabilizer.applyContainment(layoutRef.current, 'layout');
// Load non-critical CSS dynamically
DynamicCSSLoader.loadCSS('/styles/non-critical.css', {
priority: 'low',
media: 'all',
});
}
}, []);
// Memoize styles to prevent object recreation on every render
const headerStyles = useMemo(
() => ({
zIndex: 999,
position: 'fixed' as const,
width: '100%',
display: 'flex',
alignItems: 'center',
padding: 0,
borderBottom: themeMode === 'dark' ? '1px solid #303030' : 'none',
}),
[themeMode]
);
const contentStyles = useMemo(
() => ({
paddingInline: isDesktop ? 64 : 24,
overflowX: 'hidden' as const,
}),
[isDesktop]
);
// Memoize theme configuration
const themeConfig = useMemo( const themeConfig = useMemo(
() => ({ () => ({
components: { components: {
@@ -67,27 +25,19 @@ const MainLayout = memo(() => {
[themeMode] [themeMode]
); );
// Memoize header className
const headerClassName = useMemo(
() => `shadow-md ${themeMode === 'dark' ? '' : 'shadow-[#18181811]'}`,
[themeMode]
);
return ( return (
<ConfigProvider theme={themeConfig}> <ConfigProvider theme={themeConfig}>
<Layout ref={layoutRef} style={{ minHeight: '100vh' }} className="prevent-layout-shift"> <Layout className="min-h-screen">
<Layout.Header className={`${headerClassName} gpu-accelerated`} style={headerStyles}> <Layout.Header
className={`sticky top-0 z-[999] flex items-center p-0 shadow-md ${
themeMode === 'dark' ? 'border-b border-[#303030]' : 'shadow-[#18181811]'
}`}
>
<Navbar /> <Navbar />
</Layout.Header> </Layout.Header>
<Layout.Content className="layout-contained"> <Layout.Content className={`px-4 sm:px-8 lg:px-12 xl:px-16 ${!isProjectView ? 'overflow-x-hidden max-w-[1400px]' : ''} mx-auto w-full`}>
<Col <Outlet />
xxl={{ span: 18, offset: 3, flex: '100%' }}
style={contentStyles}
className="task-content-container"
>
<Outlet />
</Col>
</Layout.Content> </Layout.Content>
</Layout> </Layout>
</ConfigProvider> </ConfigProvider>

View File

@@ -103,7 +103,7 @@ const HomePage = memo(() => {
}, [isDesktop, isOwnerOrAdmin]); }, [isDesktop, isOwnerOrAdmin]);
return ( return (
<div className="my-24 min-h-[90vh]"> <div className="my-8 min-h-[90vh]">
<Col className="flex flex-col gap-6"> <Col className="flex flex-col gap-6">
<GreetingWithTime /> <GreetingWithTime />
{CreateProjectButtonComponent} {CreateProjectButtonComponent}
@@ -113,13 +113,13 @@ const HomePage = memo(() => {
<Col xs={24} lg={16}> <Col xs={24} lg={16}>
<Flex vertical gap={24}> <Flex vertical gap={24}>
<TasksList /> <TasksList />
<TodoList />
</Flex> </Flex>
</Col> </Col>
<Col xs={24} lg={8}> <Col xs={24} lg={8}>
<Flex vertical gap={24}> <Flex vertical gap={24}>
<TodoList />
<UserActivityFeed /> <UserActivityFeed />
<RecentAndFavouriteProjectList /> <RecentAndFavouriteProjectList />

View File

@@ -1,9 +1,10 @@
import { CheckCircleOutlined, SyncOutlined } from '@/shared/antd-imports'; import { CheckCircleOutlined, SyncOutlined, DownOutlined, RightOutlined } from '@/shared/antd-imports';
import { useRef, useState } from 'react'; import { useRef, useState } from 'react';
import Form from 'antd/es/form'; import Form from 'antd/es/form';
import Input, { InputRef } from 'antd/es/input'; import Input, { InputRef } from 'antd/es/input';
import Flex from 'antd/es/flex'; import Flex from 'antd/es/flex';
import Card from 'antd/es/card'; import Card from 'antd/es/card';
import Collapse from 'antd/es/collapse';
import ConfigProvider from 'antd/es/config-provider'; import ConfigProvider from 'antd/es/config-provider';
import Table, { TableProps } from 'antd/es/table'; import Table, { TableProps } from 'antd/es/table';
import Tooltip from 'antd/es/tooltip'; import Tooltip from 'antd/es/tooltip';
@@ -23,6 +24,7 @@ import { useCreatePersonalTaskMutation } from '@/api/home-page/home-page.api.ser
const TodoList = () => { const TodoList = () => {
const [isAlertShowing, setIsAlertShowing] = useState(false); const [isAlertShowing, setIsAlertShowing] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(true);
const [form] = Form.useForm(); const [form] = Form.useForm();
const { t } = useTranslation('home'); const { t } = useTranslation('home');
@@ -97,73 +99,109 @@ const TodoList = () => {
]; ];
return ( return (
<Card <Card style={{ width: '100%' }} bodyStyle={{ padding: 0 }}>
title={ <style>{`
<Typography.Title level={5} style={{ marginBlockEnd: 0 }}> .todo-collapse .ant-collapse-header {
{t('home:todoList.title')} ({data?.body.length}) display: flex !important;
</Typography.Title> align-items: center !important;
} padding: 12px 16px !important;
extra={ }
<Tooltip title={t('home:todoList.refreshTasks')}> .todo-collapse .ant-collapse-expand-icon {
<Button shape="circle" icon={<SyncOutlined spin={isFetching} />} onClick={refetch} /> margin-right: 8px !important;
</Tooltip> display: flex !important;
} align-items: center !important;
style={{ width: '100%' }} }
> `}</style>
<div> <Collapse
<Form form={form} onFinish={handleTodoSubmit}> defaultActiveKey={[]}
<Form.Item name="name"> ghost
<Flex vertical> size="small"
<Input className="todo-collapse"
ref={todoInputRef} expandIcon={({ isActive }) =>
placeholder={t('home:todoList.addTask')} isActive ? <DownOutlined /> : <RightOutlined />
onChange={e => { }
const inputValue = e.currentTarget.value; onChange={(keys) => {
setIsCollapsed(keys.length === 0);
}}
items={[
{
key: '1',
label: (
<Flex style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<Typography.Title level={5} style={{ margin: 0 }}>
{t('home:todoList.title')} ({data?.body.length})
</Typography.Title>
<Tooltip title={t('home:todoList.refreshTasks')}>
<Button
shape="circle"
icon={<SyncOutlined spin={isFetching} />}
onClick={(e) => {
e.stopPropagation();
refetch();
}}
/>
</Tooltip>
</Flex>
),
children: (
<div style={{ padding: '0 16px 16px 16px' }}>
<Form form={form} onFinish={handleTodoSubmit}>
<Form.Item name="name">
<Flex vertical>
<Input
ref={todoInputRef}
placeholder={t('home:todoList.addTask')}
onChange={e => {
const inputValue = e.currentTarget.value;
if (inputValue.length >= 1) setIsAlertShowing(true); if (inputValue.length >= 1) setIsAlertShowing(true);
else if (inputValue === '') setIsAlertShowing(false); else if (inputValue === '') setIsAlertShowing(false);
}} }}
/> />
{isAlertShowing && ( {isAlertShowing && (
<Alert <Alert
message={ message={
<Typography.Text style={{ fontSize: 11 }}> <Typography.Text style={{ fontSize: 11 }}>
{t('home:todoList.pressEnter')} <strong>Enter</strong>{' '} {t('home:todoList.pressEnter')} <strong>Enter</strong>{' '}
{t('home:todoList.toCreate')} {t('home:todoList.toCreate')}
</Typography.Text> </Typography.Text>
} }
type="info" type="info"
style={{ style={{
width: 'fit-content', width: 'fit-content',
borderRadius: 2, borderRadius: 2,
padding: '0 6px', padding: '0 6px',
}} }}
/> />
)} )}
</Flex> </Flex>
</Form.Item> </Form.Item>
</Form> </Form>
<div style={{ maxHeight: 300, overflow: 'auto' }}> <div style={{ maxHeight: 300, overflow: 'auto' }}>
{data?.body.length === 0 ? ( {data?.body.length === 0 ? (
<EmptyListPlaceholder <EmptyListPlaceholder
imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp" imageSrc="https://s3.us-west-2.amazonaws.com/worklenz.com/assets/empty-box.webp"
text={t('home:todoList.noTasks')} text={t('home:todoList.noTasks')}
/> />
) : ( ) : (
<Table <Table
className="custom-two-colors-row-table" className="custom-two-colors-row-table"
rowKey={record => record.id || ''} rowKey={record => record.id || ''}
dataSource={data?.body} dataSource={data?.body}
columns={columns} columns={columns}
showHeader={false} showHeader={false}
pagination={false} pagination={false}
size="small" size="small"
loading={isFetching} loading={isFetching}
/> />
)} )}
</div> </div>
</div> </div>
),
},
]}
/>
</Card> </Card>
); );
}; };

View File

@@ -820,7 +820,7 @@ const ProjectList: React.FC = () => {
}, [loadingProjects, isFetchingProjects, viewMode, groupedProjects.loading, isLoading]); }, [loadingProjects, isFetchingProjects, viewMode, groupedProjects.loading, isLoading]);
return ( return (
<div style={{ marginBlock: 65, minHeight: '90vh' }}> <div style={{ minHeight: '90vh' }}>
<PageHeader <PageHeader
className="site-page-header" className="site-page-header"
title={`${projectCount} ${t('projects')}`} title={`${projectCount} ${t('projects')}`}

View File

@@ -369,14 +369,14 @@ const ProjectView = React.memo(() => {
// Show loading state while project is being fetched or translations are loading // Show loading state while project is being fetched or translations are loading
if (projectLoading || !isInitialized || !translationsReady) { if (projectLoading || !isInitialized || !translationsReady) {
return ( return (
<div style={{ marginBlockStart: 70, marginBlockEnd: 12, minHeight: '80vh' }}> <div style={{ marginBlockEnd: 12, minHeight: '80vh' }}>
<SuspenseFallback /> <SuspenseFallback />
</div> </div>
); );
} }
return ( return (
<div style={{ marginBlockStart: 70, marginBlockEnd: 12, minHeight: '80vh' }}> <div style={{ marginBlockEnd: 12, minHeight: '80vh' }}>
<ProjectViewHeader /> <ProjectViewHeader />
<Tabs <Tabs

View File

@@ -54,7 +54,7 @@ const Schedule: React.FC = () => {
}; };
return ( return (
<div style={{ marginBlockStart: 65, minHeight: '90vh' }}> <div style={{ minHeight: '90vh' }}>
<Flex align="center" justify="space-between"> <Flex align="center" justify="space-between">
<Flex <Flex
gap={16} gap={16}