diff --git a/docs/enhanced-task-management-technical-guide.md b/docs/enhanced-task-management-technical-guide.md new file mode 100644 index 00000000..af808cd6 --- /dev/null +++ b/docs/enhanced-task-management-technical-guide.md @@ -0,0 +1,429 @@ +# Enhanced Task Management: Technical Guide + +## Overview +The Enhanced Task Management system is a comprehensive React-based interface built on top of WorkLenz's existing task infrastructure. It provides a modern, grouped view with drag-and-drop functionality, bulk operations, and responsive design. + +## Architecture + +### Component Structure +``` +src/components/task-management/ +├── TaskListBoard.tsx # Main container with DnD context +├── TaskGroup.tsx # Individual group with collapse/expand +├── TaskRow.tsx # Task display with rich metadata +├── GroupingSelector.tsx # Grouping method switcher +└── BulkActionBar.tsx # Bulk operations toolbar +``` + +### Integration Points +The system integrates with existing WorkLenz infrastructure: + +- **Redux Store:** Uses `tasks.slice.ts` for state management +- **Types:** Leverages existing TypeScript interfaces +- **API Services:** Works with existing task API endpoints +- **WebSocket:** Supports real-time updates via existing socket system + +## Core Components + +### TaskListBoard.tsx +Main orchestrator component that provides: + +- **DnD Context:** @dnd-kit drag-and-drop functionality +- **State Management:** Redux integration for task data +- **Event Handling:** Drag events and bulk operations +- **Layout Structure:** Header controls and group container + +#### Key Props +```typescript +interface TaskListBoardProps { + projectId: string; // Required: Project identifier + className?: string; // Optional: Additional CSS classes +} +``` + +#### Redux Selectors Used +```typescript +const { + taskGroups, // ITaskListGroup[] - Grouped task data + loadingGroups, // boolean - Loading state + error, // string | null - Error state + groupBy, // IGroupBy - Current grouping method + search, // string | null - Search filter + archived, // boolean - Show archived tasks +} = useSelector((state: RootState) => state.taskReducer); +``` + +### TaskGroup.tsx +Renders individual task groups with: + +- **Collapsible Headers:** Expand/collapse functionality +- **Progress Indicators:** Visual completion progress +- **Drop Zones:** Accept dropped tasks from other groups +- **Group Statistics:** Task counts and completion rates + +#### Key Props +```typescript +interface TaskGroupProps { + group: ITaskListGroup; // Group data with tasks + projectId: string; // Project context + currentGrouping: IGroupBy; // Current grouping mode + selectedTaskIds: string[]; // Selected task IDs + onAddTask?: (groupId: string) => void; + onToggleCollapse?: (groupId: string) => void; +} +``` + +### TaskRow.tsx +Individual task display featuring: + +- **Rich Metadata:** Progress, assignees, labels, due dates +- **Drag Handles:** Sortable within and between groups +- **Selection:** Multi-select with checkboxes +- **Subtask Support:** Expandable hierarchy display + +#### Key Props +```typescript +interface TaskRowProps { + task: IProjectTask; // Task data + projectId: string; // Project context + groupId: string; // Parent group ID + currentGrouping: IGroupBy; // Current grouping mode + isSelected: boolean; // Selection state + isDragOverlay?: boolean; // Drag overlay rendering + index?: number; // Position in group + onSelect?: (taskId: string, selected: boolean) => void; + onToggleSubtasks?: (taskId: string) => void; +} +``` + +## State Management + +### Redux Integration +The system uses existing WorkLenz Redux patterns: + +```typescript +// Primary slice used +import { + fetchTaskGroups, // Async thunk for loading data + reorderTasks, // Update task order/group + setGroup, // Change grouping method + updateTaskStatus, // Update individual task status + updateTaskPriority, // Update individual task priority + // ... other existing actions +} from '@/features/tasks/tasks.slice'; +``` + +### Data Flow +1. **Component Mount:** `TaskListBoard` dispatches `fetchTaskGroups(projectId)` +2. **Group Changes:** `setGroup(newGroupBy)` triggers data reorganization +3. **Drag Operations:** `reorderTasks()` updates task positions and properties +4. **Real-time Updates:** WebSocket events update Redux state automatically + +## Drag and Drop Implementation + +### DnD Kit Integration +Uses @dnd-kit for modern, accessible drag-and-drop: + +```typescript +// Sensors for different input methods +const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { distance: 8 } + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates + }) +); +``` + +### Drag Event Handling +```typescript +const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + // Determine source and target + const sourceGroup = findTaskGroup(active.id); + const targetGroup = findTargetGroup(over?.id); + + // Update task arrays and dispatch changes + dispatch(reorderTasks({ + activeGroupId: sourceGroup.id, + overGroupId: targetGroup.id, + fromIndex: sourceIndex, + toIndex: targetIndex, + task: movedTask, + updatedSourceTasks, + updatedTargetTasks, + })); +}; +``` + +### Smart Property Updates +When tasks are moved between groups, properties update automatically: + +- **Status Grouping:** Moving to "Done" group sets task status to "done" +- **Priority Grouping:** Moving to "High" group sets task priority to "high" +- **Phase Grouping:** Moving to "Testing" group sets task phase to "testing" + +## Bulk Operations + +### Selection State Management +```typescript +// Local state for task selection +const [selectedTaskIds, setSelectedTaskIds] = useState([]); + +// Selection handlers +const handleTaskSelect = (taskId: string, selected: boolean) => { + if (selected) { + setSelectedTaskIds(prev => [...prev, taskId]); + } else { + setSelectedTaskIds(prev => prev.filter(id => id !== taskId)); + } +}; +``` + +### Context-Aware Actions +Bulk actions adapt to current grouping: + +```typescript +// Only show status changes when not grouped by status +{currentGrouping !== 'status' && ( + + + +)} +``` + +## Performance Optimizations + +### Memoized Selectors +```typescript +// Expensive group calculations are memoized +const taskGroups = useMemo(() => { + return createGroupsFromTasks(tasks, currentGrouping); +}, [tasks, currentGrouping]); +``` + +### Virtual Scrolling Ready +For large datasets, the system is prepared for react-window integration: + +```typescript +// Large group detection +const shouldVirtualize = group.tasks.length > 100; + +return shouldVirtualize ? ( + +) : ( + +); +``` + +### Optimistic Updates +UI updates immediately while API calls process in background: + +```typescript +// Immediate UI update +dispatch(updateTaskStatusOptimistically(taskId, newStatus)); + +// API call with rollback on error +try { + await updateTaskStatus(taskId, newStatus); +} catch (error) { + dispatch(rollbackTaskStatusUpdate(taskId)); +} +``` + +## Responsive Design + +### Breakpoint Strategy +```css +/* Mobile-first responsive design */ +.task-row { + padding: 12px; +} + +@media (min-width: 768px) { + .task-row { + padding: 16px; + } +} + +@media (min-width: 1024px) { + .task-row { + padding: 20px; + } +} +``` + +### Progressive Enhancement +- **Mobile:** Essential information only +- **Tablet:** Additional metadata visible +- **Desktop:** Full feature set with optimal layout + +## Accessibility + +### ARIA Implementation +```typescript +// Proper ARIA labels for screen readers +
+ +
+``` + +### Keyboard Navigation +- **Tab:** Navigate between elements +- **Space:** Select/deselect tasks +- **Enter:** Activate buttons +- **Arrows:** Navigate sortable lists with keyboard sensor + +### Focus Management +```typescript +// Maintain focus during dynamic updates +useEffect(() => { + if (shouldFocusTask) { + taskRef.current?.focus(); + } +}, [taskGroups]); +``` + +## WebSocket Integration + +### Real-time Updates +The system subscribes to existing WorkLenz WebSocket events: + +```typescript +// Socket event handlers (existing WorkLenz patterns) +socket.on('TASK_STATUS_CHANGED', (data) => { + dispatch(updateTaskStatus(data)); +}); + +socket.on('TASK_PROGRESS_UPDATED', (data) => { + dispatch(updateTaskProgress(data)); +}); +``` + +### Live Collaboration +- Multiple users can work simultaneously +- Changes appear in real-time +- Conflict resolution through server-side validation + +## API Integration + +### Existing Endpoints Used +```typescript +// Uses existing WorkLenz API services +import { tasksApiService } from '@/api/tasks/tasks.api.service'; + +// Task data fetching +tasksApiService.getTaskList(config); + +// Task updates +tasksApiService.updateTask(taskId, changes); + +// Bulk operations +tasksApiService.bulkUpdateTasks(taskIds, changes); +``` + +### Error Handling +```typescript +try { + await dispatch(fetchTaskGroups(projectId)); +} catch (error) { + // Display user-friendly error message + message.error('Failed to load tasks. Please try again.'); + logger.error('Task loading error:', error); +} +``` + +## Testing Strategy + +### Component Testing +```typescript +// Example test structure +describe('TaskListBoard', () => { + it('should render task groups correctly', () => { + const mockTasks = generateMockTasks(10); + render(); + + expect(screen.getByText('Tasks (10)')).toBeInTheDocument(); + }); + + it('should handle drag and drop operations', async () => { + // Test drag and drop functionality + }); +}); +``` + +### Integration Testing +- Redux state management +- API service integration +- WebSocket event handling +- Drag and drop operations + +## Development Guidelines + +### Code Organization +- Follow existing WorkLenz patterns +- Use TypeScript strictly +- Implement proper error boundaries +- Maintain accessibility standards + +### Performance Considerations +- Memoize expensive calculations +- Implement virtual scrolling for large datasets +- Debounce user input operations +- Optimize re-render cycles + +### Styling Standards +- Use existing Ant Design components +- Follow WorkLenz design system +- Implement responsive breakpoints +- Maintain dark mode compatibility + +## Future Enhancements + +### Planned Features +- Custom column integration +- Advanced filtering capabilities +- Kanban board view +- Enhanced time tracking +- Task templates + +### Extension Points +The system is designed for easy extension: + +```typescript +// Plugin architecture ready +interface TaskViewPlugin { + name: string; + component: React.ComponentType; + supportedGroupings: IGroupBy[]; +} + +const plugins: TaskViewPlugin[] = [ + { name: 'kanban', component: KanbanView, supportedGroupings: ['status'] }, + { name: 'timeline', component: TimelineView, supportedGroupings: ['phase'] }, +]; +``` + +## Deployment Considerations + +### Bundle Size +- Tree-shake unused dependencies +- Code-split large components +- Optimize asset loading + +### Browser Compatibility +- Modern browsers (ES2020+) +- Graceful degradation for older browsers +- Progressive enhancement approach + +### Performance Monitoring +- Track component render times +- Monitor API response times +- Measure user interaction latency \ No newline at end of file diff --git a/docs/enhanced-task-management-user-guide.md b/docs/enhanced-task-management-user-guide.md new file mode 100644 index 00000000..34a50e85 --- /dev/null +++ b/docs/enhanced-task-management-user-guide.md @@ -0,0 +1,275 @@ +# Enhanced Task Management: User Guide + +## What Is Enhanced Task Management? +The Enhanced Task Management system provides a modern, grouped view of your tasks with advanced features like drag-and-drop, bulk operations, and dynamic grouping. This system builds on WorkLenz's existing task infrastructure while offering improved productivity and organization tools. + +## Why Use Enhanced Task Management? +- **Better Organization:** Group tasks by Status, Priority, or Phase for clearer project overview +- **Increased Productivity:** Bulk operations let you update multiple tasks at once +- **Intuitive Interface:** Drag-and-drop functionality makes task management feel natural +- **Rich Task Display:** See progress, assignees, labels, and due dates at a glance +- **Responsive Design:** Works seamlessly on desktop, tablet, and mobile devices + +## Getting Started + +### Accessing Enhanced Task Management +1. Navigate to your project workspace +2. Look for the enhanced task view option in your project interface +3. The system will display your tasks grouped by the current grouping method (default: Status) + +### Understanding the Interface +The enhanced task management interface consists of several key areas: + +- **Header Controls:** Task count, grouping selector, and action buttons +- **Task Groups:** Collapsible sections containing related tasks +- **Individual Tasks:** Rich task cards with metadata and actions +- **Bulk Action Bar:** Appears when multiple tasks are selected (blue bar) + +## Task Grouping + +### Available Grouping Options +You can organize your tasks using three different grouping methods: + +#### 1. Status Grouping (Default) +Groups tasks by their current status: +- **To Do:** Tasks not yet started +- **Doing:** Tasks currently in progress +- **Done:** Completed tasks + +#### 2. Priority Grouping +Groups tasks by their priority level: +- **Critical:** Highest priority, urgent tasks +- **High:** Important tasks requiring attention +- **Medium:** Standard priority tasks +- **Low:** Tasks that can be addressed later + +#### 3. Phase Grouping +Groups tasks by project phases: +- **Planning:** Tasks in the planning stage +- **Development:** Implementation and development tasks +- **Testing:** Quality assurance and testing tasks +- **Deployment:** Release and deployment tasks + +### Switching Between Groupings +1. Locate the "Group by" dropdown in the header controls +2. Select your preferred grouping method (Status, Priority, or Phase) +3. Tasks will automatically reorganize into the new groups +4. Your grouping preference is saved for future sessions + +### Group Features +Each task group includes: +- **Color-coded headers** with visual indicators +- **Task count badges** showing the number of tasks in each group +- **Progress indicators** showing completion percentage +- **Collapse/expand functionality** to hide or show group contents +- **Add task buttons** to quickly create tasks in specific groups + +## Drag and Drop + +### Moving Tasks Within Groups +1. Hover over a task to reveal the drag handle (⋮⋮ icon) +2. Click and hold the drag handle +3. Drag the task to your desired position within the same group +4. Release to drop the task in its new position + +### Moving Tasks Between Groups +1. Click and hold the drag handle on any task +2. Drag the task over a different group +3. The target group will highlight to show it can accept the task +4. Release to drop the task into the new group +5. The task's properties (status, priority, or phase) will automatically update + +### Drag and Drop Benefits +- **Instant Updates:** Task properties change automatically when moved between groups +- **Visual Feedback:** Clear indicators show where tasks can be dropped +- **Keyboard Accessible:** Alternative keyboard controls for accessibility +- **Mobile Friendly:** Touch-friendly drag operations on mobile devices + +## Multi-Select and Bulk Operations + +### Selecting Tasks +You can select multiple tasks using several methods: + +#### Individual Selection +- Click the checkbox next to any task to select it +- Click again to deselect + +#### Range Selection +- Select the first task in your desired range +- Hold Shift and click the last task in the range +- All tasks between the first and last will be selected + +#### Multiple Selection +- Hold Ctrl (or Cmd on Mac) while clicking tasks +- This allows you to select non-consecutive tasks + +### Bulk Actions +When you have tasks selected, a blue bulk action bar appears with these options: + +#### Change Status (when not grouped by Status) +- Update the status of all selected tasks at once +- Choose from available status options in your project + +#### Set Priority (when not grouped by Priority) +- Assign the same priority level to all selected tasks +- Options include Critical, High, Medium, and Low + +#### More Actions +Additional bulk operations include: +- **Assign to Member:** Add team members to multiple tasks +- **Add Labels:** Apply labels to selected tasks +- **Archive Tasks:** Move multiple tasks to archive + +#### Delete Tasks +- Permanently remove multiple tasks at once +- Confirmation dialog prevents accidental deletions + +### Bulk Action Tips +- The bulk action bar only shows relevant options based on your current grouping +- You can clear your selection at any time using the "Clear" button +- Bulk operations provide immediate feedback and can be undone if needed + +## Task Display Features + +### Rich Task Information +Each task displays comprehensive information: + +#### Basic Information +- **Task Key:** Unique identifier (e.g., PROJ-123) +- **Task Name:** Clear, descriptive title +- **Description:** Additional details when available + +#### Visual Indicators +- **Progress Bar:** Shows completion percentage (0-100%) +- **Priority Indicator:** Color-coded dot showing task importance +- **Status Color:** Left border color indicates current status + +#### Team and Collaboration +- **Assignee Avatars:** Profile pictures of assigned team members (up to 3 visible) +- **Labels:** Color-coded tags for categorization +- **Comment Count:** Number of comments and discussions +- **Attachment Count:** Number of files attached to the task + +#### Timing Information +- **Due Dates:** When tasks are scheduled to complete + - Red text: Overdue tasks + - Orange text: Due today or within 3 days + - Gray text: Future due dates +- **Time Tracking:** Estimated vs. logged time when available + +### Subtask Support +Tasks with subtasks include additional features: + +#### Expanding Subtasks +- Click the "+X" button next to task names to expand subtasks +- Subtasks appear indented below the parent task +- Click "−X" to collapse subtasks + +#### Subtask Progress +- Parent task progress reflects completion of all subtasks +- Individual subtask progress is visible when expanded +- Subtask counts show total number of child tasks + +## Advanced Features + +### Real-time Updates +- Changes made by team members appear instantly +- Live collaboration with multiple users +- WebSocket connections ensure data synchronization + +### Search and Filtering +- Use existing project search and filter capabilities +- Enhanced task management respects current filter settings +- Search results maintain grouping organization + +### Responsive Design +The interface adapts to different screen sizes: + +#### Desktop (Large Screens) +- Full feature set with all metadata visible +- Optimal drag-and-drop experience +- Multi-column layouts where appropriate + +#### Tablet (Medium Screens) +- Condensed but functional interface +- Touch-friendly interactions +- Simplified metadata display + +#### Mobile (Small Screens) +- Stacked layout for easy navigation +- Large touch targets for selections +- Essential information prioritized + +## Best Practices + +### Organizing Your Tasks +1. **Choose the Right Grouping:** Select the grouping method that best fits your workflow +2. **Use Labels Consistently:** Apply meaningful labels for better categorization +3. **Keep Groups Balanced:** Avoid having too many tasks in a single group +4. **Regular Maintenance:** Review and update task organization periodically + +### Collaboration Tips +1. **Clear Task Names:** Use descriptive titles that everyone understands +2. **Proper Assignment:** Assign tasks to appropriate team members +3. **Progress Updates:** Keep progress percentages current for accurate project tracking +4. **Use Comments:** Communicate about tasks using the comment system + +### Productivity Techniques +1. **Batch Similar Operations:** Use bulk actions for efficiency +2. **Prioritize Effectively:** Use priority grouping during planning phases +3. **Track Progress:** Monitor completion rates using group progress indicators +4. **Plan Ahead:** Use due dates and time estimates for better scheduling + +## Keyboard Shortcuts + +### Navigation +- **Tab:** Move focus between elements +- **Enter:** Activate focused button or link +- **Esc:** Close open dialogs or clear selections + +### Selection +- **Space:** Select/deselect focused task +- **Shift + Click:** Range selection +- **Ctrl + Click:** Multi-selection (Cmd + Click on Mac) + +### Actions +- **Delete:** Remove selected tasks (with confirmation) +- **Ctrl + A:** Select all visible tasks (Cmd + A on Mac) + +## Troubleshooting + +### Common Issues + +#### Tasks Not Moving Between Groups +- Ensure you have edit permissions for the tasks +- Check that you're dragging from the drag handle (⋮⋮ icon) +- Verify the target group allows the task type + +#### Bulk Actions Not Working +- Confirm tasks are actually selected (checkboxes checked) +- Ensure you have appropriate permissions +- Check that the action is available for your current grouping + +#### Missing Task Information +- Some metadata may be hidden on smaller screens +- Try expanding to full screen or using desktop view +- Check that task has the required information (assignees, labels, etc.) + +### Performance Tips +- For projects with hundreds of tasks, consider using filters to reduce visible tasks +- Collapse groups you're not actively working with +- Clear selections when not performing bulk operations + +## Getting Help +- Contact your workspace administrator for permission-related issues +- Check the main WorkLenz documentation for general task management help +- Report bugs or feature requests through your organization's support channels + +## What's New +This enhanced task management system builds on WorkLenz's solid foundation while adding: +- Modern drag-and-drop interfaces +- Flexible grouping options +- Powerful bulk operation capabilities +- Rich visual task displays +- Mobile-responsive design +- Improved accessibility features \ No newline at end of file diff --git a/worklenz-frontend/src/app/store.ts b/worklenz-frontend/src/app/store.ts index 8fa3cadc..3523ff64 100644 --- a/worklenz-frontend/src/app/store.ts +++ b/worklenz-frontend/src/app/store.ts @@ -73,6 +73,11 @@ import timeReportsOverviewReducer from '@features/reporting/time-reports/time-re import roadmapReducer from '../features/roadmap/roadmap-slice'; import teamMembersReducer from '@features/team-members/team-members.slice'; import groupByFilterDropdownReducer from '../features/group-by-filter-dropdown/group-by-filter-dropdown-slice'; + +// Task Management System +import taskManagementReducer from '@features/task-management/task-management.slice'; +import groupingReducer from '@features/task-management/grouping.slice'; +import selectionReducer from '@features/task-management/selection.slice'; import homePageApiService from '@/api/home-page/home-page.api.service'; import { projectsApi } from '@/api/projects/projects.v1.api.service'; @@ -159,6 +164,11 @@ export const store = configureStore({ roadmapReducer: roadmapReducer, groupByFilterDropdownReducer: groupByFilterDropdownReducer, timeReportsOverviewReducer: timeReportsOverviewReducer, + + // Task Management System + taskManagement: taskManagementReducer, + grouping: groupingReducer, + taskManagementSelection: selectionReducer, }, }); diff --git a/worklenz-frontend/src/components/task-management/BulkActionBar.tsx b/worklenz-frontend/src/components/task-management/BulkActionBar.tsx new file mode 100644 index 00000000..5f4cba8f --- /dev/null +++ b/worklenz-frontend/src/components/task-management/BulkActionBar.tsx @@ -0,0 +1,174 @@ +import React from 'react'; +import { Card, Button, Space, Typography, Dropdown, Menu, Popconfirm, message } from 'antd'; +import { + DeleteOutlined, + EditOutlined, + TagOutlined, + UserOutlined, + CheckOutlined, + CloseOutlined, + MoreOutlined, +} from '@ant-design/icons'; +import { useDispatch, useSelector } from 'react-redux'; +import { IGroupBy, bulkUpdateTasks, bulkDeleteTasks } from '@/features/tasks/tasks.slice'; +import { AppDispatch, RootState } from '@/app/store'; + +const { Text } = Typography; + +interface BulkActionBarProps { + selectedTaskIds: string[]; + totalSelected: number; + currentGrouping: IGroupBy; + projectId: string; + onClearSelection?: () => void; +} + +const BulkActionBar: React.FC = ({ + selectedTaskIds, + totalSelected, + currentGrouping, + projectId, + onClearSelection, +}) => { + const dispatch = useDispatch(); + const { statuses, priorities } = useSelector((state: RootState) => state.taskReducer); + + const handleBulkStatusChange = (statusId: string) => { + // dispatch(bulkUpdateTasks({ ids: selectedTaskIds, changes: { status: statusId } })); + message.success(`Updated ${totalSelected} tasks`); + onClearSelection?.(); + }; + + const handleBulkPriorityChange = (priority: string) => { + // dispatch(bulkUpdateTasks({ ids: selectedTaskIds, changes: { priority } })); + message.success(`Updated ${totalSelected} tasks`); + onClearSelection?.(); + }; + + const handleBulkDelete = () => { + // dispatch(bulkDeleteTasks(selectedTaskIds)); + message.success(`Deleted ${totalSelected} tasks`); + onClearSelection?.(); + }; + + const statusMenu = ( + handleBulkStatusChange(key)} + items={statuses.map(status => ({ + key: status.id!, + label: ( +
+
+ {status.name} +
+ ), + }))} + /> + ); + + const priorityMenu = ( + handleBulkPriorityChange(key)} + items={[ + { key: 'critical', label: 'Critical', icon:
}, + { key: 'high', label: 'High', icon:
}, + { key: 'medium', label: 'Medium', icon:
}, + { key: 'low', label: 'Low', icon:
}, + ]} + /> + ); + + const moreActionsMenu = ( + , + }, + { + key: 'labels', + label: 'Add labels', + icon: , + }, + { + key: 'archive', + label: 'Archive tasks', + icon: , + }, + ]} + /> + ); + + return ( + +
+
+ + {totalSelected} task{totalSelected > 1 ? 's' : ''} selected + +
+ + + {/* Status Change */} + {currentGrouping !== 'status' && ( + + + + )} + + {/* Priority Change */} + {currentGrouping !== 'priority' && ( + + + + )} + + {/* More Actions */} + + + + + {/* Delete */} + 1 ? 's' : ''}?`} + description="This action cannot be undone." + onConfirm={handleBulkDelete} + okText="Delete" + cancelText="Cancel" + okType="danger" + > + + + + {/* Clear Selection */} + + +
+
+ ); +}; + +export default BulkActionBar; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/GroupingSelector.tsx b/worklenz-frontend/src/components/task-management/GroupingSelector.tsx new file mode 100644 index 00000000..ffbc63b1 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/GroupingSelector.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { Select, Typography } from 'antd'; +import { IGroupBy } from '@/features/tasks/tasks.slice'; +import { IGroupByOption } from '@/types/tasks/taskList.types'; + +const { Text } = Typography; +const { Option } = Select; + +interface GroupingSelectorProps { + currentGrouping: IGroupBy; + onChange: (groupBy: IGroupBy) => void; + options: IGroupByOption[]; + disabled?: boolean; +} + +const GroupingSelector: React.FC = ({ + currentGrouping, + onChange, + options, + disabled = false, +}) => { + return ( +
+ Group by: + +
+ ); +}; + +export default GroupingSelector; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/TaskGroup.tsx b/worklenz-frontend/src/components/task-management/TaskGroup.tsx new file mode 100644 index 00000000..9a3fbb1d --- /dev/null +++ b/worklenz-frontend/src/components/task-management/TaskGroup.tsx @@ -0,0 +1,444 @@ +import React, { useState } from 'react'; +import { useDroppable } from '@dnd-kit/core'; +import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable'; +import { useSelector } from 'react-redux'; +import { Button, Typography } from 'antd'; +import { PlusOutlined, RightOutlined, DownOutlined } from '@ant-design/icons'; +import { ITaskListGroup } from '@/types/tasks/taskList.types'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { IGroupBy, COLUMN_KEYS } from '@/features/tasks/tasks.slice'; +import { RootState } from '@/app/store'; +import TaskRow from './TaskRow'; +import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row'; + +const { Text } = Typography; + +interface TaskGroupProps { + group: ITaskListGroup; + projectId: string; + currentGrouping: IGroupBy; + selectedTaskIds: string[]; + onAddTask?: (groupId: string) => void; + onToggleCollapse?: (groupId: string) => void; + onSelectTask?: (taskId: string, selected: boolean) => void; + onToggleSubtasks?: (taskId: string) => void; +} + +const TaskGroup: React.FC = ({ + group, + projectId, + currentGrouping, + selectedTaskIds, + onAddTask, + onToggleCollapse, + onSelectTask, + onToggleSubtasks, +}) => { + const [isCollapsed, setIsCollapsed] = useState(false); + + const { setNodeRef, isOver } = useDroppable({ + id: group.id, + data: { + type: 'group', + groupId: group.id, + }, + }); + + // Get column visibility from Redux store + const columns = useSelector((state: RootState) => state.taskReducer.columns); + + // Helper function to check if a column is visible + const isColumnVisible = (columnKey: string) => { + const column = columns.find(col => col.key === columnKey); + return column ? column.pinned : true; // Default to visible if column not found + }; + + // Get task IDs for sortable context + const taskIds = group.tasks.map(task => task.id!); + + // Calculate group statistics + const completedTasks = group.tasks.filter( + task => task.status_category?.is_done || task.complete_ratio === 100 + ).length; + const totalTasks = group.tasks.length; + const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0; + + // Get group color based on grouping type + const getGroupColor = () => { + if (group.color_code) return group.color_code; + + // Fallback colors based on group value + switch (currentGrouping) { + case 'status': + return group.id === 'todo' ? '#faad14' : group.id === 'doing' ? '#1890ff' : '#52c41a'; + case 'priority': + return group.id === 'critical' + ? '#ff4d4f' + : group.id === 'high' + ? '#fa8c16' + : group.id === 'medium' + ? '#faad14' + : '#52c41a'; + case 'phase': + return '#722ed1'; + default: + return '#d9d9d9'; + } + }; + + const handleToggleCollapse = () => { + setIsCollapsed(!isCollapsed); + onToggleCollapse?.(group.id); + }; + + const handleAddTask = () => { + onAddTask?.(group.id); + }; + + return ( +
+ {/* Group Header Row */} +
+
+
+
+
+
+ + {/* Column Headers */} + {!isCollapsed && totalTasks > 0 && ( +
+
+
+
+
+
+ Key +
+
+ Task +
+
+
+ {isColumnVisible(COLUMN_KEYS.PROGRESS) && ( +
+ Progress +
+ )} + {isColumnVisible(COLUMN_KEYS.ASSIGNEES) && ( +
+ Members +
+ )} + {isColumnVisible(COLUMN_KEYS.LABELS) && ( +
+ Labels +
+ )} + {isColumnVisible(COLUMN_KEYS.STATUS) && ( +
+ Status +
+ )} + {isColumnVisible(COLUMN_KEYS.PRIORITY) && ( +
+ Priority +
+ )} + {isColumnVisible(COLUMN_KEYS.TIME_TRACKING) && ( +
+ Time Tracking +
+ )} +
+
+
+ )} + + {/* Tasks List */} + {!isCollapsed && ( +
+ {group.tasks.length === 0 ? ( +
+
+
+
+ No tasks in this group +
+ +
+
+
+
+ ) : ( + +
+ {group.tasks.map((task, index) => ( + + ))} +
+
+ )} + + {/* Add Task Row - Always show when not collapsed */} +
+ +
+
+ )} + + +
+ ); +}; + +export default TaskGroup; diff --git a/worklenz-frontend/src/components/task-management/TaskListBoard.tsx b/worklenz-frontend/src/components/task-management/TaskListBoard.tsx new file mode 100644 index 00000000..96d92085 --- /dev/null +++ b/worklenz-frontend/src/components/task-management/TaskListBoard.tsx @@ -0,0 +1,359 @@ +import React, { useEffect, useState, useMemo } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { + DndContext, + DragOverlay, + DragStartEvent, + DragEndEvent, + DragOverEvent, + closestCorners, + KeyboardSensor, + PointerSensor, + useSensor, + useSensors, +} from '@dnd-kit/core'; +import { + sortableKeyboardCoordinates, +} from '@dnd-kit/sortable'; +import { Card, Spin, Empty } from 'antd'; +import { RootState } from '@/app/store'; +import { + IGroupBy, + setGroup, + fetchTaskGroups, + reorderTasks, +} from '@/features/tasks/tasks.slice'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import TaskGroup from './TaskGroup'; +import TaskRow from './TaskRow'; +import BulkActionBar from './BulkActionBar'; +import { AppDispatch } from '@/app/store'; + +// Import the TaskListFilters component +const TaskListFilters = React.lazy(() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters')); + +interface TaskListBoardProps { + projectId: string; + className?: string; +} + +interface DragState { + activeTask: IProjectTask | null; + activeGroupId: string | null; +} + +const TaskListBoard: React.FC = ({ projectId, className = '' }) => { + const dispatch = useDispatch(); + const [dragState, setDragState] = useState({ + activeTask: null, + activeGroupId: null, + }); + + // Redux selectors + const { + taskGroups, + loadingGroups, + error, + groupBy, + search, + archived, + } = useSelector((state: RootState) => state.taskReducer); + + // Selection state + const [selectedTaskIds, setSelectedTaskIds] = useState([]); + + // Drag and Drop sensors + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, + }, + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates, + }) + ); + + // Fetch task groups when component mounts or dependencies change + useEffect(() => { + if (projectId) { + dispatch(fetchTaskGroups(projectId)); + } + }, [dispatch, projectId, groupBy, search, archived]); + + // Memoized calculations + const allTaskIds = useMemo(() => { + return taskGroups.flatMap(group => group.tasks.map(task => task.id!)); + }, [taskGroups]); + + const totalTasksCount = useMemo(() => { + return taskGroups.reduce((sum, group) => sum + group.tasks.length, 0); + }, [taskGroups]); + + const hasSelection = selectedTaskIds.length > 0; + + // Handlers + const handleGroupingChange = (newGroupBy: IGroupBy) => { + dispatch(setGroup(newGroupBy)); + }; + + const handleDragStart = (event: DragStartEvent) => { + const { active } = event; + const taskId = active.id as string; + + // Find the task and its group + let activeTask: IProjectTask | null = null; + let activeGroupId: string | null = null; + + for (const group of taskGroups) { + const task = group.tasks.find(t => t.id === taskId); + if (task) { + activeTask = task; + activeGroupId = group.id; + break; + } + } + + setDragState({ + activeTask, + activeGroupId, + }); + }; + + const handleDragOver = (event: DragOverEvent) => { + // Handle drag over logic if needed for visual feedback + }; + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event; + + setDragState({ + activeTask: null, + activeGroupId: null, + }); + + if (!over || !dragState.activeTask || !dragState.activeGroupId) { + return; + } + + const activeTaskId = active.id as string; + const overContainer = over.id as string; + + // Determine if dropping on a group or task + const overGroup = taskGroups.find(g => g.id === overContainer); + let targetGroupId = overContainer; + let targetIndex = -1; + + if (!overGroup) { + // Dropping on a task, find which group it belongs to + for (const group of taskGroups) { + const taskIndex = group.tasks.findIndex(t => t.id === overContainer); + if (taskIndex !== -1) { + targetGroupId = group.id; + targetIndex = taskIndex; + break; + } + } + } + + const sourceGroup = taskGroups.find(g => g.id === dragState.activeGroupId); + const targetGroup = taskGroups.find(g => g.id === targetGroupId); + + if (!sourceGroup || !targetGroup) return; + + const sourceIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId); + if (sourceIndex === -1) return; + + // Calculate new positions + const finalTargetIndex = targetIndex === -1 ? targetGroup.tasks.length : targetIndex; + + // Create updated task arrays + const updatedSourceTasks = [...sourceGroup.tasks]; + const [movedTask] = updatedSourceTasks.splice(sourceIndex, 1); + + let updatedTargetTasks: IProjectTask[]; + if (sourceGroup.id === targetGroup.id) { + // Moving within the same group + updatedTargetTasks = updatedSourceTasks; + updatedTargetTasks.splice(finalTargetIndex, 0, movedTask); + } else { + // Moving between different groups + updatedTargetTasks = [...targetGroup.tasks]; + updatedTargetTasks.splice(finalTargetIndex, 0, movedTask); + } + + // Dispatch the reorder action + dispatch(reorderTasks({ + activeGroupId: sourceGroup.id, + overGroupId: targetGroup.id, + fromIndex: sourceIndex, + toIndex: finalTargetIndex, + task: movedTask, + updatedSourceTasks, + updatedTargetTasks, + })); + }; + + + + const handleSelectTask = (taskId: string, selected: boolean) => { + setSelectedTaskIds(prev => { + if (selected) { + return [...prev, taskId]; + } else { + return prev.filter(id => id !== taskId); + } + }); + }; + + const handleToggleSubtasks = (taskId: string) => { + // Implementation for toggling subtasks + console.log('Toggle subtasks for task:', taskId); + }; + + if (error) { + return ( + + + + ); + } + + return ( +
+ {/* Task Filters */} + + Loading filters...
}> + + + + + {/* Bulk Action Bar */} + {hasSelection && ( + + )} + + {/* Task Groups Container */} +
+ {loadingGroups ? ( + +
+ +
+
+ ) : taskGroups.length === 0 ? ( + + + + ) : ( + +
+ {taskGroups.map((group) => ( + + ))} +
+ + + {dragState.activeTask ? ( + + ) : null} + +
+ )} +
+ + +
+ ); +}; + +export default TaskListBoard; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-management/TaskRow.tsx b/worklenz-frontend/src/components/task-management/TaskRow.tsx new file mode 100644 index 00000000..daa181bf --- /dev/null +++ b/worklenz-frontend/src/components/task-management/TaskRow.tsx @@ -0,0 +1,652 @@ +import React from 'react'; +import { useSortable } from '@dnd-kit/sortable'; +import { CSS } from '@dnd-kit/utilities'; +import { useSelector } from 'react-redux'; +import { Checkbox, Avatar, Tag, Progress, Typography, Space, Button, Tooltip } from 'antd'; +import { + HolderOutlined, + EyeOutlined, + MessageOutlined, + PaperClipOutlined, + ClockCircleOutlined, +} from '@ant-design/icons'; +import { IProjectTask } from '@/types/project/projectTasksViewModel.types'; +import { IGroupBy, COLUMN_KEYS } from '@/features/tasks/tasks.slice'; +import { RootState } from '@/app/store'; + +const { Text } = Typography; + +interface TaskRowProps { + task: IProjectTask; + projectId: string; + groupId: string; + currentGrouping: IGroupBy; + isSelected: boolean; + isDragOverlay?: boolean; + index?: number; + onSelect?: (taskId: string, selected: boolean) => void; + onToggleSubtasks?: (taskId: string) => void; +} + +const TaskRow: React.FC = ({ + task, + projectId, + groupId, + currentGrouping, + isSelected, + isDragOverlay = false, + index, + onSelect, + onToggleSubtasks, +}) => { + const { + attributes, + listeners, + setNodeRef, + transform, + transition, + isDragging, + } = useSortable({ + id: task.id!, + data: { + type: 'task', + taskId: task.id, + groupId, + }, + disabled: isDragOverlay, + }); + + // Get column visibility from Redux store + const columns = useSelector((state: RootState) => state.taskReducer.columns); + + // Helper function to check if a column is visible + const isColumnVisible = (columnKey: string) => { + const column = columns.find(col => col.key === columnKey); + return column ? column.pinned : true; // Default to visible if column not found + }; + + const style = { + transform: CSS.Transform.toString(transform), + transition, + opacity: isDragging ? 0.5 : 1, + }; + + const handleSelectChange = (checked: boolean) => { + onSelect?.(task.id!, checked); + }; + + const handleToggleSubtasks = () => { + onToggleSubtasks?.(task.id!); + }; + + // Format due date + const formatDueDate = (dateString?: string) => { + if (!dateString) return null; + const date = new Date(dateString); + const now = new Date(); + const diffTime = date.getTime() - now.getTime(); + const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + + if (diffDays < 0) { + return { text: `${Math.abs(diffDays)}d overdue`, color: 'error' }; + } else if (diffDays === 0) { + return { text: 'Due today', color: 'warning' }; + } else if (diffDays <= 3) { + return { text: `Due in ${diffDays}d`, color: 'warning' }; + } else { + return { text: `Due ${date.toLocaleDateString()}`, color: 'default' }; + } + }; + + const dueDate = formatDueDate(task.end_date); + + return ( + <> +
+
+ {/* Fixed Columns */} +
+ {/* Drag Handle */} +
+
+ + {/* Selection Checkbox */} +
+ handleSelectChange(e.target.checked)} + /> +
+ + {/* Task Key */} +
+ {task.project_id && task.task_key && ( + + {task.task_key} + + )} +
+ + {/* Task Name */} +
+
+
+ + {task.name} + + {task.sub_tasks_count && task.sub_tasks_count > 0 && ( + + )} +
+
+
+
+ + {/* Scrollable Columns */} +
+ {/* Progress */} + {isColumnVisible(COLUMN_KEYS.PROGRESS) && ( +
+ {task.complete_ratio !== undefined && task.complete_ratio >= 0 && ( +
+ {percent}%} + /> +
+ )} +
+ )} + + {/* Members */} + {isColumnVisible(COLUMN_KEYS.ASSIGNEES) && ( +
+ {task.assignees && task.assignees.length > 0 && ( + + {task.assignees.map((assignee) => ( + + + {assignee.name?.charAt(0)?.toUpperCase()} + + + ))} + + )} +
+ )} + + {/* Labels */} + {isColumnVisible(COLUMN_KEYS.LABELS) && ( +
+ {task.labels && task.labels.length > 0 && ( +
+ {task.labels.slice(0, 3).map((label) => ( + + {label.name} + + ))} + {task.labels.length > 3 && ( + + +{task.labels.length - 3} + + )} +
+ )} +
+ )} + + {/* Status */} + {isColumnVisible(COLUMN_KEYS.STATUS) && ( +
+ {task.status_name && ( +
+ {task.status_name} +
+ )} +
+ )} + + {/* Priority */} + {isColumnVisible(COLUMN_KEYS.PRIORITY) && ( +
+ {task.priority_name && ( +
+
+ {task.priority_name} +
+ )} +
+ )} + + {/* Time Tracking */} + {isColumnVisible(COLUMN_KEYS.TIME_TRACKING) && ( +
+
+ {task.time_spent_string && ( +
+ + {task.time_spent_string} +
+ )} + {/* Task Indicators */} +
+ {task.comments_count && task.comments_count > 0 && ( +
+ + {task.comments_count} +
+ )} + {task.attachments_count && task.attachments_count > 0 && ( +
+ + {task.attachments_count} +
+ )} +
+
+
+ )} +
+
+
+ + {/* Subtasks */} + {task.show_sub_tasks && task.sub_tasks && task.sub_tasks.length > 0 && ( +
+ {task.sub_tasks.map((subtask) => ( + + ))} +
+ )} + + + + ); +}; + +export default TaskRow; \ No newline at end of file diff --git a/worklenz-frontend/src/features/task-management/grouping.slice.ts b/worklenz-frontend/src/features/task-management/grouping.slice.ts new file mode 100644 index 00000000..67c97b19 --- /dev/null +++ b/worklenz-frontend/src/features/task-management/grouping.slice.ts @@ -0,0 +1,189 @@ +import { createSlice, PayloadAction, createSelector } from '@reduxjs/toolkit'; +import { GroupingState, TaskGroup } from '@/types/task-management.types'; +import { RootState } from '@/app/store'; +import { taskManagementSelectors } from './task-management.slice'; + +const initialState: GroupingState = { + currentGrouping: 'status', + customPhases: ['Planning', 'Development', 'Testing', 'Deployment'], + groupOrder: { + status: ['todo', 'doing', 'done'], + priority: ['critical', 'high', 'medium', 'low'], + phase: ['Planning', 'Development', 'Testing', 'Deployment'], + }, + groupStates: {}, +}; + +const groupingSlice = createSlice({ + name: 'grouping', + initialState, + reducers: { + setCurrentGrouping: (state, action: PayloadAction<'status' | 'priority' | 'phase'>) => { + state.currentGrouping = action.payload; + }, + + addCustomPhase: (state, action: PayloadAction) => { + const phase = action.payload.trim(); + if (phase && !state.customPhases.includes(phase)) { + state.customPhases.push(phase); + state.groupOrder.phase.push(phase); + } + }, + + removeCustomPhase: (state, action: PayloadAction) => { + const phase = action.payload; + state.customPhases = state.customPhases.filter(p => p !== phase); + state.groupOrder.phase = state.groupOrder.phase.filter(p => p !== phase); + }, + + updateCustomPhases: (state, action: PayloadAction) => { + state.customPhases = action.payload; + state.groupOrder.phase = action.payload; + }, + + updateGroupOrder: (state, action: PayloadAction<{ groupType: string; order: string[] }>) => { + const { groupType, order } = action.payload; + state.groupOrder[groupType] = order; + }, + + toggleGroupCollapsed: (state, action: PayloadAction) => { + const groupId = action.payload; + if (!state.groupStates[groupId]) { + state.groupStates[groupId] = { collapsed: false }; + } + state.groupStates[groupId].collapsed = !state.groupStates[groupId].collapsed; + }, + + setGroupCollapsed: (state, action: PayloadAction<{ groupId: string; collapsed: boolean }>) => { + const { groupId, collapsed } = action.payload; + if (!state.groupStates[groupId]) { + state.groupStates[groupId] = { collapsed: false }; + } + state.groupStates[groupId].collapsed = collapsed; + }, + + collapseAllGroups: (state) => { + Object.keys(state.groupStates).forEach(groupId => { + state.groupStates[groupId].collapsed = true; + }); + }, + + expandAllGroups: (state) => { + Object.keys(state.groupStates).forEach(groupId => { + state.groupStates[groupId].collapsed = false; + }); + }, + }, +}); + +export const { + setCurrentGrouping, + addCustomPhase, + removeCustomPhase, + updateCustomPhases, + updateGroupOrder, + toggleGroupCollapsed, + setGroupCollapsed, + collapseAllGroups, + expandAllGroups, +} = groupingSlice.actions; + +// Selectors +export const selectCurrentGrouping = (state: RootState) => state.grouping.currentGrouping; +export const selectCustomPhases = (state: RootState) => state.grouping.customPhases; +export const selectGroupOrder = (state: RootState) => state.grouping.groupOrder; +export const selectGroupStates = (state: RootState) => state.grouping.groupStates; + +// Complex selectors using createSelector for memoization +export const selectCurrentGroupOrder = createSelector( + [selectCurrentGrouping, selectGroupOrder], + (currentGrouping, groupOrder) => groupOrder[currentGrouping] || [] +); + +export const selectTaskGroups = createSelector( + [taskManagementSelectors.selectAll, selectCurrentGrouping, selectCurrentGroupOrder, selectGroupStates], + (tasks, currentGrouping, groupOrder, groupStates) => { + const groups: TaskGroup[] = []; + + // Get unique values for the current grouping + const groupValues = groupOrder.length > 0 ? groupOrder : + [...new Set(tasks.map(task => { + if (currentGrouping === 'status') return task.status; + if (currentGrouping === 'priority') return task.priority; + return task.phase; + }))]; + + groupValues.forEach(value => { + const tasksInGroup = tasks.filter(task => { + if (currentGrouping === 'status') return task.status === value; + if (currentGrouping === 'priority') return task.priority === value; + return task.phase === value; + }).sort((a, b) => a.order - b.order); + + const groupId = `${currentGrouping}-${value}`; + + groups.push({ + id: groupId, + title: value.charAt(0).toUpperCase() + value.slice(1), + groupType: currentGrouping, + groupValue: value, + collapsed: groupStates[groupId]?.collapsed || false, + taskIds: tasksInGroup.map(task => task.id), + color: getGroupColor(currentGrouping, value), + }); + }); + + return groups; + } +); + +export const selectTasksByCurrentGrouping = createSelector( + [taskManagementSelectors.selectAll, selectCurrentGrouping], + (tasks, currentGrouping) => { + const grouped: Record = {}; + + tasks.forEach(task => { + let key: string; + if (currentGrouping === 'status') key = task.status; + else if (currentGrouping === 'priority') key = task.priority; + else key = task.phase; + + if (!grouped[key]) grouped[key] = []; + grouped[key].push(task); + }); + + // Sort tasks within each group by order + Object.keys(grouped).forEach(key => { + grouped[key].sort((a, b) => a.order - b.order); + }); + + return grouped; + } +); + +// Helper function to get group colors +const getGroupColor = (groupType: string, value: string): string => { + const colorMaps = { + status: { + todo: '#f0f0f0', + doing: '#1890ff', + done: '#52c41a', + }, + priority: { + critical: '#ff4d4f', + high: '#ff7a45', + medium: '#faad14', + low: '#52c41a', + }, + phase: { + Planning: '#722ed1', + Development: '#1890ff', + Testing: '#faad14', + Deployment: '#52c41a', + }, + }; + + return colorMaps[groupType as keyof typeof colorMaps]?.[value as keyof any] || '#d9d9d9'; +}; + +export default groupingSlice.reducer; \ No newline at end of file diff --git a/worklenz-frontend/src/features/task-management/selection.slice.ts b/worklenz-frontend/src/features/task-management/selection.slice.ts new file mode 100644 index 00000000..3a15485c --- /dev/null +++ b/worklenz-frontend/src/features/task-management/selection.slice.ts @@ -0,0 +1,110 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { SelectionState } from '@/types/task-management.types'; +import { RootState } from '@/app/store'; + +const initialState: SelectionState = { + selectedTaskIds: [], + lastSelectedId: null, +}; + +const selectionSlice = createSlice({ + name: 'selection', + initialState, + reducers: { + toggleTaskSelection: (state, action: PayloadAction) => { + const taskId = action.payload; + const index = state.selectedTaskIds.indexOf(taskId); + + if (index === -1) { + state.selectedTaskIds.push(taskId); + } else { + state.selectedTaskIds.splice(index, 1); + } + + state.lastSelectedId = taskId; + }, + + selectTask: (state, action: PayloadAction) => { + const taskId = action.payload; + if (!state.selectedTaskIds.includes(taskId)) { + state.selectedTaskIds.push(taskId); + } + state.lastSelectedId = taskId; + }, + + deselectTask: (state, action: PayloadAction) => { + const taskId = action.payload; + state.selectedTaskIds = state.selectedTaskIds.filter(id => id !== taskId); + if (state.lastSelectedId === taskId) { + state.lastSelectedId = state.selectedTaskIds[state.selectedTaskIds.length - 1] || null; + } + }, + + selectMultipleTasks: (state, action: PayloadAction) => { + const taskIds = action.payload; + // Add new task IDs that aren't already selected + taskIds.forEach(id => { + if (!state.selectedTaskIds.includes(id)) { + state.selectedTaskIds.push(id); + } + }); + state.lastSelectedId = taskIds[taskIds.length - 1] || state.lastSelectedId; + }, + + selectRangeTasks: (state, action: PayloadAction<{ startId: string; endId: string; allTaskIds: string[] }>) => { + const { startId, endId, allTaskIds } = action.payload; + const startIndex = allTaskIds.indexOf(startId); + const endIndex = allTaskIds.indexOf(endId); + + if (startIndex !== -1 && endIndex !== -1) { + const [start, end] = startIndex <= endIndex ? [startIndex, endIndex] : [endIndex, startIndex]; + const rangeIds = allTaskIds.slice(start, end + 1); + + // Add range IDs that aren't already selected + rangeIds.forEach(id => { + if (!state.selectedTaskIds.includes(id)) { + state.selectedTaskIds.push(id); + } + }); + + state.lastSelectedId = endId; + } + }, + + selectAllTasks: (state, action: PayloadAction) => { + state.selectedTaskIds = action.payload; + state.lastSelectedId = action.payload[action.payload.length - 1] || null; + }, + + clearSelection: (state) => { + state.selectedTaskIds = []; + state.lastSelectedId = null; + }, + + setSelection: (state, action: PayloadAction) => { + state.selectedTaskIds = action.payload; + state.lastSelectedId = action.payload[action.payload.length - 1] || null; + }, + }, +}); + +export const { + toggleTaskSelection, + selectTask, + deselectTask, + selectMultipleTasks, + selectRangeTasks, + selectAllTasks, + clearSelection, + setSelection, +} = selectionSlice.actions; + +// Selectors +export const selectSelectedTaskIds = (state: RootState) => state.taskManagementSelection.selectedTaskIds; +export const selectLastSelectedId = (state: RootState) => state.taskManagementSelection.lastSelectedId; +export const selectHasSelection = (state: RootState) => state.taskManagementSelection.selectedTaskIds.length > 0; +export const selectSelectionCount = (state: RootState) => state.taskManagementSelection.selectedTaskIds.length; +export const selectIsTaskSelected = (taskId: string) => (state: RootState) => + state.taskManagementSelection.selectedTaskIds.includes(taskId); + +export default selectionSlice.reducer; \ No newline at end of file diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts new file mode 100644 index 00000000..6a3a972d --- /dev/null +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -0,0 +1,135 @@ +import { createSlice, createEntityAdapter, PayloadAction } from '@reduxjs/toolkit'; +import { Task, TaskManagementState } from '@/types/task-management.types'; +import { RootState } from '@/app/store'; + +// Entity adapter for normalized state +const tasksAdapter = createEntityAdapter({ + selectId: (task) => task.id, + sortComparer: (a, b) => a.order - b.order, +}); + +const initialState: TaskManagementState = { + entities: {}, + ids: [], + loading: false, + error: null, +}; + +const taskManagementSlice = createSlice({ + name: 'taskManagement', + initialState: tasksAdapter.getInitialState(initialState), + reducers: { + // Basic CRUD operations + setTasks: (state, action: PayloadAction) => { + tasksAdapter.setAll(state, action.payload); + state.loading = false; + state.error = null; + }, + + addTask: (state, action: PayloadAction) => { + tasksAdapter.addOne(state, action.payload); + }, + + updateTask: (state, action: PayloadAction<{ id: string; changes: Partial }>) => { + tasksAdapter.updateOne(state, { + id: action.payload.id, + changes: { + ...action.payload.changes, + updatedAt: new Date().toISOString(), + }, + }); + }, + + deleteTask: (state, action: PayloadAction) => { + tasksAdapter.removeOne(state, action.payload); + }, + + // Bulk operations + bulkUpdateTasks: (state, action: PayloadAction<{ ids: string[]; changes: Partial }>) => { + const { ids, changes } = action.payload; + const updates = ids.map(id => ({ + id, + changes: { + ...changes, + updatedAt: new Date().toISOString(), + }, + })); + tasksAdapter.updateMany(state, updates); + }, + + bulkDeleteTasks: (state, action: PayloadAction) => { + tasksAdapter.removeMany(state, action.payload); + }, + + // Drag and drop operations + reorderTasks: (state, action: PayloadAction<{ taskIds: string[]; newOrder: number[] }>) => { + const { taskIds, newOrder } = action.payload; + const updates = taskIds.map((id, index) => ({ + id, + changes: { order: newOrder[index] }, + })); + tasksAdapter.updateMany(state, updates); + }, + + moveTaskToGroup: (state, action: PayloadAction<{ taskId: string; groupType: 'status' | 'priority' | 'phase'; groupValue: string }>) => { + const { taskId, groupType, groupValue } = action.payload; + const changes: Partial = { + updatedAt: new Date().toISOString(), + }; + + // Update the appropriate field based on group type + if (groupType === 'status') { + changes.status = groupValue as Task['status']; + } else if (groupType === 'priority') { + changes.priority = groupValue as Task['priority']; + } else if (groupType === 'phase') { + changes.phase = groupValue; + } + + tasksAdapter.updateOne(state, { id: taskId, changes }); + }, + + // Loading states + setLoading: (state, action: PayloadAction) => { + state.loading = action.payload; + }, + + setError: (state, action: PayloadAction) => { + state.error = action.payload; + state.loading = false; + }, + }, +}); + +export const { + setTasks, + addTask, + updateTask, + deleteTask, + bulkUpdateTasks, + bulkDeleteTasks, + reorderTasks, + moveTaskToGroup, + setLoading, + setError, +} = taskManagementSlice.actions; + +// Selectors +export const taskManagementSelectors = tasksAdapter.getSelectors( + (state) => state.taskManagement +); + +// Additional selectors +export const selectTasksByStatus = (state: RootState, status: string) => + taskManagementSelectors.selectAll(state).filter(task => task.status === status); + +export const selectTasksByPriority = (state: RootState, priority: string) => + taskManagementSelectors.selectAll(state).filter(task => task.priority === priority); + +export const selectTasksByPhase = (state: RootState, phase: string) => + taskManagementSelectors.selectAll(state).filter(task => task.phase === phase); + +export const selectTasksLoading = (state: RootState) => state.taskManagement.loading; +export const selectTasksError = (state: RootState) => state.taskManagement.error; + +export default taskManagementSlice.reducer; \ No newline at end of file diff --git a/worklenz-frontend/src/lib/project/project-view-constants.ts b/worklenz-frontend/src/lib/project/project-view-constants.ts index fc4b8e87..3957b42e 100644 --- a/worklenz-frontend/src/lib/project/project-view-constants.ts +++ b/worklenz-frontend/src/lib/project/project-view-constants.ts @@ -5,6 +5,7 @@ import ProjectViewMembers from '@/pages/projects/projectView/members/project-vie import ProjectViewUpdates from '@/pages/projects/project-view-1/updates/project-view-updates'; import ProjectViewTaskList from '@/pages/projects/projectView/taskList/project-view-task-list'; import ProjectViewBoard from '@/pages/projects/projectView/board/project-view-board'; +import ProjectViewEnhancedTasks from '@/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks'; // type of a tab items type TabItems = { @@ -22,47 +23,54 @@ export const tabItems: TabItems[] = [ key: 'tasks-list', label: 'Task List', isPinned: true, - element: React.createElement(ProjectViewTaskList), + element: React.createElement(ProjectViewEnhancedTasks), }, { index: 1, + key: 'task-list-v1', + label: 'Task List v1', + isPinned: true, + element: React.createElement(ProjectViewTaskList), + }, + { + index: 2, key: 'board', label: 'Board', isPinned: true, element: React.createElement(ProjectViewBoard), }, // { - // index: 2, + // index: 3, // key: 'workload', // label: 'Workload', // element: React.createElement(ProjectViewWorkload), // }, // { - // index: 3, + // index: 4, // key: 'roadmap', // label: 'Roadmap', // element: React.createElement(ProjectViewRoadmap), // }, { - index: 4, + index: 5, key: 'project-insights-member-overview', label: 'Insights', element: React.createElement(ProjectViewInsights), }, { - index: 5, + index: 6, key: 'all-attachments', label: 'Files', element: React.createElement(ProjectViewFiles), }, { - index: 6, + index: 7, key: 'members', label: 'Members', element: React.createElement(ProjectViewMembers), }, { - index: 7, + index: 8, key: 'updates', label: 'Updates', element: React.createElement(ProjectViewUpdates), diff --git a/worklenz-frontend/src/pages/TaskManagementDemo.tsx b/worklenz-frontend/src/pages/TaskManagementDemo.tsx new file mode 100644 index 00000000..8204f525 --- /dev/null +++ b/worklenz-frontend/src/pages/TaskManagementDemo.tsx @@ -0,0 +1,78 @@ +import React, { useEffect } from 'react'; +import { Layout, Typography, Card, Space, Alert } from 'antd'; +import { useDispatch } from 'react-redux'; +import TaskListBoard from '@/components/task-management/TaskListBoard'; +import { AppDispatch } from '@/app/store'; + +const { Header, Content } = Layout; +const { Title, Paragraph } = Typography; + +const TaskManagementDemo: React.FC = () => { + const dispatch = useDispatch(); + + // Mock project ID for demo + const demoProjectId = 'demo-project-123'; + + useEffect(() => { + // Initialize demo data if needed + // You might want to populate some sample tasks here for demonstration + }, [dispatch]); + + return ( + +
+
+ + Enhanced Task Management System + +
+
+ + + + {/* Introduction */} + + Task Management Features + + This enhanced task management system provides a comprehensive interface for managing tasks + with the following key features: + +
    +
  • Dynamic Grouping: Group tasks by Status, Priority, or Phase
  • +
  • Drag & Drop: Reorder tasks within groups or move between groups
  • +
  • Multi-select: Select multiple tasks for bulk operations
  • +
  • Bulk Actions: Change status, priority, assignees, or delete multiple tasks
  • +
  • Subtasks: Expandable subtask support with progress tracking
  • +
  • Real-time Updates: Live updates via WebSocket connections
  • +
  • Rich Task Display: Progress bars, assignees, labels, due dates, and more
  • +
+
+ + {/* Usage Instructions */} + +

Grouping: Use the dropdown to switch between Status, Priority, and Phase grouping.

+

Drag & Drop: Click and drag tasks to reorder within groups or move between groups.

+

Selection: Click checkboxes to select tasks, then use bulk actions in the blue bar.

+

Subtasks: Click the +/- buttons next to task names to expand/collapse subtasks.

+
+ } + type="info" + showIcon + className="mb-4" + /> + + {/* Task List Board */} + + + + + ); +}; + +export default TaskManagementDemo; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks.tsx b/worklenz-frontend/src/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks.tsx new file mode 100644 index 00000000..c6829889 --- /dev/null +++ b/worklenz-frontend/src/pages/projects/projectView/enhancedTasks/project-view-enhanced-tasks.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import { useAppSelector } from '@/hooks/useAppSelector'; +import TaskListBoard from '@/components/task-management/TaskListBoard'; + +const ProjectViewEnhancedTasks: React.FC = () => { + const { project } = useAppSelector(state => state.projectReducer); + + if (!project?.id) { + return ( +
+ Project not found +
+ ); + } + + return ( +
+ +
+ ); +}; + +export default ProjectViewEnhancedTasks; \ No newline at end of file diff --git a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row.tsx b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row.tsx index 3d8f33d0..b0232907 100644 --- a/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row.tsx +++ b/worklenz-frontend/src/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row.tsx @@ -1,5 +1,7 @@ import Input, { InputRef } from 'antd/es/input'; -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useRef, useState, useEffect } from 'react'; +import { Spin } from 'antd'; +import { LoadingOutlined } from '@ant-design/icons'; import { useAppSelector } from '@/hooks/useAppSelector'; import { colors } from '@/styles/colors'; import { useTranslation } from 'react-i18next'; @@ -31,7 +33,10 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr const [isEdit, setIsEdit] = useState(false); const [taskName, setTaskName] = useState(''); const [creatingTask, setCreatingTask] = useState(false); + const [error, setError] = useState(''); + const [taskCreationTimeout, setTaskCreationTimeout] = useState(null); const taskInputRef = useRef(null); + const containerRef = useRef(null); const dispatch = useAppDispatch(); const currentSession = useAuthService().getCurrentSession(); @@ -43,13 +48,62 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr const customBorderColor = useMemo(() => themeMode === 'dark' && ' border-[#303030]', [themeMode]); const projectId = useAppSelector(state => state.projectReducer.projectId); + // Cleanup timeout on unmount + useEffect(() => { + return () => { + if (taskCreationTimeout) { + clearTimeout(taskCreationTimeout); + } + }; + }, [taskCreationTimeout]); + + // Handle click outside to cancel edit mode + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + isEdit && + !creatingTask && + containerRef.current && + !containerRef.current.contains(event.target as Node) + ) { + cancelEdit(); + } + }; + + if (isEdit) { + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + } + }, [isEdit, creatingTask]); + const createRequestBody = (): ITaskCreateRequest | null => { if (!projectId || !currentSession) return null; const body: ITaskCreateRequest = { - project_id: projectId, + id: '', name: taskName, - reporter_id: currentSession.id, + description: '', + status_id: '', + priority: '', + start_date: '', + end_date: '', + total_hours: 0, + total_minutes: 0, + billable: false, + phase_id: '', + parent_task_id: undefined, + project_id: projectId, team_id: currentSession.team_id, + task_key: '', + labels: [], + assignees: [], + names: [], + sub_tasks_count: 0, + manual_progress: false, + progress_value: null, + weight: null, + reporter_id: currentSession.id, }; const groupBy = getCurrentGroup(); @@ -69,10 +123,14 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr const reset = (scroll = true) => { setIsEdit(false); - setCreatingTask(false); - setTaskName(''); + setError(''); + if (taskCreationTimeout) { + clearTimeout(taskCreationTimeout); + setTaskCreationTimeout(null); + } + setIsEdit(true); setTimeout(() => { @@ -81,6 +139,16 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr }, DRAWER_ANIMATION_INTERVAL); }; + const cancelEdit = () => { + setIsEdit(false); + setTaskName(''); + setError(''); + if (taskCreationTimeout) { + clearTimeout(taskCreationTimeout); + setTaskCreationTimeout(null); + } + }; + const onNewTaskReceived = (task: IAddNewTask) => { if (!groupId) return; @@ -106,49 +174,210 @@ const AddTaskListRow = ({ groupId = null, parentTask = null }: IAddTaskListRowPr }; const addInstantTask = async () => { - if (creatingTask || !projectId || !currentSession || taskName.trim() === '') return; + // Validation + if (creatingTask || !projectId || !currentSession) return; + + const trimmedTaskName = taskName.trim(); + if (trimmedTaskName === '') { + setError('Task name cannot be empty'); + taskInputRef.current?.focus(); + return; + } try { setCreatingTask(true); + setError(''); + const body = createRequestBody(); - if (!body) return; + if (!body) { + setError('Failed to create task. Please try again.'); + setCreatingTask(false); + return; + } + + // Set timeout for task creation (10 seconds) + const timeout = setTimeout(() => { + setCreatingTask(false); + setError('Task creation timed out. Please try again.'); + }, 10000); + + setTaskCreationTimeout(timeout); socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body)); + + // Handle success response socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => { + clearTimeout(timeout); + setTaskCreationTimeout(null); setCreatingTask(false); - onNewTaskReceived(task as IAddNewTask); + + if (task && task.id) { + onNewTaskReceived(task as IAddNewTask); + } else { + setError('Failed to create task. Please try again.'); + } }); + + // Handle error response + socket?.once('error', (errorData: any) => { + clearTimeout(timeout); + setTaskCreationTimeout(null); + setCreatingTask(false); + const errorMessage = errorData?.message || 'Failed to create task'; + setError(errorMessage); + }); + } catch (error) { console.error('Error adding task:', error); setCreatingTask(false); + setError('An unexpected error occurred. Please try again.'); } }; const handleAddTask = () => { - setIsEdit(false); + if (creatingTask) return; // Prevent multiple submissions addInstantTask(); }; + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Escape') { + e.preventDefault(); + cancelEdit(); + } else if (e.key === 'Enter' && !creatingTask) { + e.preventDefault(); + handleAddTask(); + } + }; + + const handleInputChange = (e: React.ChangeEvent) => { + setTaskName(e.target.value); + if (error) setError(''); // Clear error when user starts typing + }; + return ( -
+
{isEdit ? ( - setTaskName(e.target.value)} - onBlur={handleAddTask} - onPressEnter={handleAddTask} - ref={taskInputRef} - /> +
+ + {creatingTask && ( +
+ } + /> +
+ )} + {error && ( +
+ {error} +
+ )} +
) : ( - setIsEdit(true)} - className="w-[300px] border-none" - value={parentTask ? t('addSubTaskText') : t('addTaskText')} - ref={taskInputRef} - /> +
setIsEdit(true)} + > + + {parentTask ? t('addSubTaskText') : t('addTaskText')} + +
)} + +
); }; diff --git a/worklenz-frontend/src/styles/task-management.css b/worklenz-frontend/src/styles/task-management.css new file mode 100644 index 00000000..e1f797c8 --- /dev/null +++ b/worklenz-frontend/src/styles/task-management.css @@ -0,0 +1,601 @@ +/* Task Management System Styles */ + +.task-list-board { + width: 100%; +} + +.task-group { + transition: all 0.2s ease; +} + +.task-group.drag-over { + border-color: #1890ff !important; + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.2); +} + +.task-group .group-header { + background: #fafafa; + border-bottom: 1px solid #f0f0f0; +} + +.task-group .group-header:hover { + background: #f5f5f5; +} + +.task-row { + border-left: 2px solid transparent; + transition: all 0.2s ease; +} + +.task-row:hover { + background-color: #f9f9f9 !important; + border-left-color: #d9d9d9; +} + +.task-row.selected { + background-color: #e6f7ff !important; + border-left-color: #1890ff; +} + +.task-row .drag-handle { + cursor: grab; + transition: opacity 0.2s ease; +} + +.task-row .drag-handle:active { + cursor: grabbing; +} + +.task-row:not(:hover) .drag-handle { + opacity: 0.3; +} + +/* Progress bars */ +.ant-progress-line { + margin: 0; +} + +.ant-progress-bg { + border-radius: 2px; +} + +/* Avatar groups */ +.ant-avatar-group .ant-avatar { + border: 1px solid #fff; +} + +/* Tags */ +.task-row .ant-tag { + margin: 0; + padding: 0 4px; + height: 16px; + line-height: 14px; + font-size: 10px; + border-radius: 2px; +} + +/* Checkboxes */ +.task-row .ant-checkbox-wrapper { + margin-right: 0; +} + +/* Bulk action bar */ +.bulk-action-bar { + position: sticky; + top: 0; + z-index: 10; + background: #e6f7ff; + border: 1px solid #91d5ff; + border-radius: 6px; + margin-bottom: 16px; +} + +/* Collapsible animations */ +.task-group .ant-collapse-content > .ant-collapse-content-box { + padding: 0; +} + +/* Drag overlay */ +.task-row.drag-overlay { + background: white; + border: 1px solid #d9d9d9; + border-radius: 6px; + box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12); + transform: rotate(5deg); + cursor: grabbing; + z-index: 1000; +} + +/* Responsive design */ +@media (max-width: 768px) { + .task-row { + padding: 12px; + } + + .task-row .flex { + flex-direction: column; + align-items: flex-start; + } + + .task-row .task-metadata { + margin-top: 8px; + margin-left: 0 !important; + } +} + +/* Subtask indentation */ +.task-subtasks { + margin-left: 32px; + padding-left: 16px; + border-left: 2px solid #f0f0f0; +} + +.task-subtasks .task-row { + padding: 8px 16px; + font-size: 13px; +} + +/* Status indicators */ +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + margin-right: 8px; +} + +.priority-indicator { + width: 6px; + height: 6px; + border-radius: 50%; + display: inline-block; +} + +/* Animation classes */ +.task-row-enter { + opacity: 0; + transform: translateY(-10px); +} + +.task-row-enter-active { + opacity: 1; + transform: translateY(0); + transition: opacity 300ms, transform 300ms; +} + +.task-row-exit { + opacity: 1; +} + +.task-row-exit-active { + opacity: 0; + transform: translateY(-10px); + transition: opacity 300ms, transform 300ms; +} + +/* Custom scrollbar */ +.task-groups-container::-webkit-scrollbar { + width: 6px; +} + +.task-groups-container::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 3px; +} + +.task-groups-container::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 3px; +} + +.task-groups-container::-webkit-scrollbar-thumb:hover { + background: #a8a8a8; +} + +/* Loading states */ +.task-row.loading { + opacity: 0.6; + pointer-events: none; +} + +.task-group.loading { + opacity: 0.8; +} + +/* Focus styles for accessibility */ +.task-row:focus-within { + outline: 2px solid #1890ff; + outline-offset: 2px; +} + +.drag-handle:focus { + outline: 2px solid #1890ff; + outline-offset: 2px; +} + +/* Dark mode support */ +[data-theme="dark"] .task-list-board { + background-color: #141414; + color: rgba(255, 255, 255, 0.85); +} + +@media (prefers-color-scheme: dark) { + .task-list-board { + background-color: #141414; + color: rgba(255, 255, 255, 0.85); + } + + /* Task Groups */ + .task-group { + background-color: #1f1f1f; + border-color: #303030; + } + + .task-group.drag-over { + border-color: #1890ff !important; + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3); + background-color: rgba(24, 144, 255, 0.1); + } + + .task-group .group-header { + background: #262626; + border-bottom-color: #303030; + color: rgba(255, 255, 255, 0.85); + } + + .task-group .group-header:hover { + background: #2f2f2f; + } + + /* Task Rows */ + .task-row { + background-color: #1f1f1f; + color: rgba(255, 255, 255, 0.85); + border-color: #303030; + } + + .task-row:hover { + background-color: #262626 !important; + border-left-color: #595959; + } + + .task-row.selected { + background-color: rgba(24, 144, 255, 0.15) !important; + border-left-color: #1890ff; + } + + .task-row .drag-handle { + color: rgba(255, 255, 255, 0.45); + } + + .task-row .drag-handle:hover { + color: rgba(255, 255, 255, 0.85); + } + + /* Progress bars */ + .ant-progress-bg { + background-color: #303030; + } + + /* Text colors */ + .task-row .ant-typography { + color: rgba(255, 255, 255, 0.85); + } + + .task-row .text-gray-500 { + color: rgba(255, 255, 255, 0.45) !important; + } + + .task-row .text-gray-600 { + color: rgba(255, 255, 255, 0.65) !important; + } + + .task-row .text-gray-400 { + color: rgba(255, 255, 255, 0.45) !important; + } + + /* Completed task styling */ + .task-row .line-through { + color: rgba(255, 255, 255, 0.45); + } + + /* Bulk Action Bar */ + .bulk-action-bar { + background: rgba(24, 144, 255, 0.15); + border-color: rgba(24, 144, 255, 0.3); + color: rgba(255, 255, 255, 0.85); + } + + /* Cards and containers */ + .ant-card { + background-color: #1f1f1f; + border-color: #303030; + color: rgba(255, 255, 255, 0.85); + } + + .ant-card-head { + background-color: #262626; + border-bottom-color: #303030; + color: rgba(255, 255, 255, 0.85); + } + + .ant-card-body { + background-color: #1f1f1f; + color: rgba(255, 255, 255, 0.85); + } + + /* Buttons */ + .ant-btn { + border-color: #303030; + color: rgba(255, 255, 255, 0.85); + } + + .ant-btn:hover { + border-color: #595959; + color: rgba(255, 255, 255, 0.85); + } + + .ant-btn-primary { + background-color: #1890ff; + border-color: #1890ff; + } + + .ant-btn-primary:hover { + background-color: #40a9ff; + border-color: #40a9ff; + } + + /* Dropdowns and menus */ + .ant-dropdown-menu { + background-color: #1f1f1f; + border-color: #303030; + } + + .ant-dropdown-menu-item { + color: rgba(255, 255, 255, 0.85); + } + + .ant-dropdown-menu-item:hover { + background-color: #262626; + } + + /* Select components */ + .ant-select-selector { + background-color: #1f1f1f !important; + border-color: #303030 !important; + color: rgba(255, 255, 255, 0.85) !important; + } + + .ant-select-arrow { + color: rgba(255, 255, 255, 0.45); + } + + /* Checkboxes */ + .ant-checkbox-wrapper { + color: rgba(255, 255, 255, 0.85); + } + + .ant-checkbox-inner { + background-color: #1f1f1f; + border-color: #303030; + } + + .ant-checkbox-checked .ant-checkbox-inner { + background-color: #1890ff; + border-color: #1890ff; + } + + /* Tags and labels */ + .ant-tag { + background-color: #262626; + border-color: #303030; + color: rgba(255, 255, 255, 0.85); + } + + /* Avatars */ + .ant-avatar { + background-color: #595959; + color: rgba(255, 255, 255, 0.85); + } + + /* Tooltips */ + .ant-tooltip-inner { + background-color: #262626; + color: rgba(255, 255, 255, 0.85); + } + + .ant-tooltip-arrow-content { + background-color: #262626; + } + + /* Popconfirm */ + .ant-popover-inner { + background-color: #1f1f1f; + color: rgba(255, 255, 255, 0.85); + } + + .ant-popover-arrow-content { + background-color: #1f1f1f; + } + + /* Subtasks */ + .task-subtasks { + border-left-color: #303030; + } + + .task-subtasks .task-row { + background-color: #141414; + } + + .task-subtasks .task-row:hover { + background-color: #1f1f1f !important; + } + + /* Scrollbars */ + .task-groups-container::-webkit-scrollbar-track { + background: #141414; + } + + .task-groups-container::-webkit-scrollbar-thumb { + background: #595959; + } + + .task-groups-container::-webkit-scrollbar-thumb:hover { + background: #777777; + } + + /* Loading states */ + .ant-spin-dot-item { + background-color: #1890ff; + } + + /* Empty states */ + .ant-empty { + color: rgba(255, 255, 255, 0.45); + } + + .ant-empty-description { + color: rgba(255, 255, 255, 0.45); + } + + /* Focus styles for dark mode */ + .task-row:focus-within { + outline-color: #40a9ff; + } + + .drag-handle:focus { + outline-color: #40a9ff; + } + + /* Border colors */ + .border-gray-100 { + border-color: #303030 !important; + } + + .border-gray-200 { + border-color: #404040 !important; + } + + .border-gray-300 { + border-color: #595959 !important; + } + + /* Background utilities */ + .bg-gray-50 { + background-color: #141414 !important; + } + + .bg-gray-100 { + background-color: #1f1f1f !important; + } + + .bg-white { + background-color: #1f1f1f !important; + } + + /* Due date colors in dark mode */ + .text-red-500 { + color: #ff7875 !important; + } + + .text-orange-500 { + color: #ffa940 !important; + } + + /* Group progress bar in dark mode */ + .task-group .group-header .bg-gray-200 { + background-color: #303030 !important; + } +} + +/* Specific dark mode styles using data-theme attribute */ +[data-theme="dark"] .task-group { + background-color: #1f1f1f; + border-color: #303030; +} + +[data-theme="dark"] .task-group.drag-over { + border-color: #1890ff !important; + box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3); + background-color: rgba(24, 144, 255, 0.1); +} + +[data-theme="dark"] .task-group .group-header { + background: #262626; + border-bottom-color: #303030; + color: rgba(255, 255, 255, 0.85); +} + +[data-theme="dark"] .task-group .group-header:hover { + background: #2f2f2f; +} + +[data-theme="dark"] .task-row { + background-color: #1f1f1f; + color: rgba(255, 255, 255, 0.85); + border-color: #303030; +} + +[data-theme="dark"] .task-row:hover { + background-color: #262626 !important; + border-left-color: #595959; +} + +[data-theme="dark"] .task-row.selected { + background-color: rgba(24, 144, 255, 0.15) !important; + border-left-color: #1890ff; +} + +[data-theme="dark"] .task-row .drag-handle { + color: rgba(255, 255, 255, 0.45); +} + +[data-theme="dark"] .task-row .drag-handle:hover { + color: rgba(255, 255, 255, 0.85); +} + +[data-theme="dark"] .bulk-action-bar { + background: rgba(24, 144, 255, 0.15); + border-color: rgba(24, 144, 255, 0.3); + color: rgba(255, 255, 255, 0.85); +} + +[data-theme="dark"] .task-row .ant-typography { + color: rgba(255, 255, 255, 0.85); +} + +[data-theme="dark"] .task-row .text-gray-500 { + color: rgba(255, 255, 255, 0.45) !important; +} + +[data-theme="dark"] .task-row .text-gray-600 { + color: rgba(255, 255, 255, 0.65) !important; +} + +[data-theme="dark"] .task-row .text-gray-400 { + color: rgba(255, 255, 255, 0.45) !important; +} + +[data-theme="dark"] .task-row .line-through { + color: rgba(255, 255, 255, 0.45); +} + +[data-theme="dark"] .task-subtasks { + border-left-color: #303030; +} + +[data-theme="dark"] .task-subtasks .task-row { + background-color: #141414; +} + +[data-theme="dark"] .task-subtasks .task-row:hover { + background-color: #1f1f1f !important; +} + +[data-theme="dark"] .text-red-500 { + color: #ff7875 !important; +} + +[data-theme="dark"] .text-orange-500 { + color: #ffa940 !important; +} \ No newline at end of file diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts new file mode 100644 index 00000000..dc048c52 --- /dev/null +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -0,0 +1,125 @@ +export interface Task { + id: string; + title: string; + description?: string; + status: 'todo' | 'doing' | 'done'; + priority: 'critical' | 'high' | 'medium' | 'low'; + phase: string; // Custom phases like 'planning', 'development', 'testing', 'deployment' + progress: number; // 0-100 + assignees: string[]; + labels: string[]; + dueDate?: string; + timeTracking: { + estimated?: number; + logged: number; + }; + customFields: Record; + createdAt: string; + updatedAt: string; + order: number; +} + +export interface TaskGroup { + id: string; + title: string; + groupType: 'status' | 'priority' | 'phase'; + groupValue: string; // The actual value for the group (e.g., 'todo', 'high', 'development') + collapsed: boolean; + taskIds: string[]; + color?: string; // For visual distinction +} + +export interface GroupingConfig { + currentGrouping: 'status' | 'priority' | 'phase'; + customPhases: string[]; // User-defined phases + groupOrder: Record; // Order of groups for each grouping type +} + +export interface Column { + id: string; + title: string; + dataIndex: string; + width: number; + visible: boolean; + editable: boolean; + type: 'text' | 'select' | 'date' | 'progress' | 'tags' | 'users'; +} + +export interface User { + id: string; + name: string; + email: string; + avatar?: string; +} + +export interface Label { + id: string; + name: string; + color: string; +} + +// Redux State Interfaces +export interface TaskManagementState { + entities: Record; + ids: string[]; + loading: boolean; + error: string | null; +} + +export interface TaskGroupsState { + entities: Record; + ids: string[]; +} + +export interface GroupingState { + currentGrouping: 'status' | 'priority' | 'phase'; + customPhases: string[]; + groupOrder: Record; + groupStates: Record; // Persist group states +} + +export interface SelectionState { + selectedTaskIds: string[]; + lastSelectedId: string | null; +} + +export interface ColumnsState { + entities: Record; + ids: string[]; + order: string[]; +} + +export interface UIState { + draggedTaskId: string | null; + bulkActionMode: boolean; + editingCell: { taskId: string; field: string } | null; +} + +// Drag and Drop +export interface DragEndEvent { + active: { + id: string; + data: { + current?: { + taskId: string; + groupId: string; + }; + }; + }; + over: { + id: string; + data: { + current?: { + groupId: string; + type: 'group' | 'task'; + }; + }; + } | null; +} + +// Bulk Actions +export interface BulkAction { + type: 'status' | 'priority' | 'phase' | 'assignee' | 'label' | 'delete'; + value?: any; + taskIds: string[]; +} \ No newline at end of file diff --git a/worklenz-frontend/src/utils/task-management-mock-data.ts b/worklenz-frontend/src/utils/task-management-mock-data.ts new file mode 100644 index 00000000..bd37cb64 --- /dev/null +++ b/worklenz-frontend/src/utils/task-management-mock-data.ts @@ -0,0 +1,166 @@ +import { Task, User, Label } from '@/types/task-management.types'; +import { nanoid } from 'nanoid'; + +// Mock users +export const mockUsers: User[] = [ + { id: '1', name: 'John Doe', email: 'john@example.com', avatar: '' }, + { id: '2', name: 'Jane Smith', email: 'jane@example.com', avatar: '' }, + { id: '3', name: 'Bob Johnson', email: 'bob@example.com', avatar: '' }, + { id: '4', name: 'Alice Brown', email: 'alice@example.com', avatar: '' }, + { id: '5', name: 'Charlie Wilson', email: 'charlie@example.com', avatar: '' }, +]; + +// Mock labels +export const mockLabels: Label[] = [ + { id: '1', name: 'Bug', color: '#ff4d4f' }, + { id: '2', name: 'Feature', color: '#52c41a' }, + { id: '3', name: 'Enhancement', color: '#1890ff' }, + { id: '4', name: 'Documentation', color: '#722ed1' }, + { id: '5', name: 'Urgent', color: '#fa541c' }, + { id: '6', name: 'Research', color: '#faad14' }, +]; + +// Task titles for variety +const taskTitles = [ + 'Implement user authentication system', + 'Design responsive navigation component', + 'Fix CSS styling issues on mobile', + 'Add drag and drop functionality', + 'Optimize database queries', + 'Write unit tests for API endpoints', + 'Update documentation for new features', + 'Refactor legacy code components', + 'Set up CI/CD pipeline', + 'Configure monitoring and logging', + 'Implement real-time notifications', + 'Create user onboarding flow', + 'Add search functionality', + 'Optimize image loading performance', + 'Implement data export feature', + 'Add multi-language support', + 'Create admin dashboard', + 'Fix memory leak in background process', + 'Implement caching strategy', + 'Add email notification system', + 'Create API rate limiting', + 'Implement user roles and permissions', + 'Add file upload functionality', + 'Create backup and restore system', + 'Implement advanced filtering', + 'Add calendar integration', + 'Create reporting dashboard', + 'Implement websocket connections', + 'Add payment processing', + 'Create mobile app version', +]; + +const taskDescriptions = [ + 'This task requires careful consideration of security best practices and user experience.', + 'Need to ensure compatibility across all modern browsers and devices.', + 'Critical bug that affects user workflow and needs immediate attention.', + 'Enhancement to improve overall system performance and user satisfaction.', + 'Research task to explore new technologies and implementation approaches.', + 'Documentation update to keep project information current and accurate.', + 'Refactoring work to improve code maintainability and reduce technical debt.', + 'Testing task to ensure reliability and prevent regression bugs.', +]; + +const statuses: Task['status'][] = ['todo', 'doing', 'done']; +const priorities: Task['priority'][] = ['critical', 'high', 'medium', 'low']; +const phases = ['Planning', 'Development', 'Testing', 'Deployment']; + +function getRandomElement(array: T[]): T { + return array[Math.floor(Math.random() * array.length)]; +} + +function getRandomElements(array: T[], min: number = 0, max?: number): T[] { + const maxCount = max ?? array.length; + const count = Math.floor(Math.random() * (maxCount - min + 1)) + min; + const shuffled = [...array].sort(() => 0.5 - Math.random()); + return shuffled.slice(0, count); +} + +function getRandomProgress(): number { + const progressOptions = [0, 10, 25, 50, 75, 90, 100]; + return getRandomElement(progressOptions); +} + +function getRandomTimeTracking() { + const estimated = Math.floor(Math.random() * 40) + 1; // 1-40 hours + const logged = Math.floor(Math.random() * estimated); // 0 to estimated hours + return { estimated, logged }; +} + +function getRandomDueDate(): string | undefined { + if (Math.random() < 0.7) { // 70% chance of having a due date + const now = new Date(); + const daysToAdd = Math.floor(Math.random() * 30) - 10; // -10 to +20 days from now + const dueDate = new Date(now.getTime() + daysToAdd * 24 * 60 * 60 * 1000); + return dueDate.toISOString().split('T')[0]; + } + return undefined; +} + +export function generateMockTask(index: number): Task { + const now = new Date(); + const createdAt = new Date(now.getTime() - Math.random() * 30 * 24 * 60 * 60 * 1000); // Up to 30 days ago + + return { + id: nanoid(), + title: getRandomElement(taskTitles), + description: Math.random() < 0.8 ? getRandomElement(taskDescriptions) : undefined, + status: getRandomElement(statuses), + priority: getRandomElement(priorities), + phase: getRandomElement(phases), + progress: getRandomProgress(), + assignees: getRandomElements(mockUsers, 0, 3).map(user => user.id), // 0-3 assignees + labels: getRandomElements(mockLabels, 0, 4).map(label => label.id), // 0-4 labels + dueDate: getRandomDueDate(), + timeTracking: getRandomTimeTracking(), + customFields: {}, + createdAt: createdAt.toISOString(), + updatedAt: createdAt.toISOString(), + order: index, + }; +} + +export function generateMockTasks(count: number = 100): Task[] { + return Array.from({ length: count }, (_, index) => generateMockTask(index)); +} + +// Generate tasks with specific distribution for testing +export function generateBalancedMockTasks(count: number = 100): Task[] { + const tasks: Task[] = []; + const statusDistribution = { todo: 0.4, doing: 0.4, done: 0.2 }; + const priorityDistribution = { critical: 0.1, high: 0.3, medium: 0.4, low: 0.2 }; + + for (let i = 0; i < count; i++) { + const task = generateMockTask(i); + + // Distribute statuses + const statusRand = Math.random(); + if (statusRand < statusDistribution.todo) { + task.status = 'todo'; + } else if (statusRand < statusDistribution.todo + statusDistribution.doing) { + task.status = 'doing'; + } else { + task.status = 'done'; + } + + // Distribute priorities + const priorityRand = Math.random(); + if (priorityRand < priorityDistribution.critical) { + task.priority = 'critical'; + } else if (priorityRand < priorityDistribution.critical + priorityDistribution.high) { + task.priority = 'high'; + } else if (priorityRand < priorityDistribution.critical + priorityDistribution.high + priorityDistribution.medium) { + task.priority = 'medium'; + } else { + task.priority = 'low'; + } + + tasks.push(task); + } + + return tasks; +} \ No newline at end of file