feat(task-management): enhance task list with drag-and-drop functionality and improved styling
- Integrated drag-and-drop capabilities using @dnd-kit for task reordering within the TaskListV2 component. - Updated TaskGroupHeader to dynamically adjust colors based on group properties, improving visual clarity. - Refactored TaskRow to support drag-and-drop interactions and optimized rendering of task details. - Enhanced Redux state management for collapsed groups, transitioning from Set to array for better compatibility. - Improved task display logic to handle optional fields and ensure consistent rendering across components.
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
||||||
|
import { getContrastColor } from '@/utils/colorUtils';
|
||||||
|
|
||||||
interface TaskGroupHeaderProps {
|
interface TaskGroupHeaderProps {
|
||||||
group: {
|
group: {
|
||||||
@@ -13,40 +14,50 @@ interface TaskGroupHeaderProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, onToggle }) => {
|
const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, onToggle }) => {
|
||||||
|
const headerBackgroundColor = group.color || '#F0F0F0'; // Default light gray if no color
|
||||||
|
const headerTextColor = getContrastColor(headerBackgroundColor);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center px-4 py-2 bg-white dark:bg-gray-800 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 border-b border-gray-200 dark:border-gray-700"
|
className="flex items-center px-4 py-2 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-b border-gray-200 dark:border-gray-700"
|
||||||
|
style={{
|
||||||
|
backgroundColor: headerBackgroundColor,
|
||||||
|
color: headerTextColor,
|
||||||
|
position: 'sticky',
|
||||||
|
top: 0,
|
||||||
|
zIndex: 20 // Higher than sticky columns (zIndex: 1) and column headers (zIndex: 2)
|
||||||
|
}}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
>
|
>
|
||||||
{/* Chevron button */}
|
{/* Chevron button */}
|
||||||
<button
|
<button
|
||||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-600 transition-colors"
|
className="p-1 rounded-md hover:bg-opacity-20 transition-colors"
|
||||||
|
style={{ backgroundColor: headerBackgroundColor, color: headerTextColor, borderColor: headerTextColor, border: '1px solid' }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggle();
|
onToggle();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isCollapsed ? (
|
{isCollapsed ? (
|
||||||
<ChevronRightIcon className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
<ChevronRightIcon className="h-4 w-4" style={{ color: headerTextColor }} />
|
||||||
) : (
|
) : (
|
||||||
<ChevronDownIcon className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
<ChevronDownIcon className="h-4 w-4" style={{ color: headerTextColor }} />
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Group indicator and name */}
|
{/* Group indicator and name */}
|
||||||
<div className="ml-2 flex items-center gap-3 flex-1">
|
<div className="ml-2 flex items-center gap-3 flex-1">
|
||||||
{/* Color indicator */}
|
{/* Color indicator (removed as full header is colored) */}
|
||||||
<div
|
|
||||||
className="w-3 h-3 rounded-sm"
|
|
||||||
style={{ backgroundColor: group.color || '#94A3B8' }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Group name and count */}
|
{/* Group name and count */}
|
||||||
<div className="flex items-center justify-between flex-1">
|
<div className="flex items-center justify-between flex-1">
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-white">
|
<span className="text-sm font-medium">
|
||||||
{group.name}
|
{group.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded-full">
|
<span
|
||||||
|
className="text-xs font-medium px-2 py-0.5 rounded-full"
|
||||||
|
style={{ backgroundColor: getContrastColor(headerTextColor) === '#000000' ? 'rgba(0,0,0,0.1)' : 'rgba(255,255,255,0.2)', color: headerTextColor }}
|
||||||
|
>
|
||||||
{group.count}
|
{group.count}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,21 @@
|
|||||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
||||||
import { GroupedVirtuoso } from 'react-virtuoso';
|
import { GroupedVirtuoso } from 'react-virtuoso';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragEndEvent,
|
||||||
|
DragOverlay,
|
||||||
|
DragStartEvent,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
KeyboardSensor,
|
||||||
|
TouchSensor,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import {
|
import {
|
||||||
@@ -15,7 +31,6 @@ import {
|
|||||||
import {
|
import {
|
||||||
selectCurrentGrouping,
|
selectCurrentGrouping,
|
||||||
selectCollapsedGroups,
|
selectCollapsedGroups,
|
||||||
selectIsGroupCollapsed,
|
|
||||||
toggleGroupCollapsed,
|
toggleGroupCollapsed,
|
||||||
} from '@/features/task-management/grouping.slice';
|
} from '@/features/task-management/grouping.slice';
|
||||||
import {
|
import {
|
||||||
@@ -36,6 +51,7 @@ import { TaskListField } from '@/types/task-list-field.types';
|
|||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import ImprovedTaskFilters from '@/components/task-management/improved-task-filters';
|
import ImprovedTaskFilters from '@/components/task-management/improved-task-filters';
|
||||||
import { Bars3Icon } from '@heroicons/react/24/outline';
|
import { Bars3Icon } from '@heroicons/react/24/outline';
|
||||||
|
import { HolderOutlined } from '@ant-design/icons';
|
||||||
import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
||||||
|
|
||||||
// Base column configuration
|
// Base column configuration
|
||||||
@@ -75,10 +91,33 @@ interface TaskListV2Props {
|
|||||||
const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { projectId: urlProjectId } = useParams();
|
const { projectId: urlProjectId } = useParams();
|
||||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
|
||||||
|
// Drag and drop state
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Configure sensors for drag and drop
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 250,
|
||||||
|
tolerance: 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Using Redux state for collapsedGroups instead of local state
|
||||||
|
const collapsedGroups = useAppSelector(selectCollapsedGroups);
|
||||||
|
|
||||||
// Selectors
|
// Selectors
|
||||||
const tasks = useAppSelector(selectAllTasksArray);
|
const allTasks = useAppSelector(selectAllTasksArray); // Renamed to allTasks for clarity
|
||||||
const groups = useAppSelector(selectGroups);
|
const groups = useAppSelector(selectGroups);
|
||||||
const grouping = useAppSelector(selectGrouping);
|
const grouping = useAppSelector(selectGrouping);
|
||||||
const loading = useAppSelector(selectLoading);
|
const loading = useAppSelector(selectLoading);
|
||||||
@@ -114,7 +153,7 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
if (event.ctrlKey || event.metaKey) {
|
if (event.ctrlKey || event.metaKey) {
|
||||||
dispatch(toggleTaskSelection(taskId));
|
dispatch(toggleTaskSelection(taskId));
|
||||||
} else if (event.shiftKey && lastSelectedTaskId) {
|
} else if (event.shiftKey && lastSelectedTaskId) {
|
||||||
const taskIds = tasks.map(t => t.id);
|
const taskIds = allTasks.map(t => t.id); // Use allTasks here
|
||||||
const startIdx = taskIds.indexOf(lastSelectedTaskId);
|
const startIdx = taskIds.indexOf(lastSelectedTaskId);
|
||||||
const endIdx = taskIds.indexOf(taskId);
|
const endIdx = taskIds.indexOf(taskId);
|
||||||
const rangeIds = taskIds.slice(
|
const rangeIds = taskIds.slice(
|
||||||
@@ -126,58 +165,126 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
dispatch(selectTask(taskId));
|
dispatch(selectTask(taskId));
|
||||||
}
|
}
|
||||||
}, [dispatch, lastSelectedTaskId, tasks]);
|
}, [dispatch, lastSelectedTaskId, allTasks]);
|
||||||
|
|
||||||
const handleGroupCollapse = useCallback((groupId: string) => {
|
const handleGroupCollapse = useCallback((groupId: string) => {
|
||||||
setCollapsedGroups(prev => {
|
dispatch(toggleGroupCollapsed(groupId)); // Dispatch Redux action to toggle collapsed state
|
||||||
const next = new Set(prev);
|
}, [dispatch]);
|
||||||
if (next.has(groupId)) {
|
|
||||||
next.delete(groupId);
|
// Drag and drop handlers
|
||||||
} else {
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
next.add(groupId);
|
setActiveId(event.active.id as string);
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Memoized values
|
const handleDragEnd = useCallback((event: DragEndEvent) => {
|
||||||
const groupCounts = useMemo(() => {
|
const { active, over } = event;
|
||||||
return groups.map(group => {
|
setActiveId(null);
|
||||||
const visibleTasks = tasks.filter(task => group.taskIds.includes(task.id));
|
|
||||||
return visibleTasks.length;
|
|
||||||
});
|
|
||||||
}, [groups, tasks]);
|
|
||||||
|
|
||||||
const visibleGroups = useMemo(() => {
|
if (!over || active.id === over.id) {
|
||||||
return groups.filter(group => !collapsedGroups.has(group.id));
|
return;
|
||||||
}, [groups, collapsedGroups]);
|
}
|
||||||
|
|
||||||
|
// Find the active task
|
||||||
|
const activeTask = allTasks.find(task => task.id === active.id);
|
||||||
|
if (!activeTask) {
|
||||||
|
console.error('Active task not found:', active.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find which group the task is being moved to
|
||||||
|
const overTask = allTasks.find(task => task.id === over.id);
|
||||||
|
if (!overTask) {
|
||||||
|
console.error('Over task not found:', over.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the groups for both tasks
|
||||||
|
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||||
|
const overGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||||
|
|
||||||
|
if (!activeGroup || !overGroup) {
|
||||||
|
console.error('Could not find groups for tasks');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate new positions
|
||||||
|
const activeIndex = allTasks.findIndex(task => task.id === active.id);
|
||||||
|
const overIndex = allTasks.findIndex(task => task.id === over.id);
|
||||||
|
|
||||||
|
console.log('Drag operation:', {
|
||||||
|
activeId: active.id,
|
||||||
|
overId: over.id,
|
||||||
|
activeIndex,
|
||||||
|
overIndex,
|
||||||
|
activeGroup: activeGroup.id,
|
||||||
|
overGroup: overGroup.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Implement the actual reordering logic
|
||||||
|
// This would typically involve:
|
||||||
|
// 1. Updating the task order in Redux
|
||||||
|
// 2. Sending the update to the backend
|
||||||
|
// 3. Optimistic UI updates
|
||||||
|
|
||||||
|
}, [allTasks, groups]);
|
||||||
|
|
||||||
|
// Memoized values for GroupedVirtuoso
|
||||||
|
const virtuosoGroups = useMemo(() => {
|
||||||
|
let currentTaskIndex = 0;
|
||||||
|
return groups.map(group => {
|
||||||
|
const isCurrentGroupCollapsed = collapsedGroups.has(group.id);
|
||||||
|
const visibleTasksInGroup = isCurrentGroupCollapsed ? [] : allTasks.filter(task => group.taskIds.includes(task.id));
|
||||||
|
const tasksForVirtuoso = visibleTasksInGroup.map(task => ({
|
||||||
|
...task,
|
||||||
|
originalIndex: allTasks.indexOf(task),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const groupData = {
|
||||||
|
...group,
|
||||||
|
tasks: tasksForVirtuoso,
|
||||||
|
startIndex: currentTaskIndex,
|
||||||
|
count: tasksForVirtuoso.length,
|
||||||
|
};
|
||||||
|
currentTaskIndex += tasksForVirtuoso.length;
|
||||||
|
return groupData;
|
||||||
|
});
|
||||||
|
}, [groups, allTasks, collapsedGroups]);
|
||||||
|
|
||||||
|
const virtuosoGroupCounts = useMemo(() => {
|
||||||
|
return virtuosoGroups.map(group => group.count);
|
||||||
|
}, [virtuosoGroups]);
|
||||||
|
|
||||||
|
const virtuosoItems = useMemo(() => {
|
||||||
|
return virtuosoGroups.flatMap(group => group.tasks);
|
||||||
|
}, [virtuosoGroups]);
|
||||||
|
|
||||||
// Render functions
|
// Render functions
|
||||||
const renderGroup = useCallback((groupIndex: number) => {
|
const renderGroup = useCallback((groupIndex: number) => {
|
||||||
const group = groups[groupIndex];
|
const group = virtuosoGroups[groupIndex];
|
||||||
return (
|
return (
|
||||||
<TaskGroupHeader
|
<TaskGroupHeader
|
||||||
group={{
|
group={{
|
||||||
id: group.id,
|
id: group.id,
|
||||||
name: group.title,
|
name: group.title,
|
||||||
count: groupCounts[groupIndex],
|
count: group.count,
|
||||||
color: group.color,
|
color: group.color,
|
||||||
}}
|
}}
|
||||||
isCollapsed={collapsedGroups.has(group.id)}
|
isCollapsed={collapsedGroups.has(group.id)}
|
||||||
onToggle={() => handleGroupCollapse(group.id)}
|
onToggle={() => handleGroupCollapse(group.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [groups, groupCounts, collapsedGroups, handleGroupCollapse]);
|
}, [virtuosoGroups, collapsedGroups, handleGroupCollapse]);
|
||||||
|
|
||||||
const renderTask = useCallback((taskIndex: number) => {
|
const renderTask = useCallback((taskIndex: number) => {
|
||||||
const task = tasks[taskIndex];
|
const task = virtuosoItems[taskIndex]; // Get task from the flattened virtuosoItems
|
||||||
|
if (!task) return null; // Should not happen if logic is correct
|
||||||
return (
|
return (
|
||||||
<TaskRow
|
<TaskRow
|
||||||
task={task}
|
task={task}
|
||||||
visibleColumns={visibleColumns}
|
visibleColumns={visibleColumns}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}, [tasks, visibleColumns]);
|
}, [virtuosoItems, visibleColumns]);
|
||||||
|
|
||||||
if (loading) return <div>Loading...</div>;
|
if (loading) return <div>Loading...</div>;
|
||||||
if (error) return <div>Error: {error}</div>;
|
if (error) return <div>Error: {error}</div>;
|
||||||
@@ -185,79 +292,99 @@ const TaskListV2: React.FC<TaskListV2Props> = ({ projectId }) => {
|
|||||||
// Log data for debugging
|
// Log data for debugging
|
||||||
console.log('Rendering with:', {
|
console.log('Rendering with:', {
|
||||||
groups,
|
groups,
|
||||||
tasks,
|
allTasks,
|
||||||
groupCounts
|
virtuosoGroups,
|
||||||
|
virtuosoGroupCounts,
|
||||||
|
virtuosoItems,
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-screen bg-white dark:bg-gray-900">
|
<DndContext
|
||||||
{/* Task Filters */}
|
sensors={sensors}
|
||||||
<div className="flex-none px-4 py-3">
|
onDragStart={handleDragStart}
|
||||||
<ImprovedTaskFilters position="list" />
|
onDragEnd={handleDragEnd}
|
||||||
</div>
|
>
|
||||||
|
<div className="flex flex-col h-screen bg-white dark:bg-gray-900">
|
||||||
|
{/* Task Filters */}
|
||||||
|
<div className="flex-none px-4 py-3">
|
||||||
|
<ImprovedTaskFilters position="list" />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Column Headers */}
|
{/* Column Headers */}
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<div className="flex-none border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
<div className="flex-none border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
||||||
<div className="flex items-center min-w-max px-4 py-2">
|
<div className="flex items-center min-w-max px-4 py-2">
|
||||||
{visibleColumns.map((column, index) => {
|
{visibleColumns.map((column, index) => {
|
||||||
const columnStyle: ColumnStyle = {
|
const columnStyle: ColumnStyle = {
|
||||||
width: column.width,
|
width: column.width,
|
||||||
...(column.isSticky ? {
|
// Removed sticky functionality to prevent overlap with group headers
|
||||||
position: 'sticky',
|
// ...(column.isSticky ? {
|
||||||
left: index === 0 ? 0 : index === 1 ? 32 : 132,
|
// position: 'sticky',
|
||||||
backgroundColor: 'inherit',
|
// left: index === 0 ? 0 : index === 1 ? 32 : 132,
|
||||||
zIndex: 2,
|
// backgroundColor: 'inherit',
|
||||||
} : {}),
|
// zIndex: 2,
|
||||||
};
|
// } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={column.id}
|
key={column.id}
|
||||||
className="text-xs font-medium text-gray-500 dark:text-gray-400"
|
className="text-xs font-medium text-gray-500 dark:text-gray-400"
|
||||||
style={columnStyle}
|
style={columnStyle}
|
||||||
>
|
>
|
||||||
{column.id === 'dragHandle' ? (
|
{column.id === 'dragHandle' ? (
|
||||||
<Bars3Icon className="w-4 h-4 text-gray-400" />
|
<HolderOutlined className="text-gray-400" />
|
||||||
) : (
|
) : (
|
||||||
column.label
|
column.label
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Task List */}
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<SortableContext
|
||||||
|
items={virtuosoItems.map(task => task.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<GroupedVirtuoso
|
||||||
|
style={{ height: 'calc(100vh - 200px)' }}
|
||||||
|
groupCounts={virtuosoGroupCounts}
|
||||||
|
groupContent={renderGroup}
|
||||||
|
itemContent={renderTask}
|
||||||
|
components={{
|
||||||
|
// Removed custom Group component as TaskGroupHeader now handles stickiness
|
||||||
|
List: React.forwardRef<HTMLDivElement, { style?: React.CSSProperties; children?: React.ReactNode }>(({ style, children }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
style={style || {}}
|
||||||
|
className="virtuoso-list-container" // Add a class for potential debugging/styling
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SortableContext>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task List */}
|
{/* Drag Overlay */}
|
||||||
<div className="flex-1 overflow-hidden">
|
<DragOverlay>
|
||||||
<GroupedVirtuoso
|
{activeId ? (
|
||||||
style={{ height: 'calc(100vh - 200px)' }}
|
<div className="bg-white dark:bg-gray-800 shadow-lg rounded-md border border-blue-300 opacity-90">
|
||||||
groupCounts={groupCounts}
|
<div className="px-4 py-2">
|
||||||
groupContent={renderGroup}
|
<span className="text-sm font-medium">
|
||||||
itemContent={renderTask}
|
{allTasks.find(task => task.id === activeId)?.name || 'Task'}
|
||||||
components={{
|
</span>
|
||||||
Group: ({ children, ...props }) => (
|
</div>
|
||||||
<div
|
</div>
|
||||||
{...props}
|
) : null}
|
||||||
className="sticky top-0 z-10 bg-white dark:bg-gray-800"
|
</DragOverlay>
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
List: React.forwardRef(({ style, children }, ref) => (
|
|
||||||
<div
|
|
||||||
ref={ref as any}
|
|
||||||
style={style}
|
|
||||||
className="divide-y divide-gray-200 dark:divide-gray-700"
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
)),
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</DndContext>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { HolderOutlined } from '@ant-design/icons';
|
||||||
import { Task } from '@/types/task-management.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
|
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||||
import Avatar from '@/components/Avatar';
|
import Avatar from '@/components/Avatar';
|
||||||
|
import AssigneeSelector from '@/components/AssigneeSelector';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
import { Bars3Icon } from '@heroicons/react/24/outline';
|
import { Bars3Icon } from '@heroicons/react/24/outline';
|
||||||
import { ClockIcon } from '@heroicons/react/24/outline';
|
import { ClockIcon } from '@heroicons/react/24/outline';
|
||||||
|
import AvatarGroup from '../AvatarGroup';
|
||||||
|
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
||||||
|
|
||||||
interface TaskRowProps {
|
interface TaskRowProps {
|
||||||
task: Task;
|
task: Task;
|
||||||
@@ -14,49 +21,100 @@ interface TaskRowProps {
|
|||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Utility function to get task display name with fallbacks
|
||||||
|
const getTaskDisplayName = (task: Task): string => {
|
||||||
|
// Check each field and only use if it has actual content after trimming
|
||||||
|
if (task.title && task.title.trim()) return task.title.trim();
|
||||||
|
if (task.name && task.name.trim()) return task.name.trim();
|
||||||
|
if (task.task_key && task.task_key.trim()) return task.task_key.trim();
|
||||||
|
return DEFAULT_TASK_NAME;
|
||||||
|
};
|
||||||
|
|
||||||
const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
||||||
|
// Drag and drop functionality
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({
|
||||||
|
id: task.id,
|
||||||
|
data: {
|
||||||
|
type: 'task',
|
||||||
|
task,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Convert Task to IProjectTask format for AssigneeSelector compatibility
|
||||||
|
const convertTaskToProjectTask = (task: Task) => {
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
name: getTaskDisplayName(task),
|
||||||
|
task_key: task.task_key || getTaskDisplayName(task),
|
||||||
|
assignees:
|
||||||
|
task.assignee_names?.map((assignee: InlineMember, index: number) => ({
|
||||||
|
team_member_id: assignee.team_member_id || `assignee-${index}`,
|
||||||
|
id: assignee.team_member_id || `assignee-${index}`,
|
||||||
|
project_member_id: assignee.team_member_id || `assignee-${index}`,
|
||||||
|
name: assignee.name || '',
|
||||||
|
})) || [],
|
||||||
|
parent_task_id: task.parent_task_id,
|
||||||
|
// Add other required fields with defaults
|
||||||
|
status_id: undefined,
|
||||||
|
project_id: undefined,
|
||||||
|
manual_progress: undefined, // Required field
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const renderColumn = (columnId: string, width: string, isSticky?: boolean, index?: number) => {
|
const renderColumn = (columnId: string, width: string, isSticky?: boolean, index?: number) => {
|
||||||
const baseStyle = {
|
const baseStyle = {
|
||||||
width,
|
width,
|
||||||
...(isSticky ? {
|
// Removed sticky functionality to prevent overlap with group headers
|
||||||
position: 'sticky' as const,
|
// ...(isSticky
|
||||||
left: index === 0 ? 0 : index === 1 ? 32 : 132,
|
// ? {
|
||||||
backgroundColor: 'inherit',
|
// position: 'sticky' as const,
|
||||||
zIndex: 1,
|
// left: index === 0 ? 0 : index === 1 ? 32 : 132,
|
||||||
} : {}),
|
// backgroundColor: 'inherit',
|
||||||
|
// zIndex: 1,
|
||||||
|
// }
|
||||||
|
// : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
switch (columnId) {
|
switch (columnId) {
|
||||||
case 'dragHandle':
|
case 'dragHandle':
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="cursor-move flex items-center justify-center"
|
className="cursor-grab active:cursor-grabbing flex items-center justify-center"
|
||||||
style={baseStyle}
|
style={baseStyle}
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
>
|
>
|
||||||
<Bars3Icon className="w-4 h-4 text-gray-400" />
|
<HolderOutlined className="text-gray-400 hover:text-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'taskKey':
|
case 'taskKey':
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex items-center" style={baseStyle}>
|
||||||
className="flex items-center"
|
|
||||||
style={baseStyle}
|
|
||||||
>
|
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-white whitespace-nowrap">
|
<span className="text-sm font-medium text-gray-900 dark:text-white whitespace-nowrap">
|
||||||
{task.task_key}
|
{task.task_key || 'N/A'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'title':
|
case 'title':
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="flex items-center" style={baseStyle}>
|
||||||
className="flex items-center"
|
|
||||||
style={baseStyle}
|
|
||||||
>
|
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">
|
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||||
{task.title || task.name}
|
{getTaskDisplayName(task)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -64,7 +122,7 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
|||||||
case 'status':
|
case 'status':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle}>
|
<div style={baseStyle}>
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: task.statusColor ? `${task.statusColor}20` : 'rgb(229, 231, 235)',
|
backgroundColor: task.statusColor ? `${task.statusColor}20` : 'rgb(229, 231, 235)',
|
||||||
@@ -79,29 +137,33 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
|||||||
case 'assignees':
|
case 'assignees':
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1" style={baseStyle}>
|
<div className="flex items-center gap-1" style={baseStyle}>
|
||||||
{task.assignee_names?.slice(0, 3).map((assignee, index) => (
|
{/* Show existing assignee avatars */}
|
||||||
<Avatar
|
{
|
||||||
key={index}
|
<AvatarGroup
|
||||||
name={assignee.name || ''}
|
members={task.assignee_names || []}
|
||||||
size="small"
|
maxCount={3}
|
||||||
className="ring-2 ring-white dark:ring-gray-900"
|
isDarkMode={document.documentElement.classList.contains('dark')}
|
||||||
|
size={24}
|
||||||
/>
|
/>
|
||||||
))}
|
}
|
||||||
{(task.assignee_names?.length || 0) > 3 && (
|
{/* Add AssigneeSelector for adding/managing assignees */}
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400 ml-1">
|
<AssigneeSelector
|
||||||
+{task.assignee_names!.length - 3}
|
task={convertTaskToProjectTask(task)}
|
||||||
</span>
|
groupId={null}
|
||||||
)}
|
isDarkMode={document.documentElement.classList.contains('dark')}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'priority':
|
case 'priority':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle}>
|
<div style={baseStyle}>
|
||||||
<span
|
<span
|
||||||
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: task.priorityColor ? `${task.priorityColor}20` : 'rgb(229, 231, 235)',
|
backgroundColor: task.priorityColor
|
||||||
|
? `${task.priorityColor}20`
|
||||||
|
: 'rgb(229, 231, 235)',
|
||||||
color: task.priorityColor || 'rgb(31, 41, 55)',
|
color: task.priorityColor || 'rgb(31, 41, 55)',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -183,7 +245,7 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
|||||||
case 'estimation':
|
case 'estimation':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle}>
|
<div style={baseStyle}>
|
||||||
{task.timeTracking.estimated && (
|
{task.timeTracking?.estimated && (
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{task.timeTracking.estimated}h
|
{task.timeTracking.estimated}h
|
||||||
</span>
|
</span>
|
||||||
@@ -216,9 +278,9 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
|||||||
case 'createdDate':
|
case 'createdDate':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle}>
|
<div style={baseStyle}>
|
||||||
{task.createdAt && (
|
{task.created_at && (
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
{format(new Date(task.createdAt), 'MMM d')}
|
{format(new Date(task.created_at), 'MMM d')}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -239,9 +301,7 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
|||||||
return (
|
return (
|
||||||
<div style={baseStyle}>
|
<div style={baseStyle}>
|
||||||
{task.reporter && (
|
{task.reporter && (
|
||||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
<span className="text-sm text-gray-500 dark:text-gray-400">{task.reporter}</span>
|
||||||
{task.reporter}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -252,10 +312,18 @@ const TaskRow: React.FC<TaskRowProps> = ({ task, visibleColumns }) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center min-w-max px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800">
|
<div
|
||||||
{visibleColumns.map((column, index) => renderColumn(column.id, column.width, column.isSticky, index))}
|
ref={setNodeRef}
|
||||||
|
style={style}
|
||||||
|
className={`flex items-center min-w-max px-4 py-2 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
||||||
|
isDragging ? 'shadow-lg border border-blue-300' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{visibleColumns.map((column, index) =>
|
||||||
|
renderColumn(column.id, column.width, column.isSticky, index)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TaskRow;
|
export default TaskRow;
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { createSlice, PayloadAction, createSelector } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction, createSelector } from '@reduxjs/toolkit';
|
||||||
import { GroupingState, TaskGroup, TaskGrouping } from '@/types/task-management.types';
|
import { TaskGroup } from '@/types/task-management.types';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import { selectAllTasks } from './task-management.slice';
|
import { selectAllTasksArray } from './task-management.slice';
|
||||||
|
|
||||||
interface GroupingState {
|
type GroupingType = 'status' | 'priority' | 'phase';
|
||||||
currentGrouping: TaskGrouping | null;
|
|
||||||
|
interface LocalGroupingState {
|
||||||
|
currentGrouping: GroupingType | null;
|
||||||
customPhases: string[];
|
customPhases: string[];
|
||||||
groupOrder: {
|
groupOrder: {
|
||||||
status: string[];
|
status: string[];
|
||||||
@@ -12,10 +14,10 @@ interface GroupingState {
|
|||||||
phase: string[];
|
phase: string[];
|
||||||
};
|
};
|
||||||
groupStates: Record<string, { collapsed: boolean }>;
|
groupStates: Record<string, { collapsed: boolean }>;
|
||||||
collapsedGroups: Set<string>;
|
collapsedGroups: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: GroupingState = {
|
const initialState: LocalGroupingState = {
|
||||||
currentGrouping: null,
|
currentGrouping: null,
|
||||||
customPhases: ['Planning', 'Development', 'Testing', 'Deployment'],
|
customPhases: ['Planning', 'Development', 'Testing', 'Deployment'],
|
||||||
groupOrder: {
|
groupOrder: {
|
||||||
@@ -24,14 +26,14 @@ const initialState: GroupingState = {
|
|||||||
phase: ['Planning', 'Development', 'Testing', 'Deployment'],
|
phase: ['Planning', 'Development', 'Testing', 'Deployment'],
|
||||||
},
|
},
|
||||||
groupStates: {},
|
groupStates: {},
|
||||||
collapsedGroups: new Set(),
|
collapsedGroups: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupingSlice = createSlice({
|
const groupingSlice = createSlice({
|
||||||
name: 'grouping',
|
name: 'grouping',
|
||||||
initialState,
|
initialState,
|
||||||
reducers: {
|
reducers: {
|
||||||
setCurrentGrouping: (state, action: PayloadAction<TaskGrouping | null>) => {
|
setCurrentGrouping: (state, action: PayloadAction<GroupingType | null>) => {
|
||||||
state.currentGrouping = action.payload;
|
state.currentGrouping = action.payload;
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -54,20 +56,19 @@ const groupingSlice = createSlice({
|
|||||||
state.groupOrder.phase = action.payload;
|
state.groupOrder.phase = action.payload;
|
||||||
},
|
},
|
||||||
|
|
||||||
updateGroupOrder: (state, action: PayloadAction<{ groupType: string; order: string[] }>) => {
|
updateGroupOrder: (state, action: PayloadAction<{ groupType: keyof LocalGroupingState['groupOrder']; order: string[] }>) => {
|
||||||
const { groupType, order } = action.payload;
|
const { groupType, order } = action.payload;
|
||||||
state.groupOrder[groupType] = order;
|
state.groupOrder[groupType] = order;
|
||||||
},
|
},
|
||||||
|
|
||||||
toggleGroupCollapsed: (state, action: PayloadAction<string>) => {
|
toggleGroupCollapsed: (state, action: PayloadAction<string>) => {
|
||||||
const groupId = action.payload;
|
const groupId = action.payload;
|
||||||
const collapsedGroups = new Set(state.collapsedGroups);
|
const isCollapsed = state.collapsedGroups.includes(groupId);
|
||||||
if (collapsedGroups.has(groupId)) {
|
if (isCollapsed) {
|
||||||
collapsedGroups.delete(groupId);
|
state.collapsedGroups = state.collapsedGroups.filter(id => id !== groupId);
|
||||||
} else {
|
} else {
|
||||||
collapsedGroups.add(groupId);
|
state.collapsedGroups.push(groupId);
|
||||||
}
|
}
|
||||||
state.collapsedGroups = collapsedGroups;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
setGroupCollapsed: (state, action: PayloadAction<{ groupId: string; collapsed: boolean }>) => {
|
setGroupCollapsed: (state, action: PayloadAction<{ groupId: string; collapsed: boolean }>) => {
|
||||||
@@ -79,11 +80,11 @@ const groupingSlice = createSlice({
|
|||||||
},
|
},
|
||||||
|
|
||||||
collapseAllGroups: (state, action: PayloadAction<string[]>) => {
|
collapseAllGroups: (state, action: PayloadAction<string[]>) => {
|
||||||
state.collapsedGroups = new Set(action.payload);
|
state.collapsedGroups = action.payload;
|
||||||
},
|
},
|
||||||
|
|
||||||
expandAllGroups: state => {
|
expandAllGroups: state => {
|
||||||
state.collapsedGroups = new Set();
|
state.collapsedGroups = [];
|
||||||
},
|
},
|
||||||
|
|
||||||
resetGrouping: () => initialState,
|
resetGrouping: () => initialState,
|
||||||
@@ -108,54 +109,59 @@ export const selectCurrentGrouping = (state: RootState) => state.grouping.curren
|
|||||||
export const selectCustomPhases = (state: RootState) => state.grouping.customPhases;
|
export const selectCustomPhases = (state: RootState) => state.grouping.customPhases;
|
||||||
export const selectGroupOrder = (state: RootState) => state.grouping.groupOrder;
|
export const selectGroupOrder = (state: RootState) => state.grouping.groupOrder;
|
||||||
export const selectGroupStates = (state: RootState) => state.grouping.groupStates;
|
export const selectGroupStates = (state: RootState) => state.grouping.groupStates;
|
||||||
export const selectCollapsedGroups = (state: RootState) => state.grouping.collapsedGroups;
|
export const selectCollapsedGroups = (state: RootState) => new Set(state.grouping.collapsedGroups);
|
||||||
export const selectIsGroupCollapsed = (state: RootState, groupId: string) =>
|
export const selectIsGroupCollapsed = (state: RootState, groupId: string) =>
|
||||||
state.grouping.collapsedGroups.has(groupId);
|
state.grouping.collapsedGroups.includes(groupId);
|
||||||
|
|
||||||
// Complex selectors using createSelector for memoization
|
// Complex selectors using createSelector for memoization
|
||||||
export const selectCurrentGroupOrder = createSelector(
|
export const selectCurrentGroupOrder = createSelector(
|
||||||
[selectCurrentGrouping, selectGroupOrder],
|
[selectCurrentGrouping, selectGroupOrder],
|
||||||
(currentGrouping, groupOrder) => groupOrder[currentGrouping] || []
|
(currentGrouping, groupOrder) => {
|
||||||
|
if (!currentGrouping) return [];
|
||||||
|
return groupOrder[currentGrouping] || [];
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
export const selectTaskGroups = createSelector(
|
export const selectTaskGroups = createSelector(
|
||||||
[selectAllTasks, selectCurrentGrouping, selectCurrentGroupOrder, selectGroupStates],
|
[selectAllTasksArray, selectCurrentGrouping, selectCurrentGroupOrder, selectGroupStates],
|
||||||
(tasks, currentGrouping, groupOrder, groupStates) => {
|
(tasks, currentGrouping, groupOrder, groupStates) => {
|
||||||
const groups: TaskGroup[] = [];
|
const groups: TaskGroup[] = [];
|
||||||
|
|
||||||
|
if (!currentGrouping) return groups;
|
||||||
|
|
||||||
// Get unique values for the current grouping
|
// Get unique values for the current grouping
|
||||||
const groupValues =
|
const groupValues =
|
||||||
groupOrder.length > 0
|
groupOrder.length > 0
|
||||||
? groupOrder
|
? groupOrder
|
||||||
: [
|
: Array.from(new Set(
|
||||||
...new Set(
|
tasks.map(task => {
|
||||||
tasks.map(task => {
|
if (currentGrouping === 'status') return task.status;
|
||||||
if (currentGrouping === 'status') return task.status;
|
if (currentGrouping === 'priority') return task.priority;
|
||||||
if (currentGrouping === 'priority') return task.priority;
|
return task.phase;
|
||||||
return task.phase;
|
})
|
||||||
})
|
));
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
groupValues.forEach(value => {
|
groupValues.forEach(value => {
|
||||||
|
if (!value) return; // Skip undefined values
|
||||||
|
|
||||||
const tasksInGroup = tasks
|
const tasksInGroup = tasks
|
||||||
.filter(task => {
|
.filter(task => {
|
||||||
if (currentGrouping === 'status') return task.status === value;
|
if (currentGrouping === 'status') return task.status === value;
|
||||||
if (currentGrouping === 'priority') return task.priority === value;
|
if (currentGrouping === 'priority') return task.priority === value;
|
||||||
return task.phase === value;
|
return task.phase === value;
|
||||||
})
|
})
|
||||||
.sort((a, b) => a.order - b.order);
|
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||||
|
|
||||||
const groupId = `${currentGrouping}-${value}`;
|
const groupId = `${currentGrouping}-${value}`;
|
||||||
|
|
||||||
groups.push({
|
groups.push({
|
||||||
id: groupId,
|
id: groupId,
|
||||||
title: value.charAt(0).toUpperCase() + value.slice(1),
|
title: value.charAt(0).toUpperCase() + value.slice(1),
|
||||||
groupType: currentGrouping,
|
|
||||||
groupValue: value,
|
|
||||||
collapsed: groupStates[groupId]?.collapsed || false,
|
|
||||||
taskIds: tasksInGroup.map(task => task.id),
|
taskIds: tasksInGroup.map(task => task.id),
|
||||||
|
type: currentGrouping,
|
||||||
color: getGroupColor(currentGrouping, value),
|
color: getGroupColor(currentGrouping, value),
|
||||||
|
collapsed: groupStates[groupId]?.collapsed || false,
|
||||||
|
groupValue: value,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -164,15 +170,17 @@ export const selectTaskGroups = createSelector(
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const selectTasksByCurrentGrouping = createSelector(
|
export const selectTasksByCurrentGrouping = createSelector(
|
||||||
[selectAllTasks, selectCurrentGrouping],
|
[selectAllTasksArray, selectCurrentGrouping],
|
||||||
(tasks, currentGrouping) => {
|
(tasks, currentGrouping) => {
|
||||||
const grouped: Record<string, typeof tasks> = {};
|
const grouped: Record<string, typeof tasks> = {};
|
||||||
|
|
||||||
|
if (!currentGrouping) return grouped;
|
||||||
|
|
||||||
tasks.forEach(task => {
|
tasks.forEach(task => {
|
||||||
let key: string;
|
let key: string;
|
||||||
if (currentGrouping === 'status') key = task.status;
|
if (currentGrouping === 'status') key = task.status;
|
||||||
else if (currentGrouping === 'priority') key = task.priority;
|
else if (currentGrouping === 'priority') key = task.priority;
|
||||||
else key = task.phase;
|
else key = task.phase || 'Development';
|
||||||
|
|
||||||
if (!grouped[key]) grouped[key] = [];
|
if (!grouped[key]) grouped[key] = [];
|
||||||
grouped[key].push(task);
|
grouped[key].push(task);
|
||||||
@@ -180,7 +188,7 @@ export const selectTasksByCurrentGrouping = createSelector(
|
|||||||
|
|
||||||
// Sort tasks within each group by order
|
// Sort tasks within each group by order
|
||||||
Object.keys(grouped).forEach(key => {
|
Object.keys(grouped).forEach(key => {
|
||||||
grouped[key].sort((a, b) => a.order - b.order);
|
grouped[key].sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||||
});
|
});
|
||||||
|
|
||||||
return grouped;
|
return grouped;
|
||||||
@@ -188,7 +196,7 @@ export const selectTasksByCurrentGrouping = createSelector(
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Helper function to get group colors
|
// Helper function to get group colors
|
||||||
const getGroupColor = (groupType: string, value: string): string => {
|
const getGroupColor = (groupType: GroupingType, value: string): string => {
|
||||||
const colorMaps = {
|
const colorMaps = {
|
||||||
status: {
|
status: {
|
||||||
todo: '#f0f0f0',
|
todo: '#f0f0f0',
|
||||||
@@ -209,7 +217,8 @@ const getGroupColor = (groupType: string, value: string): string => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return colorMaps[groupType as keyof typeof colorMaps]?.[value as keyof any] || '#d9d9d9';
|
const colorMap = colorMaps[groupType];
|
||||||
|
return (colorMap as any)?.[value] || '#d9d9d9';
|
||||||
};
|
};
|
||||||
|
|
||||||
export default groupingSlice.reducer;
|
export default groupingSlice.reducer;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
ITaskListV3Response,
|
ITaskListV3Response,
|
||||||
} from '@/api/tasks/tasks.api.service';
|
} from '@/api/tasks/tasks.api.service';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
|
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
||||||
|
|
||||||
// Helper function to safely convert time values
|
// Helper function to safely convert time values
|
||||||
const convertTimeValue = (value: any): number => {
|
const convertTimeValue = (value: any): number => {
|
||||||
@@ -137,7 +138,7 @@ export const fetchTasks = createAsyncThunk(
|
|||||||
group.tasks.map((task: any) => ({
|
group.tasks.map((task: any) => ({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
task_key: task.task_key || '',
|
task_key: task.task_key || '',
|
||||||
title: task.name || '',
|
title: (task.title && task.title.trim()) ? task.title.trim() : DEFAULT_TASK_NAME,
|
||||||
description: task.description || '',
|
description: task.description || '',
|
||||||
status: statusIdToNameMap[task.status] || 'todo',
|
status: statusIdToNameMap[task.status] || 'todo',
|
||||||
priority: priorityIdToNameMap[task.priority] || 'medium',
|
priority: priorityIdToNameMap[task.priority] || 'medium',
|
||||||
@@ -221,13 +222,17 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
|
|
||||||
// Log raw response for debugging
|
// Log raw response for debugging
|
||||||
console.log('Raw API response:', response.body);
|
console.log('Raw API response:', response.body);
|
||||||
|
console.log('Sample task from backend:', response.body.allTasks?.[0]);
|
||||||
|
|
||||||
// Ensure tasks are properly normalized
|
// Ensure tasks are properly normalized
|
||||||
const tasks = response.body.allTasks.map((task: any) => {
|
const tasks = response.body.allTasks.map((task: any) => {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: task.id,
|
id: task.id,
|
||||||
title: task.name || '',
|
title: (task.title && task.title.trim()) ? task.title.trim() : DEFAULT_TASK_NAME,
|
||||||
description: task.description || '',
|
description: task.description || '',
|
||||||
status: task.status || 'todo',
|
status: task.status || 'todo',
|
||||||
priority: task.priority || 'medium',
|
priority: task.priority || 'medium',
|
||||||
|
|||||||
@@ -3,13 +3,21 @@ import { EntityState } from '@reduxjs/toolkit';
|
|||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title?: string; // Make title optional since it can be empty from database
|
||||||
|
name?: string; // Alternative name field
|
||||||
|
task_key?: string; // Task key field
|
||||||
description?: string;
|
description?: string;
|
||||||
status: string;
|
status: string;
|
||||||
priority: string;
|
priority: string;
|
||||||
phase?: string;
|
phase?: string;
|
||||||
assignee?: string;
|
assignee?: string;
|
||||||
|
assignee_names?: InlineMember[]; // Array of assigned members
|
||||||
|
names?: InlineMember[]; // Alternative names field
|
||||||
due_date?: string;
|
due_date?: string;
|
||||||
|
dueDate?: string; // Alternative due date field
|
||||||
|
startDate?: string; // Start date field
|
||||||
|
completedAt?: string; // Completion date
|
||||||
|
updatedAt?: string; // Update timestamp
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
sub_tasks?: Task[];
|
sub_tasks?: Task[];
|
||||||
@@ -27,6 +35,11 @@ export interface Task {
|
|||||||
has_dependencies?: boolean;
|
has_dependencies?: boolean;
|
||||||
schedule_id?: string | null;
|
schedule_id?: string | null;
|
||||||
order?: number;
|
order?: number;
|
||||||
|
reporter?: string; // Reporter field
|
||||||
|
timeTracking?: { // Time tracking information
|
||||||
|
logged?: number;
|
||||||
|
estimated?: number;
|
||||||
|
};
|
||||||
// Add any other task properties as needed
|
// Add any other task properties as needed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,20 @@
|
|||||||
export const tagBackground = (color: string): string => {
|
export const tagBackground = (color: string): string => {
|
||||||
return `${color}1A`; // 1A is 10% opacity in hex
|
return `${color}1A`; // 1A is 10% opacity in hex
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getContrastColor = (hexcolor: string): string => {
|
||||||
|
// If a color is not a valid hex, default to a sensible contrast
|
||||||
|
if (!/^#([A-Fa-f0-9]{3}){1,2}$/.test(hexcolor)) {
|
||||||
|
return '#000000'; // Default to black for invalid colors
|
||||||
|
}
|
||||||
|
|
||||||
|
const r = parseInt(hexcolor.slice(1, 3), 16);
|
||||||
|
const g = parseInt(hexcolor.slice(3, 5), 16);
|
||||||
|
const b = parseInt(hexcolor.slice(5, 7), 16);
|
||||||
|
|
||||||
|
// Perceptual luminance calculation (from WCAG 2.0)
|
||||||
|
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||||
|
|
||||||
|
// Use a threshold to decide between black and white text
|
||||||
|
return luminance > 0.5 ? '#000000' : '#FFFFFF';
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user