- Updated AssigneeSelector and LabelsSelector components to include text color adjustments for better visibility in dark mode. - Introduced ImprovedTaskFilters component for a more efficient task filtering experience, integrating Redux state management for selected priorities and labels. - Refactored task management slice to support new filtering capabilities, including selected priorities and improved task fetching logic. - Enhanced TaskGroup and TaskRow components to accommodate new filtering features and improve overall layout consistency.
463 lines
18 KiB
TypeScript
463 lines
18 KiB
TypeScript
import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react';
|
|
import { useSortable } from '@dnd-kit/sortable';
|
|
import { CSS } from '@dnd-kit/utilities';
|
|
import { useSelector } from 'react-redux';
|
|
import { Input, Typography } from 'antd';
|
|
import type { InputRef } from 'antd';
|
|
import {
|
|
HolderOutlined,
|
|
MessageOutlined,
|
|
PaperClipOutlined,
|
|
ClockCircleOutlined,
|
|
} from '@ant-design/icons';
|
|
import { Task } from '@/types/task-management.types';
|
|
import { RootState } from '@/app/store';
|
|
import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tag, Tooltip } from '@/components';
|
|
import { useSocket } from '@/socket/socketContext';
|
|
import { SocketEvents } from '@/shared/socket-events';
|
|
|
|
interface TaskRowProps {
|
|
task: Task;
|
|
projectId: string;
|
|
groupId: string;
|
|
currentGrouping: 'status' | 'priority' | 'phase';
|
|
isSelected: boolean;
|
|
isDragOverlay?: boolean;
|
|
index?: number;
|
|
onSelect?: (taskId: string, selected: boolean) => void;
|
|
onToggleSubtasks?: (taskId: string) => void;
|
|
columns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
|
|
fixedColumns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
|
|
scrollableColumns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
|
|
}
|
|
|
|
// Priority and status colors - moved outside component to avoid recreation
|
|
const PRIORITY_COLORS = {
|
|
critical: '#ff4d4f',
|
|
high: '#ff7a45',
|
|
medium: '#faad14',
|
|
low: '#52c41a',
|
|
} as const;
|
|
|
|
const STATUS_COLORS = {
|
|
todo: '#f0f0f0',
|
|
doing: '#1890ff',
|
|
done: '#52c41a',
|
|
} as const;
|
|
|
|
const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|
task,
|
|
projectId,
|
|
groupId,
|
|
currentGrouping,
|
|
isSelected,
|
|
isDragOverlay = false,
|
|
index,
|
|
onSelect,
|
|
onToggleSubtasks,
|
|
columns,
|
|
fixedColumns,
|
|
scrollableColumns,
|
|
}) => {
|
|
const { socket, connected } = useSocket();
|
|
|
|
// Edit task name state
|
|
const [editTaskName, setEditTaskName] = useState(false);
|
|
const [taskName, setTaskName] = useState(task.title || '');
|
|
const inputRef = useRef<InputRef>(null);
|
|
const wrapperRef = useRef<HTMLDivElement>(null);
|
|
|
|
const {
|
|
attributes,
|
|
listeners,
|
|
setNodeRef,
|
|
transform,
|
|
transition,
|
|
isDragging,
|
|
} = useSortable({
|
|
id: task.id,
|
|
data: {
|
|
type: 'task',
|
|
taskId: task.id,
|
|
groupId,
|
|
},
|
|
disabled: isDragOverlay,
|
|
});
|
|
|
|
// Get theme from Redux store
|
|
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
|
|
|
// Click outside detection for edit mode
|
|
useEffect(() => {
|
|
const handleClickOutside = (event: MouseEvent) => {
|
|
if (wrapperRef.current && !wrapperRef.current.contains(event.target as Node)) {
|
|
handleTaskNameSave();
|
|
}
|
|
};
|
|
|
|
if (editTaskName) {
|
|
document.addEventListener('mousedown', handleClickOutside);
|
|
inputRef.current?.focus();
|
|
}
|
|
|
|
return () => {
|
|
document.removeEventListener('mousedown', handleClickOutside);
|
|
};
|
|
}, [editTaskName]);
|
|
|
|
// Handle task name save
|
|
const handleTaskNameSave = useCallback(() => {
|
|
const newTaskName = inputRef.current?.input?.value;
|
|
if (newTaskName?.trim() !== '' && connected && newTaskName !== task.title) {
|
|
socket?.emit(
|
|
SocketEvents.TASK_NAME_CHANGE.toString(),
|
|
JSON.stringify({
|
|
task_id: task.id,
|
|
name: newTaskName,
|
|
parent_task: null, // Assuming top-level tasks for now
|
|
})
|
|
);
|
|
}
|
|
setEditTaskName(false);
|
|
}, [connected, socket, task.id, task.title]);
|
|
|
|
// Memoize style calculations - simplified
|
|
const style = useMemo(() => ({
|
|
transform: CSS.Transform.toString(transform),
|
|
transition,
|
|
opacity: isDragging ? 0.5 : 1,
|
|
}), [transform, transition, isDragging]);
|
|
|
|
// Memoize event handlers to prevent unnecessary re-renders
|
|
const handleSelectChange = useCallback((checked: boolean) => {
|
|
onSelect?.(task.id, checked);
|
|
}, [onSelect, task.id]);
|
|
|
|
const handleToggleSubtasks = useCallback(() => {
|
|
onToggleSubtasks?.(task.id);
|
|
}, [onToggleSubtasks, task.id]);
|
|
|
|
// Memoize assignees for AvatarGroup to prevent unnecessary re-renders
|
|
const avatarGroupMembers = useMemo(() => {
|
|
return task.assignee_names || [];
|
|
}, [task.assignee_names]);
|
|
|
|
// Simplified class name calculations
|
|
const containerClasses = useMemo(() => {
|
|
const baseClasses = 'border-b transition-all duration-300';
|
|
const themeClasses = isDarkMode
|
|
? 'border-gray-700 bg-gray-900 hover:bg-gray-800'
|
|
: 'border-gray-200 bg-white hover:bg-gray-50';
|
|
const selectedClasses = isSelected
|
|
? (isDarkMode ? 'bg-blue-900/20' : 'bg-blue-50')
|
|
: '';
|
|
const overlayClasses = isDragOverlay
|
|
? `rounded shadow-lg border-2 ${isDarkMode ? 'bg-gray-900 border-gray-600 shadow-2xl' : 'bg-white border-gray-300 shadow-2xl'}`
|
|
: '';
|
|
return `${baseClasses} ${themeClasses} ${selectedClasses} ${overlayClasses}`;
|
|
}, [isDarkMode, isSelected, isDragOverlay]);
|
|
|
|
const fixedColumnsClasses = useMemo(() =>
|
|
`flex sticky left-0 z-10 border-r-2 shadow-sm ${isDarkMode ? 'bg-gray-900 border-gray-700' : 'bg-white border-gray-200'}`,
|
|
[isDarkMode]
|
|
);
|
|
|
|
const taskNameClasses = useMemo(() => {
|
|
const baseClasses = 'text-sm font-medium flex-1 overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300 cursor-pointer';
|
|
const themeClasses = isDarkMode ? 'text-gray-100 hover:text-blue-400' : 'text-gray-900 hover:text-blue-600';
|
|
const completedClasses = task.progress === 100
|
|
? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`
|
|
: '';
|
|
|
|
return `${baseClasses} ${themeClasses} ${completedClasses}`;
|
|
}, [isDarkMode, task.progress]);
|
|
|
|
// Get colors - using constants for better performance
|
|
const getPriorityColor = useCallback((priority: string) =>
|
|
PRIORITY_COLORS[priority as keyof typeof PRIORITY_COLORS] || '#d9d9d9', []);
|
|
|
|
const getStatusColor = useCallback((status: string) =>
|
|
STATUS_COLORS[status as keyof typeof STATUS_COLORS] || '#d9d9d9', []);
|
|
|
|
// Create adapter for LabelsSelector - memoized
|
|
const taskAdapter = useMemo(() => ({
|
|
id: task.id,
|
|
name: task.title,
|
|
parent_task_id: null,
|
|
all_labels: task.labels?.map(label => ({
|
|
id: label.id,
|
|
name: label.name,
|
|
color_code: label.color
|
|
})) || [],
|
|
labels: task.labels?.map(label => ({
|
|
id: label.id,
|
|
name: label.name,
|
|
color_code: label.color
|
|
})) || [],
|
|
} as any), [task.id, task.title, task.labels]);
|
|
|
|
// Create adapter for AssigneeSelector - memoized
|
|
const taskAdapterForAssignee = useMemo(() => ({
|
|
id: task.id,
|
|
name: task.title,
|
|
parent_task_id: null,
|
|
assignees: task.assignee_names?.map(member => ({
|
|
team_member_id: member.team_member_id,
|
|
id: member.team_member_id,
|
|
project_member_id: member.team_member_id,
|
|
name: member.name,
|
|
})) || [],
|
|
} as any), [task.id, task.title, task.assignee_names]);
|
|
|
|
// Memoize due date calculation
|
|
const dueDate = useMemo(() => {
|
|
if (!task.dueDate) return null;
|
|
const date = new Date(task.dueDate);
|
|
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' };
|
|
}
|
|
}, [task.dueDate]);
|
|
|
|
return (
|
|
<div
|
|
ref={setNodeRef}
|
|
style={style}
|
|
className={containerClasses}
|
|
>
|
|
<div className="flex h-10 max-h-10 overflow-visible relative">
|
|
{/* Fixed Columns */}
|
|
<div
|
|
className="fixed-columns-row"
|
|
style={{
|
|
display: 'flex',
|
|
background: isDarkMode ? '#1a1a1a' : '#fff',
|
|
width: fixedColumns?.reduce((sum, col) => sum + col.width, 0) || 0,
|
|
}}
|
|
>
|
|
{fixedColumns?.map(col => {
|
|
switch (col.key) {
|
|
case 'drag':
|
|
return (
|
|
<div key={col.key} className="w-10 flex items-center justify-center px-2 border-r" style={{ width: col.width }}>
|
|
<Button
|
|
variant="text"
|
|
size="small"
|
|
icon={<HolderOutlined />}
|
|
className="opacity-40 hover:opacity-100 cursor-grab active:cursor-grabbing"
|
|
isDarkMode={isDarkMode}
|
|
{...attributes}
|
|
{...listeners}
|
|
/>
|
|
</div>
|
|
);
|
|
case 'select':
|
|
return (
|
|
<div key={col.key} className="w-10 flex items-center justify-center px-2 border-r" style={{ width: col.width }}>
|
|
<Checkbox
|
|
checked={isSelected}
|
|
onChange={handleSelectChange}
|
|
isDarkMode={isDarkMode}
|
|
/>
|
|
</div>
|
|
);
|
|
case 'key':
|
|
return (
|
|
<div key={col.key} className="w-20 flex items-center px-2 border-r" style={{ width: col.width }}>
|
|
<Tag
|
|
backgroundColor={isDarkMode ? "#374151" : "#f0f0f0"}
|
|
color={isDarkMode ? "#d1d5db" : "#666"}
|
|
className="truncate whitespace-nowrap max-w-full"
|
|
>
|
|
{task.task_key}
|
|
</Tag>
|
|
</div>
|
|
);
|
|
case 'task':
|
|
return (
|
|
<div key={col.key} className="flex items-center px-2" style={{ width: col.width }}>
|
|
<div className="flex-1 min-w-0 flex flex-col justify-center h-full overflow-hidden">
|
|
<div className="flex items-center gap-2 h-5 overflow-hidden">
|
|
<div ref={wrapperRef} className="flex-1 min-w-0">
|
|
{!editTaskName ? (
|
|
<Typography.Text
|
|
ellipsis={{ tooltip: task.title }}
|
|
onClick={() => setEditTaskName(true)}
|
|
className={taskNameClasses}
|
|
style={{ cursor: 'pointer' }}
|
|
>
|
|
{task.title}
|
|
</Typography.Text>
|
|
) : (
|
|
<Input
|
|
ref={inputRef}
|
|
variant="borderless"
|
|
value={taskName}
|
|
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setTaskName(e.target.value)}
|
|
onPressEnter={handleTaskNameSave}
|
|
className={`${isDarkMode ? 'bg-gray-800 text-gray-100 border-gray-600' : 'bg-white text-gray-900 border-gray-300'}`}
|
|
style={{
|
|
width: '100%',
|
|
padding: '2px 4px',
|
|
fontSize: '14px',
|
|
fontWeight: 500,
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
})}
|
|
</div>
|
|
{/* Scrollable Columns */}
|
|
<div className="scrollable-columns-row" style={{ display: 'flex', minWidth: scrollableColumns?.reduce((sum, col) => sum + col.width, 0) || 0 }}>
|
|
{scrollableColumns?.map(col => {
|
|
switch (col.key) {
|
|
case 'progress':
|
|
return (
|
|
<div key={col.key} className="flex items-center justify-center px-2 border-r" style={{ width: col.width }}>
|
|
{task.progress !== undefined && task.progress >= 0 && (
|
|
<Progress
|
|
type="circle"
|
|
percent={task.progress}
|
|
size={24}
|
|
strokeColor={task.progress === 100 ? '#52c41a' : '#1890ff'}
|
|
strokeWidth={2}
|
|
showInfo={true}
|
|
isDarkMode={isDarkMode}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
case 'members':
|
|
return (
|
|
<div key={col.key} className="flex items-center px-2 border-r" style={{ width: col.width }}>
|
|
<div className="flex items-center gap-2">
|
|
{avatarGroupMembers.length > 0 && (
|
|
<AvatarGroup
|
|
members={avatarGroupMembers}
|
|
size={24}
|
|
maxCount={3}
|
|
isDarkMode={isDarkMode}
|
|
/>
|
|
)}
|
|
<AssigneeSelector
|
|
task={taskAdapterForAssignee}
|
|
groupId={groupId}
|
|
isDarkMode={isDarkMode}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
case 'labels':
|
|
return (
|
|
<div key={col.key} className="max-w-[200px] flex items-center px-2 border-r" style={{ width: col.width }}>
|
|
<div className="flex items-center gap-1 flex-wrap h-full w-full overflow-visible relative">
|
|
{task.labels?.map((label, index) => (
|
|
label.end && label.names && label.name ? (
|
|
<CustomNumberLabel
|
|
key={`${label.id}-${index}`}
|
|
labelList={label.names}
|
|
namesString={label.name}
|
|
isDarkMode={isDarkMode}
|
|
/>
|
|
) : (
|
|
<CustomColordLabel
|
|
key={`${label.id}-${index}`}
|
|
label={label}
|
|
isDarkMode={isDarkMode}
|
|
/>
|
|
)
|
|
))}
|
|
<LabelsSelector
|
|
task={taskAdapter}
|
|
isDarkMode={isDarkMode}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
case 'status':
|
|
return (
|
|
<div key={col.key} className="flex items-center px-2 border-r" style={{ width: col.width }}>
|
|
<Tag
|
|
backgroundColor={getStatusColor(task.status)}
|
|
color="white"
|
|
className="text-xs font-medium uppercase"
|
|
>
|
|
{task.status}
|
|
</Tag>
|
|
</div>
|
|
);
|
|
case 'priority':
|
|
return (
|
|
<div key={col.key} className="flex items-center px-2 border-r" style={{ width: col.width }}>
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="w-2 h-2 rounded-full"
|
|
style={{ backgroundColor: getPriorityColor(task.priority) }}
|
|
/>
|
|
<span className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'}`}>
|
|
{task.priority}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
);
|
|
case 'timeTracking':
|
|
return (
|
|
<div key={col.key} className="flex items-center px-2 border-r" style={{ width: col.width }}>
|
|
<div className="flex items-center gap-2 h-full overflow-hidden">
|
|
{task.timeTracking?.logged && task.timeTracking.logged > 0 && (
|
|
<div className="flex items-center gap-1">
|
|
<ClockCircleOutlined className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`} />
|
|
<span className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'}`}>
|
|
{typeof task.timeTracking.logged === 'number'
|
|
? `${task.timeTracking.logged}h`
|
|
: task.timeTracking.logged
|
|
}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
default:
|
|
return null;
|
|
}
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}, (prevProps, nextProps) => {
|
|
// Simplified comparison for better performance
|
|
return (
|
|
prevProps.task.id === nextProps.task.id &&
|
|
prevProps.task.title === nextProps.task.title &&
|
|
prevProps.task.progress === nextProps.task.progress &&
|
|
prevProps.task.status === nextProps.task.status &&
|
|
prevProps.task.priority === nextProps.task.priority &&
|
|
prevProps.task.labels?.length === nextProps.task.labels?.length &&
|
|
prevProps.task.assignee_names?.length === nextProps.task.assignee_names?.length &&
|
|
prevProps.isSelected === nextProps.isSelected &&
|
|
prevProps.isDragOverlay === nextProps.isDragOverlay &&
|
|
prevProps.groupId === nextProps.groupId
|
|
);
|
|
});
|
|
|
|
TaskRow.displayName = 'TaskRow';
|
|
|
|
export default TaskRow;
|