Merge pull request #165 from Worklenz/imp/task-list-performance-fixes

Imp/task list performance fixes
This commit is contained in:
Chamika J
2025-06-20 08:40:14 +05:30
committed by GitHub
18 changed files with 4082 additions and 32 deletions

View File

@@ -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,
},
});

View File

@@ -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;

View File

@@ -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;

View 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;

View File

@@ -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;

View 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;

View 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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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),

View 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;

View File

@@ -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;

View File

@@ -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>
);
};

View 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;
}

View 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[];
}

View 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;
}