feat(store): integrate task management reducers into the store
- Added taskManagementReducer, groupingReducer, and selectionReducer to the Redux store. - Organized imports and store configuration for better clarity and maintainability.
This commit is contained in:
@@ -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;
|
||||
199
worklenz-frontend/src/components/task-management/TaskGroup.tsx
Normal file
199
worklenz-frontend/src/components/task-management/TaskGroup.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useDroppable } from '@dnd-kit/core';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { Card, Button, Typography, Badge, Collapse, Space, Tooltip } from 'antd';
|
||||
import {
|
||||
CaretRightOutlined,
|
||||
CaretDownOutlined,
|
||||
PlusOutlined,
|
||||
MoreOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { ITaskListGroup, IProjectTask } from '@/types/tasks/taskList.types';
|
||||
import { IGroupBy } from '@/features/tasks/tasks.slice';
|
||||
import TaskRow from './TaskRow';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface TaskGroupProps {
|
||||
group: ITaskListGroup;
|
||||
projectId: string;
|
||||
currentGrouping: IGroupBy;
|
||||
selectedTaskIds: string[];
|
||||
onAddTask?: (groupId: string) => void;
|
||||
onToggleCollapse?: (groupId: string) => void;
|
||||
}
|
||||
|
||||
const TaskGroup: React.FC<TaskGroupProps> = ({
|
||||
group,
|
||||
projectId,
|
||||
currentGrouping,
|
||||
selectedTaskIds,
|
||||
onAddTask,
|
||||
onToggleCollapse,
|
||||
}) => {
|
||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
||||
|
||||
const { setNodeRef, isOver } = useDroppable({
|
||||
id: group.id,
|
||||
data: {
|
||||
type: 'group',
|
||||
groupId: group.id,
|
||||
},
|
||||
});
|
||||
|
||||
// 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 === '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 (
|
||||
<Card
|
||||
ref={setNodeRef}
|
||||
className={`task-group ${isOver ? 'drag-over' : ''}`}
|
||||
style={{
|
||||
borderLeft: `4px solid ${getGroupColor()}`,
|
||||
backgroundColor: isOver ? '#f0f8ff' : undefined,
|
||||
}}
|
||||
styles={{
|
||||
body: { padding: 0 },
|
||||
}}
|
||||
>
|
||||
{/* Group Header */}
|
||||
<div className="group-header px-4 py-3 border-b border-gray-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={isCollapsed ? <CaretRightOutlined /> : <CaretDownOutlined />}
|
||||
onClick={handleToggleCollapse}
|
||||
className="p-0 w-6 h-6 flex items-center justify-center"
|
||||
/>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
style={{ backgroundColor: getGroupColor() }}
|
||||
/>
|
||||
<Text strong className="text-base">
|
||||
{group.name}
|
||||
</Text>
|
||||
<Badge count={totalTasks} showZero style={{ backgroundColor: '#f0f0f0', color: '#666' }} />
|
||||
</div>
|
||||
|
||||
{completionRate > 0 && (
|
||||
<Text type="secondary" className="text-sm">
|
||||
{completionRate}% complete
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Tooltip title="Add task to this group">
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddTask}
|
||||
className="opacity-60 hover:opacity-100"
|
||||
/>
|
||||
</Tooltip>
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<MoreOutlined />}
|
||||
className="opacity-60 hover:opacity-100"
|
||||
/>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Progress Bar */}
|
||||
{totalTasks > 0 && (
|
||||
<div className="mt-2">
|
||||
<div className="w-full bg-gray-200 rounded-full h-1">
|
||||
<div
|
||||
className="h-1 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${completionRate}%`,
|
||||
backgroundColor: getGroupColor(),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tasks List */}
|
||||
{!isCollapsed && (
|
||||
<div className="tasks-container">
|
||||
{group.tasks.length === 0 ? (
|
||||
<div className="p-8 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>
|
||||
) : (
|
||||
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskGroup;
|
||||
@@ -0,0 +1,342 @@
|
||||
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 {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
sortableKeyboardCoordinates,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Card, Button, Select, Space, Typography, Spin, Empty } from 'antd';
|
||||
import { ExpandOutlined, CompressOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { RootState } from '@/app/store';
|
||||
import {
|
||||
IGroupBy,
|
||||
GROUP_BY_OPTIONS,
|
||||
setGroup,
|
||||
fetchTaskGroups,
|
||||
reorderTasks,
|
||||
collapseAllGroups,
|
||||
expandAllGroups,
|
||||
} from '@/features/tasks/tasks.slice';
|
||||
import { IProjectTask, ITaskListGroup } from '@/types/tasks/taskList.types';
|
||||
import TaskGroup from './TaskGroup';
|
||||
import TaskRow from './TaskRow';
|
||||
import BulkActionBar from './BulkActionBar';
|
||||
import GroupingSelector from './GroupingSelector';
|
||||
import { AppDispatch } from '@/app/store';
|
||||
|
||||
const { Title } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
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 (assuming you have a selection slice)
|
||||
// const selectedTaskIds = useSelector((state: RootState) => state.selection?.selectedTaskIds || []);
|
||||
const selectedTaskIds: string[] = []; // Temporary placeholder
|
||||
|
||||
// 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 handleCollapseAll = () => {
|
||||
// This would need to be implemented in the tasks slice
|
||||
// dispatch(collapseAllGroups());
|
||||
};
|
||||
|
||||
const handleExpandAll = () => {
|
||||
// This would need to be implemented in the tasks slice
|
||||
// dispatch(expandAllGroups());
|
||||
};
|
||||
|
||||
const handleRefresh = () => {
|
||||
if (projectId) {
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
}
|
||||
};
|
||||
|
||||
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}`}>
|
||||
{/* Header Controls */}
|
||||
<Card
|
||||
size="small"
|
||||
className="mb-4"
|
||||
styles={{ body: { padding: '12px 16px' } }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<Title level={4} className="mb-0">
|
||||
Tasks ({totalTasksCount})
|
||||
</Title>
|
||||
|
||||
<GroupingSelector
|
||||
currentGrouping={groupBy}
|
||||
onChange={handleGroupingChange}
|
||||
options={GROUP_BY_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<CompressOutlined />}
|
||||
onClick={handleCollapseAll}
|
||||
title="Collapse All Groups"
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<ExpandOutlined />}
|
||||
onClick={handleExpandAll}
|
||||
title="Expand All Groups"
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleRefresh}
|
||||
loading={loadingGroups}
|
||||
title="Refresh"
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Bulk Action Bar */}
|
||||
{hasSelection && (
|
||||
<BulkActionBar
|
||||
selectedTaskIds={selectedTaskIds}
|
||||
totalSelected={selectedTaskIds.length}
|
||||
currentGrouping={groupBy}
|
||||
projectId={projectId}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Task Groups */}
|
||||
<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="space-y-4">
|
||||
{taskGroups.map((group) => (
|
||||
<TaskGroup
|
||||
key={group.id}
|
||||
group={group}
|
||||
projectId={projectId}
|
||||
currentGrouping={groupBy}
|
||||
selectedTaskIds={selectedTaskIds}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<DragOverlay>
|
||||
{dragState.activeTask ? (
|
||||
<TaskRow
|
||||
task={dragState.activeTask}
|
||||
projectId={projectId}
|
||||
groupId={dragState.activeGroupId!}
|
||||
currentGrouping={groupBy}
|
||||
isSelected={false}
|
||||
isDragOverlay
|
||||
/>
|
||||
) : null}
|
||||
</DragOverlay>
|
||||
</DndContext>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskListBoard;
|
||||
285
worklenz-frontend/src/components/task-management/TaskRow.tsx
Normal file
285
worklenz-frontend/src/components/task-management/TaskRow.tsx
Normal file
@@ -0,0 +1,285 @@
|
||||
import React from 'react';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Checkbox, Avatar, Tag, Progress, Typography, Space, Button, Tooltip } from 'antd';
|
||||
import {
|
||||
DragOutlined,
|
||||
EyeOutlined,
|
||||
MessageOutlined,
|
||||
PaperClipOutlined,
|
||||
ClockCircleOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { IProjectTask } from '@/types/tasks/taskList.types';
|
||||
import { IGroupBy } from '@/features/tasks/tasks.slice';
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
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 p-4 hover:bg-gray-50 ${isSelected ? 'bg-blue-50 border-l-2 border-l-blue-500' : ''} ${isDragOverlay ? 'shadow-lg bg-white rounded-md border' : ''}`}
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{/* Drag Handle */}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<DragOutlined />}
|
||||
className="drag-handle opacity-40 hover:opacity-100 cursor-grab active:cursor-grabbing"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
|
||||
{/* Selection Checkbox */}
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onChange={(e) => handleSelectChange(e.target.checked)}
|
||||
/>
|
||||
|
||||
{/* Task Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Task Name and Key */}
|
||||
<div className="flex items-center space-x-2 mb-1">
|
||||
{task.project_id && (
|
||||
<Text code className="text-xs text-gray-500">
|
||||
{task.key}
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
strong
|
||||
className={`text-sm ${task.complete_ratio === 100 ? 'line-through text-gray-500' : ''}`}
|
||||
>
|
||||
{task.name}
|
||||
</Text>
|
||||
{task.sub_tasks_count && task.sub_tasks_count > 0 && (
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
onClick={handleToggleSubtasks}
|
||||
className="text-xs text-gray-500 px-1 h-5"
|
||||
>
|
||||
{task.show_sub_tasks ? '−' : '+'} {task.sub_tasks_count}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description (if exists) */}
|
||||
{task.description && (
|
||||
<Text type="secondary" className="text-xs line-clamp-1">
|
||||
{task.description}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Labels */}
|
||||
{task.labels && task.labels.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1">
|
||||
{task.labels.slice(0, 3).map((label) => (
|
||||
<Tag
|
||||
key={label.id}
|
||||
color={label.color_code}
|
||||
className="text-xs m-0 px-1 py-0 text-white"
|
||||
style={{
|
||||
backgroundColor: label.color_code,
|
||||
border: 'none',
|
||||
fontSize: '10px',
|
||||
}}
|
||||
>
|
||||
{label.name}
|
||||
</Tag>
|
||||
))}
|
||||
{task.labels.length > 3 && (
|
||||
<Text type="secondary" className="text-xs">
|
||||
+{task.labels.length - 3} more
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Metadata */}
|
||||
<div className="flex items-center space-x-3 ml-4">
|
||||
{/* Progress */}
|
||||
{task.complete_ratio !== undefined && task.complete_ratio > 0 && (
|
||||
<div className="w-16">
|
||||
<Progress
|
||||
percent={task.complete_ratio}
|
||||
size="small"
|
||||
showInfo={false}
|
||||
strokeColor={task.complete_ratio === 100 ? '#52c41a' : '#1890ff'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Assignees */}
|
||||
{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"
|
||||
src={assignee.avatar_url}
|
||||
style={{ backgroundColor: assignee.color_code }}
|
||||
>
|
||||
{assignee.name?.charAt(0)?.toUpperCase()}
|
||||
</Avatar>
|
||||
</Tooltip>
|
||||
))}
|
||||
</Avatar.Group>
|
||||
)}
|
||||
|
||||
{/* Priority Indicator */}
|
||||
{task.priority_color && (
|
||||
<div
|
||||
className="w-2 h-2 rounded-full"
|
||||
style={{ backgroundColor: task.priority_color }}
|
||||
title={`Priority: ${task.priority}`}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Due Date */}
|
||||
{dueDate && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<ClockCircleOutlined className="text-xs text-gray-400" />
|
||||
<Text
|
||||
className={`text-xs ${
|
||||
dueDate.color === 'error' ? 'text-red-500' :
|
||||
dueDate.color === 'warning' ? 'text-orange-500' :
|
||||
'text-gray-500'
|
||||
}`}
|
||||
>
|
||||
{dueDate.text}
|
||||
</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Task Indicators */}
|
||||
<Space size={0}>
|
||||
{task.comments_count && task.comments_count > 0 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<MessageOutlined className="text-xs text-gray-400" />
|
||||
<Text className="text-xs text-gray-500">{task.comments_count}</Text>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{task.attachments_count && task.attachments_count > 0 && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<PaperClipOutlined className="text-xs text-gray-400" />
|
||||
<Text className="text-xs text-gray-500">{task.attachments_count}</Text>
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{/* View/Edit Button */}
|
||||
<Button
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
className="opacity-60 hover:opacity-100"
|
||||
onClick={() => {
|
||||
// Handle task view/edit
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subtasks */}
|
||||
{task.show_sub_tasks && task.sub_tasks && task.sub_tasks.length > 0 && (
|
||||
<div className="mt-3 ml-8 pl-4 border-l-2 border-gray-200">
|
||||
{task.sub_tasks.map((subtask) => (
|
||||
<TaskRow
|
||||
key={subtask.id}
|
||||
task={subtask}
|
||||
projectId={projectId}
|
||||
groupId={groupId}
|
||||
currentGrouping={currentGrouping}
|
||||
isSelected={isSelected}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskRow;
|
||||
Reference in New Issue
Block a user