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

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