Merge pull request #165 from Worklenz/imp/task-list-performance-fixes
Imp/task list performance fixes
This commit is contained in:
429
docs/enhanced-task-management-technical-guide.md
Normal file
429
docs/enhanced-task-management-technical-guide.md
Normal file
@@ -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<string[]>([]);
|
||||
|
||||
// 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' && (
|
||||
<Dropdown overlay={statusMenu}>
|
||||
<Button>Change Status</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
```
|
||||
|
||||
## 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 ? (
|
||||
<VirtualizedTaskList tasks={group.tasks} />
|
||||
) : (
|
||||
<StandardTaskList tasks={group.tasks} />
|
||||
);
|
||||
```
|
||||
|
||||
### 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
|
||||
<div
|
||||
role="button"
|
||||
aria-label={`Move task ${task.name}`}
|
||||
tabIndex={0}
|
||||
{...dragHandleProps}
|
||||
>
|
||||
<DragOutlined />
|
||||
</div>
|
||||
```
|
||||
|
||||
### 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(<TaskListBoard projectId="test-project" />);
|
||||
|
||||
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
|
||||
275
docs/enhanced-task-management-user-guide.md
Normal file
275
docs/enhanced-task-management-user-guide.md
Normal file
@@ -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
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -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<BulkActionBarProps> = ({
|
||||
selectedTaskIds,
|
||||
totalSelected,
|
||||
currentGrouping,
|
||||
projectId,
|
||||
onClearSelection,
|
||||
}) => {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
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 = (
|
||||
<Menu
|
||||
onClick={({ key }) => handleBulkStatusChange(key)}
|
||||
items={statuses.map(status => ({
|
||||
key: status.id!,
|
||||
label: (
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: status.color_code }}
|
||||
/>
|
||||
<span>{status.name}</span>
|
||||
</div>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
|
||||
const priorityMenu = (
|
||||
<Menu
|
||||
onClick={({ key }) => handleBulkPriorityChange(key)}
|
||||
items={[
|
||||
{ key: 'critical', label: 'Critical', icon: <div className="w-2 h-2 rounded-full bg-red-500" /> },
|
||||
{ key: 'high', label: 'High', icon: <div className="w-2 h-2 rounded-full bg-orange-500" /> },
|
||||
{ key: 'medium', label: 'Medium', icon: <div className="w-2 h-2 rounded-full bg-yellow-500" /> },
|
||||
{ key: 'low', label: 'Low', icon: <div className="w-2 h-2 rounded-full bg-green-500" /> },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
const moreActionsMenu = (
|
||||
<Menu
|
||||
items={[
|
||||
{
|
||||
key: 'assign',
|
||||
label: 'Assign to member',
|
||||
icon: <UserOutlined />,
|
||||
},
|
||||
{
|
||||
key: 'labels',
|
||||
label: 'Add labels',
|
||||
icon: <TagOutlined />,
|
||||
},
|
||||
{
|
||||
key: 'archive',
|
||||
label: 'Archive tasks',
|
||||
icon: <EditOutlined />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
className="mb-4 bg-blue-50 border-blue-200"
|
||||
styles={{ body: { padding: '8px 16px' } }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Text strong className="text-blue-700">
|
||||
{totalSelected} task{totalSelected > 1 ? 's' : ''} selected
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
{/* Status Change */}
|
||||
{currentGrouping !== 'status' && (
|
||||
<Dropdown overlay={statusMenu} trigger={['click']}>
|
||||
<Button size="small" icon={<CheckOutlined />}>
|
||||
Change Status
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
{/* Priority Change */}
|
||||
{currentGrouping !== 'priority' && (
|
||||
<Dropdown overlay={priorityMenu} trigger={['click']}>
|
||||
<Button size="small" icon={<EditOutlined />}>
|
||||
Set Priority
|
||||
</Button>
|
||||
</Dropdown>
|
||||
)}
|
||||
|
||||
{/* More Actions */}
|
||||
<Dropdown overlay={moreActionsMenu} trigger={['click']}>
|
||||
<Button size="small" icon={<MoreOutlined />}>
|
||||
More Actions
|
||||
</Button>
|
||||
</Dropdown>
|
||||
|
||||
{/* Delete */}
|
||||
<Popconfirm
|
||||
title={`Delete ${totalSelected} task${totalSelected > 1 ? 's' : ''}?`}
|
||||
description="This action cannot be undone."
|
||||
onConfirm={handleBulkDelete}
|
||||
okText="Delete"
|
||||
cancelText="Cancel"
|
||||
okType="danger"
|
||||
>
|
||||
<Button size="small" danger icon={<DeleteOutlined />}>
|
||||
Delete
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
|
||||
{/* Clear Selection */}
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClearSelection}
|
||||
title="Clear selection"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkActionBar;
|
||||
@@ -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<GroupingSelectorProps> = ({
|
||||
currentGrouping,
|
||||
onChange,
|
||||
options,
|
||||
disabled = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Text className="text-sm text-gray-600">Group by:</Text>
|
||||
<Select
|
||||
value={currentGrouping}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
style={{ minWidth: 100 }}
|
||||
className="capitalize"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Option key={option.value} value={option.value} className="capitalize">
|
||||
{option.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupingSelector;
|
||||
444
worklenz-frontend/src/components/task-management/TaskGroup.tsx
Normal file
444
worklenz-frontend/src/components/task-management/TaskGroup.tsx
Normal file
@@ -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<TaskGroupProps> = ({
|
||||
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 (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
className={`task-group ${isOver ? 'drag-over' : ''}`}
|
||||
style={{
|
||||
backgroundColor: isOver ? '#f0f8ff' : undefined,
|
||||
}}
|
||||
>
|
||||
{/* Group Header Row */}
|
||||
<div className="task-group-header">
|
||||
<div className="task-group-header-row">
|
||||
<div
|
||||
className="task-group-header-content"
|
||||
style={{ backgroundColor: getGroupColor() }}
|
||||
>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={isCollapsed ? <RightOutlined /> : <DownOutlined />}
|
||||
onClick={handleToggleCollapse}
|
||||
className="task-group-header-button"
|
||||
/>
|
||||
<Text strong className="task-group-header-text">
|
||||
{group.name} ({totalTasks})
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column Headers */}
|
||||
{!isCollapsed && totalTasks > 0 && (
|
||||
<div className="task-group-column-headers">
|
||||
<div className="task-group-column-headers-row">
|
||||
<div className="task-table-fixed-columns">
|
||||
<div
|
||||
className="task-table-cell task-table-header-cell"
|
||||
style={{ width: '40px' }}
|
||||
></div>
|
||||
<div
|
||||
className="task-table-cell task-table-header-cell"
|
||||
style={{ width: '40px' }}
|
||||
></div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '80px' }}>
|
||||
<Text className="column-header-text">Key</Text>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '475px' }}>
|
||||
<Text className="column-header-text">Task</Text>
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-table-scrollable-columns">
|
||||
{isColumnVisible(COLUMN_KEYS.PROGRESS) && (
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '90px' }}>
|
||||
<Text className="column-header-text">Progress</Text>
|
||||
</div>
|
||||
)}
|
||||
{isColumnVisible(COLUMN_KEYS.ASSIGNEES) && (
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '150px' }}>
|
||||
<Text className="column-header-text">Members</Text>
|
||||
</div>
|
||||
)}
|
||||
{isColumnVisible(COLUMN_KEYS.LABELS) && (
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '150px' }}>
|
||||
<Text className="column-header-text">Labels</Text>
|
||||
</div>
|
||||
)}
|
||||
{isColumnVisible(COLUMN_KEYS.STATUS) && (
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
||||
<Text className="column-header-text">Status</Text>
|
||||
</div>
|
||||
)}
|
||||
{isColumnVisible(COLUMN_KEYS.PRIORITY) && (
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
||||
<Text className="column-header-text">Priority</Text>
|
||||
</div>
|
||||
)}
|
||||
{isColumnVisible(COLUMN_KEYS.TIME_TRACKING) && (
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '120px' }}>
|
||||
<Text className="column-header-text">Time Tracking</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Tasks List */}
|
||||
{!isCollapsed && (
|
||||
<div className="task-group-body">
|
||||
{group.tasks.length === 0 ? (
|
||||
<div className="task-group-empty">
|
||||
<div className="task-table-fixed-columns">
|
||||
<div style={{ width: '380px', padding: '20px 12px' }}>
|
||||
<div className="text-center text-gray-500">
|
||||
<Text type="secondary">No tasks in this group</Text>
|
||||
<br />
|
||||
<Button
|
||||
type="link"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddTask}
|
||||
className="mt-2"
|
||||
>
|
||||
Add first task
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
|
||||
<div className="task-group-tasks">
|
||||
{group.tasks.map((task, index) => (
|
||||
<TaskRow
|
||||
key={task.id}
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
groupId={group.id}
|
||||
currentGrouping={currentGrouping}
|
||||
isSelected={selectedTaskIds.includes(task.id!)}
|
||||
index={index}
|
||||
onSelect={onSelectTask}
|
||||
onToggleSubtasks={onToggleSubtasks}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
|
||||
{/* Add Task Row - Always show when not collapsed */}
|
||||
<div className="task-group-add-task">
|
||||
<AddTaskListRow groupId={group.id} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.task-group {
|
||||
border: 1px solid var(--task-border-primary, #e8e8e8);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 16px;
|
||||
background: var(--task-bg-primary, white);
|
||||
box-shadow: 0 1px 3px var(--task-shadow, rgba(0, 0, 0, 0.1));
|
||||
overflow-x: auto;
|
||||
overflow-y: visible;
|
||||
transition: all 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.task-group-header {
|
||||
background: var(--task-bg-primary, white);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.task-group-header-row {
|
||||
display: inline-flex;
|
||||
height: auto;
|
||||
max-height: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-group-header-content {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 6px;
|
||||
background-color: #f0f0f0;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.task-group-header-button {
|
||||
color: white !important;
|
||||
padding: 0 !important;
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
margin-right: 8px !important;
|
||||
border: none !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.task-group-header-button:hover {
|
||||
background: rgba(255, 255, 255, 0.2) !important;
|
||||
border-radius: 2px !important;
|
||||
}
|
||||
|
||||
.task-group-header-text {
|
||||
color: white !important;
|
||||
font-size: 13px !important;
|
||||
font-weight: 600 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.task-group-progress {
|
||||
display: flex;
|
||||
height: 20px;
|
||||
align-items: center;
|
||||
background: var(--task-bg-tertiary, #f8f9fa);
|
||||
border-bottom: 1px solid var(--task-border-primary, #e8e8e8);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.task-group-column-headers {
|
||||
background: var(--task-bg-secondary, #f5f5f5);
|
||||
border-bottom: 1px solid var(--task-border-tertiary, #d9d9d9);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.task-group-column-headers-row {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
max-height: 40px;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
min-width: 1200px; /* Ensure minimum width for all columns */
|
||||
}
|
||||
|
||||
.task-table-header-cell {
|
||||
background: var(--task-bg-secondary, #f5f5f5);
|
||||
font-weight: 600;
|
||||
color: var(--task-text-secondary, #595959);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1px solid var(--task-border-tertiary, #d9d9d9);
|
||||
height: 32px;
|
||||
max-height: 32px;
|
||||
overflow: hidden;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.column-header-text {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: var(--task-text-secondary, #595959);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.task-group-body {
|
||||
background: var(--task-bg-primary, white);
|
||||
transition: background-color 0.3s ease;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-group-empty {
|
||||
display: flex;
|
||||
height: 80px;
|
||||
align-items: center;
|
||||
background: var(--task-bg-primary, white);
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
.task-group-tasks {
|
||||
background: var(--task-bg-primary, white);
|
||||
transition: background-color 0.3s ease;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-group-add-task {
|
||||
background: var(--task-bg-primary, white);
|
||||
border-top: 1px solid var(--task-border-secondary, #f0f0f0);
|
||||
transition: all 0.3s ease;
|
||||
padding: 0 12px;
|
||||
width: 100%;
|
||||
min-height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-group-add-task:hover {
|
||||
background: var(--task-hover-bg, #fafafa);
|
||||
}
|
||||
|
||||
.task-table-fixed-columns {
|
||||
display: flex;
|
||||
background: var(--task-bg-secondary, #f5f5f5);
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 11;
|
||||
border-right: 2px solid var(--task-border-primary, #e8e8e8);
|
||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.task-table-scrollable-columns {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-table-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 12px;
|
||||
border-right: 1px solid var(--task-border-secondary, #f0f0f0);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
height: 40px;
|
||||
max-height: 40px;
|
||||
min-height: 40px;
|
||||
overflow: hidden;
|
||||
color: var(--task-text-primary, #262626);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.task-table-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.drag-over {
|
||||
background-color: var(--task-drag-over-bg, #f0f8ff) !important;
|
||||
border-color: var(--task-drag-over-border, #40a9ff) !important;
|
||||
}
|
||||
|
||||
/* Ensure buttons and components fit within row height */
|
||||
.task-group .ant-btn {
|
||||
height: auto;
|
||||
max-height: 32px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.task-group .ant-badge {
|
||||
height: auto;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* Dark mode specific adjustments */
|
||||
.dark .task-group,
|
||||
[data-theme="dark"] .task-group {
|
||||
box-shadow: 0 1px 3px var(--task-shadow, rgba(0, 0, 0, 0.3));
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskGroup;
|
||||
@@ -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<TaskListBoardProps> = ({ projectId, className = '' }) => {
|
||||
const dispatch = useDispatch<AppDispatch>();
|
||||
const [dragState, setDragState] = useState<DragState>({
|
||||
activeTask: null,
|
||||
activeGroupId: null,
|
||||
});
|
||||
|
||||
// Redux selectors
|
||||
const {
|
||||
taskGroups,
|
||||
loadingGroups,
|
||||
error,
|
||||
groupBy,
|
||||
search,
|
||||
archived,
|
||||
} = useSelector((state: RootState) => state.taskReducer);
|
||||
|
||||
// Selection state
|
||||
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([]);
|
||||
|
||||
// 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 (
|
||||
<Card className={className}>
|
||||
<Empty
|
||||
description={`Error loading tasks: ${error}`}
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`task-list-board ${className}`}>
|
||||
{/* Task Filters */}
|
||||
<Card
|
||||
size="small"
|
||||
className="mb-4"
|
||||
styles={{ body: { padding: '12px 16px' } }}
|
||||
>
|
||||
<React.Suspense fallback={<div>Loading filters...</div>}>
|
||||
<TaskListFilters position="list" />
|
||||
</React.Suspense>
|
||||
</Card>
|
||||
|
||||
{/* Bulk Action Bar */}
|
||||
{hasSelection && (
|
||||
<BulkActionBar
|
||||
selectedTaskIds={selectedTaskIds}
|
||||
totalSelected={selectedTaskIds.length}
|
||||
currentGrouping={groupBy}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Task Groups Container */}
|
||||
<div className="task-groups-container">
|
||||
{loadingGroups ? (
|
||||
<Card>
|
||||
<div className="flex justify-center items-center py-8">
|
||||
<Spin size="large" />
|
||||
</div>
|
||||
</Card>
|
||||
) : taskGroups.length === 0 ? (
|
||||
<Card>
|
||||
<Empty
|
||||
description="No tasks found"
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
/>
|
||||
</Card>
|
||||
) : (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCorners}
|
||||
onDragStart={handleDragStart}
|
||||
onDragOver={handleDragOver}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<div className="task-groups">
|
||||
{taskGroups.map((group) => (
|
||||
<TaskGroup
|
||||
key={group.id}
|
||||
group={group}
|
||||
projectId={projectId}
|
||||
currentGrouping={groupBy}
|
||||
selectedTaskIds={selectedTaskIds}
|
||||
onSelectTask={handleSelectTask}
|
||||
onToggleSubtasks={handleToggleSubtasks}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{dragState.activeTask ? (
|
||||
<TaskRow
|
||||
task={dragState.activeTask}
|
||||
projectId={projectId}
|
||||
groupId={dragState.activeGroupId!}
|
||||
currentGrouping={groupBy}
|
||||
isSelected={false}
|
||||
isDragOverlay
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<style>{`
|
||||
.task-groups-container {
|
||||
max-height: calc(100vh - 300px);
|
||||
overflow-y: auto;
|
||||
overflow-x: visible;
|
||||
padding: 8px 8px 8px 0;
|
||||
border-radius: 8px;
|
||||
transition: background-color 0.3s ease;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.task-groups {
|
||||
min-width: fit-content;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
:root {
|
||||
--task-bg-primary: #ffffff;
|
||||
--task-bg-secondary: #f5f5f5;
|
||||
--task-bg-tertiary: #f8f9fa;
|
||||
--task-border-primary: #e8e8e8;
|
||||
--task-border-secondary: #f0f0f0;
|
||||
--task-border-tertiary: #d9d9d9;
|
||||
--task-text-primary: #262626;
|
||||
--task-text-secondary: #595959;
|
||||
--task-text-tertiary: #8c8c8c;
|
||||
--task-shadow: rgba(0, 0, 0, 0.1);
|
||||
--task-hover-bg: #fafafa;
|
||||
--task-selected-bg: #e6f7ff;
|
||||
--task-selected-border: #1890ff;
|
||||
--task-drag-over-bg: #f0f8ff;
|
||||
--task-drag-over-border: #40a9ff;
|
||||
}
|
||||
|
||||
.dark .task-groups-container,
|
||||
[data-theme="dark"] .task-groups-container {
|
||||
--task-bg-primary: #1f1f1f;
|
||||
--task-bg-secondary: #141414;
|
||||
--task-bg-tertiary: #262626;
|
||||
--task-border-primary: #303030;
|
||||
--task-border-secondary: #404040;
|
||||
--task-border-tertiary: #505050;
|
||||
--task-text-primary: #ffffff;
|
||||
--task-text-secondary: #d9d9d9;
|
||||
--task-text-tertiary: #8c8c8c;
|
||||
--task-shadow: rgba(0, 0, 0, 0.3);
|
||||
--task-hover-bg: #2a2a2a;
|
||||
--task-selected-bg: #1a2332;
|
||||
--task-selected-border: #1890ff;
|
||||
--task-drag-over-bg: #1a2332;
|
||||
--task-drag-over-border: #40a9ff;
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListBoard;
|
||||
652
worklenz-frontend/src/components/task-management/TaskRow.tsx
Normal file
652
worklenz-frontend/src/components/task-management/TaskRow.tsx
Normal file
@@ -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<TaskRowProps> = ({
|
||||
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 (
|
||||
<>
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={`task-row ${isSelected ? 'task-row-selected' : ''} ${isDragOverlay ? 'task-row-drag-overlay' : ''}`}
|
||||
>
|
||||
<div className="task-row-content">
|
||||
{/* Fixed Columns */}
|
||||
<div className="task-table-fixed-columns">
|
||||
{/* Drag Handle */}
|
||||
<div className="task-table-cell task-table-cell-drag" style={{ width: '40px' }}>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<HolderOutlined />}
|
||||
className="drag-handle opacity-40 hover:opacity-100 cursor-grab active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Selection Checkbox */}
|
||||
<div className="task-table-cell task-table-cell-checkbox" style={{ width: '40px' }}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => handleSelectChange(e.target.checked)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Task Key */}
|
||||
<div className="task-table-cell task-table-cell-key" style={{ width: '80px' }}>
|
||||
{task.project_id && task.task_key && (
|
||||
<Text code className="task-key">
|
||||
{task.task_key}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Name */}
|
||||
<div className="task-table-cell task-table-cell-task" style={{ width: '475px' }}>
|
||||
<div className="task-content">
|
||||
<div className="task-header">
|
||||
<Text
|
||||
strong
|
||||
className={`task-name ${task.complete_ratio === 100 ? 'task-completed' : ''}`}
|
||||
>
|
||||
{task.name}
|
||||
</Text>
|
||||
{task.sub_tasks_count && task.sub_tasks_count > 0 && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={handleToggleSubtasks}
|
||||
className="subtask-toggle"
|
||||
>
|
||||
{task.show_sub_tasks ? '−' : '+'} {task.sub_tasks_count}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Scrollable Columns */}
|
||||
<div className="task-table-scrollable-columns">
|
||||
{/* Progress */}
|
||||
{isColumnVisible(COLUMN_KEYS.PROGRESS) && (
|
||||
<div className="task-table-cell" style={{ width: '90px' }}>
|
||||
{task.complete_ratio !== undefined && task.complete_ratio >= 0 && (
|
||||
<div className="task-progress">
|
||||
<Progress
|
||||
type="circle"
|
||||
percent={task.complete_ratio}
|
||||
size={32}
|
||||
strokeColor={task.complete_ratio === 100 ? '#52c41a' : '#1890ff'}
|
||||
strokeWidth={4}
|
||||
showInfo={true}
|
||||
format={(percent) => <span style={{ fontSize: '10px', fontWeight: '500' }}>{percent}%</span>}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Members */}
|
||||
{isColumnVisible(COLUMN_KEYS.ASSIGNEES) && (
|
||||
<div className="task-table-cell" style={{ width: '150px' }}>
|
||||
{task.assignees && task.assignees.length > 0 && (
|
||||
<Avatar.Group size="small" maxCount={3}>
|
||||
{task.assignees.map((assignee) => (
|
||||
<Tooltip key={assignee.id} title={assignee.name}>
|
||||
<Avatar
|
||||
size="small"
|
||||
>
|
||||
{assignee.name?.charAt(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Avatar.Group>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Labels */}
|
||||
{isColumnVisible(COLUMN_KEYS.LABELS) && (
|
||||
<div className="task-table-cell" style={{ width: '150px' }}>
|
||||
{task.labels && task.labels.length > 0 && (
|
||||
<div className="task-labels-column">
|
||||
{task.labels.slice(0, 3).map((label) => (
|
||||
<Tag
|
||||
key={label.id}
|
||||
className="task-label"
|
||||
style={{
|
||||
backgroundColor: label.color_code,
|
||||
border: 'none',
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</Tag>
|
||||
))}
|
||||
{task.labels.length > 3 && (
|
||||
<Text type="secondary" className="task-labels-more">
|
||||
+{task.labels.length - 3}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status */}
|
||||
{isColumnVisible(COLUMN_KEYS.STATUS) && (
|
||||
<div className="task-table-cell" style={{ width: '100px' }}>
|
||||
{task.status_name && (
|
||||
<div
|
||||
className="task-status"
|
||||
style={{
|
||||
backgroundColor: task.status_color,
|
||||
color: 'white',
|
||||
}}
|
||||
>
|
||||
{task.status_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Priority */}
|
||||
{isColumnVisible(COLUMN_KEYS.PRIORITY) && (
|
||||
<div className="task-table-cell" style={{ width: '100px' }}>
|
||||
{task.priority_name && (
|
||||
<div className="task-priority">
|
||||
<div
|
||||
className="task-priority-indicator"
|
||||
style={{ backgroundColor: task.priority_color }}
|
||||
/>
|
||||
<Text className="task-priority-text">{task.priority_name}</Text>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Tracking */}
|
||||
{isColumnVisible(COLUMN_KEYS.TIME_TRACKING) && (
|
||||
<div className="task-table-cell" style={{ width: '120px' }}>
|
||||
<div className="task-time-tracking">
|
||||
{task.time_spent_string && (
|
||||
<div className="task-time-spent">
|
||||
<ClockCircleOutlined className="task-time-icon" />
|
||||
<Text className="task-time-text">{task.time_spent_string}</Text>
|
||||
</div>
|
||||
)}
|
||||
{/* Task Indicators */}
|
||||
<div className="task-indicators">
|
||||
{task.comments_count && task.comments_count > 0 && (
|
||||
<div className="task-indicator">
|
||||
<MessageOutlined />
|
||||
<span>{task.comments_count}</span>
|
||||
</div>
|
||||
)}
|
||||
{task.attachments_count && task.attachments_count > 0 && (
|
||||
<div className="task-indicator">
|
||||
<PaperClipOutlined />
|
||||
<span>{task.attachments_count}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtasks */}
|
||||
{task.show_sub_tasks && task.sub_tasks && task.sub_tasks.length > 0 && (
|
||||
<div className="task-subtasks">
|
||||
{task.sub_tasks.map((subtask) => (
|
||||
<TaskRow
|
||||
key={subtask.id}
|
||||
task={subtask}
|
||||
projectId={projectId}
|
||||
groupId={groupId}
|
||||
currentGrouping={currentGrouping}
|
||||
isSelected={isSelected}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.task-row {
|
||||
border-bottom: 1px solid var(--task-border-secondary, #f0f0f0);
|
||||
background: var(--task-bg-primary, white);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.task-row:hover {
|
||||
background-color: var(--task-hover-bg, #fafafa);
|
||||
}
|
||||
|
||||
.task-row-selected {
|
||||
background-color: var(--task-selected-bg, #e6f7ff);
|
||||
border-left: 3px solid var(--task-selected-border, #1890ff);
|
||||
}
|
||||
|
||||
.task-row-drag-overlay {
|
||||
background: var(--task-bg-primary, white);
|
||||
border: 1px solid var(--task-border-tertiary, #d9d9d9);
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 4px 12px var(--task-shadow, rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.task-row-content {
|
||||
display: flex;
|
||||
height: 40px;
|
||||
max-height: 40px;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
min-width: 1200px; /* Ensure minimum width for all columns */
|
||||
}
|
||||
|
||||
.task-table-fixed-columns {
|
||||
display: flex;
|
||||
background: var(--task-bg-primary, white);
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
border-right: 2px solid var(--task-border-primary, #e8e8e8);
|
||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.task-table-scrollable-columns {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-table-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 8px;
|
||||
border-right: 1px solid var(--task-border-secondary, #f0f0f0);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
height: 40px;
|
||||
max-height: 40px;
|
||||
min-height: 40px;
|
||||
overflow: hidden;
|
||||
color: var(--task-text-primary, #262626);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.task-table-cell:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.task-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-bottom: 1px;
|
||||
height: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-key {
|
||||
font-size: 10px;
|
||||
color: var(--task-text-tertiary, #666);
|
||||
background: var(--task-bg-secondary, #f0f0f0);
|
||||
padding: 1px 4px;
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--task-text-primary, #262626);
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.task-completed {
|
||||
text-decoration: line-through;
|
||||
color: var(--task-text-tertiary, #8c8c8c);
|
||||
}
|
||||
|
||||
.subtask-toggle {
|
||||
font-size: 10px;
|
||||
color: var(--task-text-tertiary, #666);
|
||||
padding: 0 4px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.task-labels {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
height: 18px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-label {
|
||||
font-size: 10px;
|
||||
padding: 0 4px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
border-radius: 2px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-label-small {
|
||||
font-size: 9px;
|
||||
padding: 0 3px;
|
||||
height: 14px;
|
||||
line-height: 14px;
|
||||
border-radius: 2px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.task-labels-more {
|
||||
font-size: 10px;
|
||||
color: var(--task-text-tertiary, #8c8c8c);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.task-progress {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.task-progress .ant-progress {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.task-progress-text {
|
||||
font-size: 10px;
|
||||
color: var(--task-text-tertiary, #666);
|
||||
min-width: 24px;
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.task-labels-column {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
flex-wrap: nowrap;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-status {
|
||||
font-size: 10px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 2px;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.task-priority {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.task-priority-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.task-priority-text {
|
||||
font-size: 11px;
|
||||
color: var(--task-text-tertiary, #666);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.task-time-tracking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.task-time-spent {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.task-time-icon {
|
||||
font-size: 10px;
|
||||
color: var(--task-text-tertiary, #8c8c8c);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.task-time-text {
|
||||
font-size: 10px;
|
||||
color: var(--task-text-tertiary, #666);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.task-indicators {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.task-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
font-size: 10px;
|
||||
color: var(--task-text-tertiary, #8c8c8c);
|
||||
transition: color 0.3s ease;
|
||||
}
|
||||
|
||||
.task-subtasks {
|
||||
margin-left: 40px;
|
||||
border-left: 2px solid var(--task-border-secondary, #f0f0f0);
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
opacity: 0.4;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.drag-handle:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Ensure buttons and components fit within row height */
|
||||
.task-row .ant-btn {
|
||||
height: auto;
|
||||
max-height: 24px;
|
||||
padding: 0 4px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.task-row .ant-checkbox-wrapper {
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-row .ant-avatar-group {
|
||||
height: 24px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-row .ant-avatar {
|
||||
width: 24px !important;
|
||||
height: 24px !important;
|
||||
line-height: 24px !important;
|
||||
font-size: 10px !important;
|
||||
}
|
||||
|
||||
.task-row .ant-tag {
|
||||
margin: 0;
|
||||
padding: 0 4px;
|
||||
height: 16px;
|
||||
line-height: 16px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.task-row .ant-progress {
|
||||
margin: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.task-row .ant-progress-line {
|
||||
height: 6px !important;
|
||||
}
|
||||
|
||||
.task-row .ant-progress-bg {
|
||||
height: 6px !important;
|
||||
}
|
||||
|
||||
/* Dark mode specific adjustments for Ant Design components */
|
||||
.dark .task-row .ant-progress-bg,
|
||||
[data-theme="dark"] .task-row .ant-progress-bg {
|
||||
background-color: var(--task-border-primary, #303030) !important;
|
||||
}
|
||||
|
||||
.dark .task-row .ant-checkbox-wrapper,
|
||||
[data-theme="dark"] .task-row .ant-checkbox-wrapper {
|
||||
color: var(--task-text-primary, #ffffff);
|
||||
}
|
||||
|
||||
.dark .task-row .ant-btn,
|
||||
[data-theme="dark"] .task-row .ant-btn {
|
||||
color: var(--task-text-secondary, #d9d9d9);
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.dark .task-row .ant-btn:hover,
|
||||
[data-theme="dark"] .task-row .ant-btn:hover {
|
||||
color: var(--task-text-primary, #ffffff);
|
||||
background-color: var(--task-hover-bg, #2a2a2a);
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskRow;
|
||||
189
worklenz-frontend/src/features/task-management/grouping.slice.ts
Normal file
189
worklenz-frontend/src/features/task-management/grouping.slice.ts
Normal file
@@ -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<string>) => {
|
||||
const phase = action.payload.trim();
|
||||
if (phase && !state.customPhases.includes(phase)) {
|
||||
state.customPhases.push(phase);
|
||||
state.groupOrder.phase.push(phase);
|
||||
}
|
||||
},
|
||||
|
||||
removeCustomPhase: (state, action: PayloadAction<string>) => {
|
||||
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<string[]>) => {
|
||||
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<string>) => {
|
||||
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<string, typeof tasks> = {};
|
||||
|
||||
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;
|
||||
@@ -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<string>) => {
|
||||
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<string>) => {
|
||||
const taskId = action.payload;
|
||||
if (!state.selectedTaskIds.includes(taskId)) {
|
||||
state.selectedTaskIds.push(taskId);
|
||||
}
|
||||
state.lastSelectedId = taskId;
|
||||
},
|
||||
|
||||
deselectTask: (state, action: PayloadAction<string>) => {
|
||||
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<string[]>) => {
|
||||
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<string[]>) => {
|
||||
state.selectedTaskIds = action.payload;
|
||||
state.lastSelectedId = action.payload[action.payload.length - 1] || null;
|
||||
},
|
||||
|
||||
clearSelection: (state) => {
|
||||
state.selectedTaskIds = [];
|
||||
state.lastSelectedId = null;
|
||||
},
|
||||
|
||||
setSelection: (state, action: PayloadAction<string[]>) => {
|
||||
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;
|
||||
@@ -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<Task>({
|
||||
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<Task[]>) => {
|
||||
tasksAdapter.setAll(state, action.payload);
|
||||
state.loading = false;
|
||||
state.error = null;
|
||||
},
|
||||
|
||||
addTask: (state, action: PayloadAction<Task>) => {
|
||||
tasksAdapter.addOne(state, action.payload);
|
||||
},
|
||||
|
||||
updateTask: (state, action: PayloadAction<{ id: string; changes: Partial<Task> }>) => {
|
||||
tasksAdapter.updateOne(state, {
|
||||
id: action.payload.id,
|
||||
changes: {
|
||||
...action.payload.changes,
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
deleteTask: (state, action: PayloadAction<string>) => {
|
||||
tasksAdapter.removeOne(state, action.payload);
|
||||
},
|
||||
|
||||
// Bulk operations
|
||||
bulkUpdateTasks: (state, action: PayloadAction<{ ids: string[]; changes: Partial<Task> }>) => {
|
||||
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<string[]>) => {
|
||||
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<Task> = {
|
||||
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<boolean>) => {
|
||||
state.loading = action.payload;
|
||||
},
|
||||
|
||||
setError: (state, action: PayloadAction<string | null>) => {
|
||||
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<RootState>(
|
||||
(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;
|
||||
@@ -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),
|
||||
|
||||
78
worklenz-frontend/src/pages/TaskManagementDemo.tsx
Normal file
78
worklenz-frontend/src/pages/TaskManagementDemo.tsx
Normal file
@@ -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<AppDispatch>();
|
||||
|
||||
// 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 (
|
||||
<Layout className="min-h-screen bg-gray-50">
|
||||
<Header className="bg-white shadow-sm">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<Title level={2} className="mb-0 text-gray-800">
|
||||
Enhanced Task Management System
|
||||
</Title>
|
||||
</div>
|
||||
</Header>
|
||||
|
||||
<Content className="max-w-7xl mx-auto px-4 py-6 w-full">
|
||||
<Space direction="vertical" size="large" className="w-full">
|
||||
{/* Introduction */}
|
||||
<Card>
|
||||
<Title level={3}>Task Management Features</Title>
|
||||
<Paragraph>
|
||||
This enhanced task management system provides a comprehensive interface for managing tasks
|
||||
with the following key features:
|
||||
</Paragraph>
|
||||
<ul className="list-disc list-inside space-y-1 text-gray-700">
|
||||
<li><strong>Dynamic Grouping:</strong> Group tasks by Status, Priority, or Phase</li>
|
||||
<li><strong>Drag & Drop:</strong> Reorder tasks within groups or move between groups</li>
|
||||
<li><strong>Multi-select:</strong> Select multiple tasks for bulk operations</li>
|
||||
<li><strong>Bulk Actions:</strong> Change status, priority, assignees, or delete multiple tasks</li>
|
||||
<li><strong>Subtasks:</strong> Expandable subtask support with progress tracking</li>
|
||||
<li><strong>Real-time Updates:</strong> Live updates via WebSocket connections</li>
|
||||
<li><strong>Rich Task Display:</strong> Progress bars, assignees, labels, due dates, and more</li>
|
||||
</ul>
|
||||
</Card>
|
||||
|
||||
{/* Usage Instructions */}
|
||||
<Alert
|
||||
message="Demo Instructions"
|
||||
description={
|
||||
<div>
|
||||
<p><strong>Grouping:</strong> Use the dropdown to switch between Status, Priority, and Phase grouping.</p>
|
||||
<p><strong>Drag & Drop:</strong> Click and drag tasks to reorder within groups or move between groups.</p>
|
||||
<p><strong>Selection:</strong> Click checkboxes to select tasks, then use bulk actions in the blue bar.</p>
|
||||
<p><strong>Subtasks:</strong> Click the +/- buttons next to task names to expand/collapse subtasks.</p>
|
||||
</div>
|
||||
}
|
||||
type="info"
|
||||
showIcon
|
||||
className="mb-4"
|
||||
/>
|
||||
|
||||
{/* Task List Board */}
|
||||
<TaskListBoard
|
||||
projectId={demoProjectId}
|
||||
className="task-management-demo"
|
||||
/>
|
||||
</Space>
|
||||
</Content>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskManagementDemo;
|
||||
@@ -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 (
|
||||
<div className="p-4 text-center text-gray-500">
|
||||
Project not found
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="project-view-enhanced-tasks">
|
||||
<TaskListBoard projectId={project.id} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectViewEnhancedTasks;
|
||||
@@ -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<boolean>(false);
|
||||
const [taskName, setTaskName] = useState<string>('');
|
||||
const [creatingTask, setCreatingTask] = useState<boolean>(false);
|
||||
const [error, setError] = useState<string>('');
|
||||
const [taskCreationTimeout, setTaskCreationTimeout] = useState<NodeJS.Timeout | null>(null);
|
||||
const taskInputRef = useRef<InputRef>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(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<HTMLInputElement>) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
cancelEdit();
|
||||
} else if (e.key === 'Enter' && !creatingTask) {
|
||||
e.preventDefault();
|
||||
handleAddTask();
|
||||
}
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setTaskName(e.target.value);
|
||||
if (error) setError(''); // Clear error when user starts typing
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="add-task-row-container" ref={containerRef}>
|
||||
{isEdit ? (
|
||||
<Input
|
||||
className="h-12 w-full rounded-none"
|
||||
style={{ borderColor: colors.skyBlue }}
|
||||
placeholder={t('addTaskInputPlaceholder')}
|
||||
onChange={e => setTaskName(e.target.value)}
|
||||
onBlur={handleAddTask}
|
||||
onPressEnter={handleAddTask}
|
||||
ref={taskInputRef}
|
||||
/>
|
||||
<div className="add-task-input-container">
|
||||
<Input
|
||||
className="add-task-input"
|
||||
style={{
|
||||
borderColor: error ? '#ff4d4f' : colors.skyBlue,
|
||||
paddingRight: creatingTask ? '32px' : '12px'
|
||||
}}
|
||||
placeholder={t('addTaskInputPlaceholder')}
|
||||
value={taskName}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
ref={taskInputRef}
|
||||
autoFocus
|
||||
disabled={creatingTask}
|
||||
/>
|
||||
{creatingTask && (
|
||||
<div className="add-task-loading">
|
||||
<Spin
|
||||
size="small"
|
||||
indicator={<LoadingOutlined style={{ fontSize: 14 }} spin />}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="add-task-error">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Input
|
||||
onFocus={() => setIsEdit(true)}
|
||||
className="w-[300px] border-none"
|
||||
value={parentTask ? t('addSubTaskText') : t('addTaskText')}
|
||||
ref={taskInputRef}
|
||||
/>
|
||||
<div
|
||||
className="add-task-label"
|
||||
onClick={() => setIsEdit(true)}
|
||||
>
|
||||
<span className="add-task-text">
|
||||
{parentTask ? t('addSubTaskText') : t('addTaskText')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<style>{`
|
||||
.add-task-row-container {
|
||||
width: 100%;
|
||||
transition: height 0.3s ease;
|
||||
}
|
||||
|
||||
.add-task-input-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.add-task-input {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid ${colors.skyBlue};
|
||||
font-size: 14px;
|
||||
padding: 0 12px;
|
||||
margin: 2px 0;
|
||||
transition: border-color 0.2s ease, background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.add-task-input:disabled {
|
||||
background-color: var(--task-bg-secondary, #f5f5f5);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.add-task-loading {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.add-task-error {
|
||||
font-size: 12px;
|
||||
color: #ff4d4f;
|
||||
margin-top: 4px;
|
||||
margin-left: 2px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.add-task-label {
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--task-text-tertiary, #8c8c8c);
|
||||
}
|
||||
|
||||
.add-task-label:hover {
|
||||
background: var(--task-hover-bg, #fafafa);
|
||||
border-color: var(--task-border-tertiary, #d9d9d9);
|
||||
color: var(--task-text-secondary, #595959);
|
||||
}
|
||||
|
||||
.add-task-text {
|
||||
font-size: 14px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Dark mode support */
|
||||
.dark .add-task-label,
|
||||
[data-theme="dark"] .add-task-label {
|
||||
color: var(--task-text-tertiary, #8c8c8c);
|
||||
}
|
||||
|
||||
.dark .add-task-label:hover,
|
||||
[data-theme="dark"] .add-task-label:hover {
|
||||
background: var(--task-hover-bg, #2a2a2a);
|
||||
border-color: var(--task-border-tertiary, #505050);
|
||||
color: var(--task-text-secondary, #d9d9d9);
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
601
worklenz-frontend/src/styles/task-management.css
Normal file
601
worklenz-frontend/src/styles/task-management.css
Normal file
@@ -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;
|
||||
}
|
||||
125
worklenz-frontend/src/types/task-management.types.ts
Normal file
125
worklenz-frontend/src/types/task-management.types.ts
Normal file
@@ -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<string, any>;
|
||||
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<string, string[]>; // 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<string, Task>;
|
||||
ids: string[];
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface TaskGroupsState {
|
||||
entities: Record<string, TaskGroup>;
|
||||
ids: string[];
|
||||
}
|
||||
|
||||
export interface GroupingState {
|
||||
currentGrouping: 'status' | 'priority' | 'phase';
|
||||
customPhases: string[];
|
||||
groupOrder: Record<string, string[]>;
|
||||
groupStates: Record<string, { collapsed: boolean }>; // Persist group states
|
||||
}
|
||||
|
||||
export interface SelectionState {
|
||||
selectedTaskIds: string[];
|
||||
lastSelectedId: string | null;
|
||||
}
|
||||
|
||||
export interface ColumnsState {
|
||||
entities: Record<string, Column>;
|
||||
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[];
|
||||
}
|
||||
166
worklenz-frontend/src/utils/task-management-mock-data.ts
Normal file
166
worklenz-frontend/src/utils/task-management-mock-data.ts
Normal file
@@ -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<T>(array: T[]): T {
|
||||
return array[Math.floor(Math.random() * array.length)];
|
||||
}
|
||||
|
||||
function getRandomElements<T>(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;
|
||||
}
|
||||
Reference in New Issue
Block a user