feat(components): introduce new UI components and enhance Vite configuration
- Added AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tag, and Tooltip components for improved UI functionality. - Updated Vite configuration to change the development server port to 5173 and removed unnecessary interop settings for module compatibility. - Enhanced task management components to utilize new task structure and improve performance.
This commit is contained in:
220
worklenz-frontend/src/components/AssigneeSelector.tsx
Normal file
220
worklenz-frontend/src/components/AssigneeSelector.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { PlusOutlined, UserAddOutlined } from '@ant-design/icons';
|
||||||
|
import { RootState } from '@/app/store';
|
||||||
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
|
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { Avatar, Button, Checkbox } from '@/components';
|
||||||
|
import { sortTeamMembers } from '@/utils/sort-team-members';
|
||||||
|
|
||||||
|
interface AssigneeSelectorProps {
|
||||||
|
task: IProjectTask;
|
||||||
|
groupId?: string | null;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||||
|
task,
|
||||||
|
groupId = null,
|
||||||
|
isDarkMode = false
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [teamMembers, setTeamMembers] = useState<ITeamMembersViewModel>({ data: [], total: 0 });
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { projectId } = useSelector((state: RootState) => state.projectReducer);
|
||||||
|
const members = useSelector((state: RootState) => state.teamMembersReducer.teamMembers);
|
||||||
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
const { socket } = useSocket();
|
||||||
|
|
||||||
|
const filteredMembers = useMemo(() => {
|
||||||
|
return teamMembers?.data?.filter(member =>
|
||||||
|
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [teamMembers, searchQuery]);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDropdownToggle = () => {
|
||||||
|
if (!isOpen) {
|
||||||
|
// Prepare team members data when opening
|
||||||
|
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
|
||||||
|
const membersData = (members?.data || []).map(member => ({
|
||||||
|
...member,
|
||||||
|
selected: assignees?.includes(member.id),
|
||||||
|
}));
|
||||||
|
const sortedMembers = sortTeamMembers(membersData);
|
||||||
|
setTeamMembers({ data: sortedMembers });
|
||||||
|
|
||||||
|
// Focus search input after opening
|
||||||
|
setTimeout(() => {
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMemberToggle = (memberId: string, checked: boolean) => {
|
||||||
|
if (!memberId || !projectId || !task?.id || !currentSession?.id) return;
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
team_member_id: memberId,
|
||||||
|
project_id: projectId,
|
||||||
|
task_id: task.id,
|
||||||
|
reporter_id: currentSession.id,
|
||||||
|
mode: checked ? 0 : 1,
|
||||||
|
parent_task: task.parent_task_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body));
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkMemberSelected = (memberId: string) => {
|
||||||
|
if (!memberId) return false;
|
||||||
|
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
|
||||||
|
return assignees?.includes(memberId) || false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={handleDropdownToggle}
|
||||||
|
className={`
|
||||||
|
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
|
||||||
|
transition-colors duration-200
|
||||||
|
${isDarkMode
|
||||||
|
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800'
|
||||||
|
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-xs" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute top-6 left-0 z-50 w-72 rounded-md shadow-lg border
|
||||||
|
${isDarkMode
|
||||||
|
? 'bg-gray-800 border-gray-600'
|
||||||
|
: 'bg-white border-gray-200'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className="p-2 border-b border-gray-200 dark:border-gray-600">
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search members..."
|
||||||
|
className={`
|
||||||
|
w-full px-2 py-1 text-xs rounded border
|
||||||
|
${isDarkMode
|
||||||
|
? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500'
|
||||||
|
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500'
|
||||||
|
}
|
||||||
|
focus:outline-none focus:ring-1 focus:ring-blue-500
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Members List */}
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{filteredMembers && filteredMembers.length > 0 ? (
|
||||||
|
filteredMembers.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-2 p-2 cursor-pointer transition-colors
|
||||||
|
${member.pending_invitation
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: isDarkMode
|
||||||
|
? 'hover:bg-gray-700'
|
||||||
|
: 'hover:bg-gray-50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!member.pending_invitation) {
|
||||||
|
const isSelected = checkMemberSelected(member.id || '');
|
||||||
|
handleMemberToggle(member.id || '', !isSelected);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checkMemberSelected(member.id || '')}
|
||||||
|
onChange={(checked) => handleMemberToggle(member.id || '', checked)}
|
||||||
|
disabled={member.pending_invitation}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Avatar
|
||||||
|
src={member.avatar_url}
|
||||||
|
name={member.name || ''}
|
||||||
|
size={24}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}>
|
||||||
|
{member.name}
|
||||||
|
</div>
|
||||||
|
<div className={`text-xs truncate ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||||
|
{member.email}
|
||||||
|
{member.pending_invitation && (
|
||||||
|
<span className="text-red-400 ml-1">(Pending)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||||
|
<div className="text-xs">No members found</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
|
||||||
|
<button
|
||||||
|
className={`
|
||||||
|
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
|
||||||
|
transition-colors
|
||||||
|
${isDarkMode
|
||||||
|
? 'text-blue-400 hover:bg-gray-700'
|
||||||
|
: 'text-blue-600 hover:bg-blue-50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
// TODO: Implement invite member functionality
|
||||||
|
console.log('Invite member clicked');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserAddOutlined />
|
||||||
|
Invite member
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AssigneeSelector;
|
||||||
89
worklenz-frontend/src/components/Avatar.tsx
Normal file
89
worklenz-frontend/src/components/Avatar.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface AvatarProps {
|
||||||
|
name?: string;
|
||||||
|
size?: number | 'small' | 'default' | 'large';
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
className?: string;
|
||||||
|
src?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Avatar: React.FC<AvatarProps> = ({
|
||||||
|
name = '',
|
||||||
|
size = 'default',
|
||||||
|
isDarkMode = false,
|
||||||
|
className = '',
|
||||||
|
src,
|
||||||
|
backgroundColor,
|
||||||
|
onClick,
|
||||||
|
style = {}
|
||||||
|
}) => {
|
||||||
|
// Handle both numeric and string sizes
|
||||||
|
const getSize = () => {
|
||||||
|
if (typeof size === 'number') {
|
||||||
|
return { width: size, height: size, fontSize: `${size * 0.4}px` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap = {
|
||||||
|
small: { width: 24, height: 24, fontSize: '10px' },
|
||||||
|
default: { width: 32, height: 32, fontSize: '14px' },
|
||||||
|
large: { width: 48, height: 48, fontSize: '18px' }
|
||||||
|
};
|
||||||
|
|
||||||
|
return sizeMap[size];
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeStyle = getSize();
|
||||||
|
|
||||||
|
const lightColors = [
|
||||||
|
'#f56565', '#4299e1', '#48bb78', '#ed8936', '#9f7aea',
|
||||||
|
'#ed64a6', '#667eea', '#38b2ac', '#f6ad55', '#4fd1c7'
|
||||||
|
];
|
||||||
|
|
||||||
|
const darkColors = [
|
||||||
|
'#e53e3e', '#3182ce', '#38a169', '#dd6b20', '#805ad5',
|
||||||
|
'#d53f8c', '#5a67d8', '#319795', '#d69e2e', '#319795'
|
||||||
|
];
|
||||||
|
|
||||||
|
const colors = isDarkMode ? darkColors : lightColors;
|
||||||
|
const colorIndex = name.charCodeAt(0) % colors.length;
|
||||||
|
const defaultBgColor = backgroundColor || colors[colorIndex];
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const avatarStyle = {
|
||||||
|
...sizeStyle,
|
||||||
|
backgroundColor: defaultBgColor,
|
||||||
|
...style
|
||||||
|
};
|
||||||
|
|
||||||
|
if (src) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={name}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`rounded-full object-cover shadow-sm cursor-pointer ${className}`}
|
||||||
|
style={avatarStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`rounded-full flex items-center justify-center text-white font-medium shadow-sm cursor-pointer ${className}`}
|
||||||
|
style={avatarStyle}
|
||||||
|
>
|
||||||
|
{name.charAt(0)?.toUpperCase() || '?'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Avatar;
|
||||||
111
worklenz-frontend/src/components/AvatarGroup.tsx
Normal file
111
worklenz-frontend/src/components/AvatarGroup.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import { Avatar, Tooltip } from './index';
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
id?: string;
|
||||||
|
team_member_id?: string;
|
||||||
|
name?: string;
|
||||||
|
names?: string[];
|
||||||
|
avatar_url?: string;
|
||||||
|
color_code?: string;
|
||||||
|
end?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvatarGroupProps {
|
||||||
|
members: Member[];
|
||||||
|
maxCount?: number;
|
||||||
|
size?: number | 'small' | 'default' | 'large';
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
className?: string;
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AvatarGroup: React.FC<AvatarGroupProps> = ({
|
||||||
|
members,
|
||||||
|
maxCount,
|
||||||
|
size = 28,
|
||||||
|
isDarkMode = false,
|
||||||
|
className = '',
|
||||||
|
onClick
|
||||||
|
}) => {
|
||||||
|
const stopPropagation = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.(e);
|
||||||
|
}, [onClick]);
|
||||||
|
|
||||||
|
const renderAvatar = useCallback((member: Member, index: number) => {
|
||||||
|
const memberName = member.end && member.names ? member.names.join(', ') : member.name || '';
|
||||||
|
const displayName = member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={member.team_member_id || member.id || index}
|
||||||
|
title={memberName}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
name={member.name || ''}
|
||||||
|
src={member.avatar_url}
|
||||||
|
size={size}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
backgroundColor={member.color_code}
|
||||||
|
onClick={stopPropagation}
|
||||||
|
className="border-2 border-white"
|
||||||
|
style={isDarkMode ? { borderColor: '#374151' } : {}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}, [stopPropagation, size, isDarkMode]);
|
||||||
|
|
||||||
|
const visibleMembers = useMemo(() => {
|
||||||
|
return maxCount ? members.slice(0, maxCount) : members;
|
||||||
|
}, [members, maxCount]);
|
||||||
|
|
||||||
|
const remainingCount = useMemo(() => {
|
||||||
|
return maxCount ? Math.max(0, members.length - maxCount) : 0;
|
||||||
|
}, [members.length, maxCount]);
|
||||||
|
|
||||||
|
const avatarElements = useMemo(() => {
|
||||||
|
return visibleMembers.map((member, index) => renderAvatar(member, index));
|
||||||
|
}, [visibleMembers, renderAvatar]);
|
||||||
|
|
||||||
|
const getSizeStyle = () => {
|
||||||
|
if (typeof size === 'number') {
|
||||||
|
return { width: size, height: size, fontSize: `${size * 0.4}px` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap = {
|
||||||
|
small: { width: 24, height: 24, fontSize: '10px' },
|
||||||
|
default: { width: 32, height: 32, fontSize: '14px' },
|
||||||
|
large: { width: 48, height: 48, fontSize: '18px' }
|
||||||
|
};
|
||||||
|
|
||||||
|
return sizeMap[size];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div onClick={stopPropagation} className={`flex -space-x-1 ${className}`}>
|
||||||
|
{avatarElements}
|
||||||
|
{remainingCount > 0 && (
|
||||||
|
<Tooltip
|
||||||
|
title={`${remainingCount} more`}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`rounded-full flex items-center justify-center text-white font-medium shadow-sm border-2 cursor-pointer ${
|
||||||
|
isDarkMode
|
||||||
|
? 'bg-gray-600 border-gray-700'
|
||||||
|
: 'bg-gray-400 border-white'
|
||||||
|
}`}
|
||||||
|
style={getSizeStyle()}
|
||||||
|
onClick={stopPropagation}
|
||||||
|
>
|
||||||
|
+{remainingCount}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AvatarGroup;
|
||||||
64
worklenz-frontend/src/components/Button.tsx
Normal file
64
worklenz-frontend/src/components/Button.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ButtonProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
variant?: 'text' | 'default' | 'primary' | 'danger';
|
||||||
|
size?: 'small' | 'default' | 'large';
|
||||||
|
className?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
type?: 'button' | 'submit' | 'reset';
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button: React.FC<ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>> = ({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
className = '',
|
||||||
|
icon,
|
||||||
|
isDarkMode = false,
|
||||||
|
disabled = false,
|
||||||
|
type = 'button',
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const baseClasses = `inline-flex items-center justify-center font-medium transition-colors duration-200 focus:outline-none focus:ring-2 ${isDarkMode ? 'focus:ring-blue-400' : 'focus:ring-blue-500'} focus:ring-offset-1 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`;
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
text: isDarkMode
|
||||||
|
? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700/50'
|
||||||
|
: 'text-gray-600 hover:text-gray-800 hover:bg-gray-100',
|
||||||
|
default: isDarkMode
|
||||||
|
? 'bg-gray-800 border border-gray-600 text-gray-200 hover:bg-gray-700'
|
||||||
|
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50',
|
||||||
|
primary: isDarkMode
|
||||||
|
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
: 'bg-blue-500 text-white hover:bg-blue-600',
|
||||||
|
danger: isDarkMode
|
||||||
|
? 'bg-red-600 text-white hover:bg-red-700'
|
||||||
|
: 'bg-red-500 text-white hover:bg-red-600'
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
small: 'px-2 py-1 text-xs rounded',
|
||||||
|
default: 'px-3 py-2 text-sm rounded-md',
|
||||||
|
large: 'px-4 py-3 text-base rounded-lg'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon && <span className={children ? "mr-1" : ""}>{icon}</span>}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Button;
|
||||||
42
worklenz-frontend/src/components/Checkbox.tsx
Normal file
42
worklenz-frontend/src/components/Checkbox.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface CheckboxProps {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Checkbox: React.FC<CheckboxProps> = ({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
isDarkMode = false,
|
||||||
|
className = '',
|
||||||
|
disabled = false
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<label className={`inline-flex items-center cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => !disabled && onChange(e.target.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<div className={`w-4 h-4 border-2 rounded transition-all duration-200 flex items-center justify-center ${
|
||||||
|
checked
|
||||||
|
? `${isDarkMode ? 'bg-blue-600 border-blue-600' : 'bg-blue-500 border-blue-500'}`
|
||||||
|
: `${isDarkMode ? 'bg-gray-800 border-gray-600 hover:border-gray-500' : 'bg-white border-gray-300 hover:border-gray-400'}`
|
||||||
|
} ${disabled ? 'cursor-not-allowed' : ''}`}>
|
||||||
|
{checked && (
|
||||||
|
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Checkbox;
|
||||||
30
worklenz-frontend/src/components/CustomColordLabel.tsx
Normal file
30
worklenz-frontend/src/components/CustomColordLabel.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Tooltip } from 'antd';
|
||||||
|
import { Label } from '@/types/task-management.types';
|
||||||
|
|
||||||
|
interface CustomColordLabelProps {
|
||||||
|
label: Label;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomColordLabel: React.FC<CustomColordLabelProps> = ({
|
||||||
|
label,
|
||||||
|
isDarkMode = false
|
||||||
|
}) => {
|
||||||
|
const truncatedName = label.name && label.name.length > 10
|
||||||
|
? `${label.name.substring(0, 10)}...`
|
||||||
|
: label.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={label.name}>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-white flex-shrink-0 max-w-[120px]"
|
||||||
|
style={{ backgroundColor: label.color }}
|
||||||
|
>
|
||||||
|
<span className="truncate">{truncatedName}</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomColordLabel;
|
||||||
30
worklenz-frontend/src/components/CustomNumberLabel.tsx
Normal file
30
worklenz-frontend/src/components/CustomNumberLabel.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Tooltip } from 'antd';
|
||||||
|
|
||||||
|
interface CustomNumberLabelProps {
|
||||||
|
labelList: string[];
|
||||||
|
namesString: string;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomNumberLabel: React.FC<CustomNumberLabelProps> = ({
|
||||||
|
labelList,
|
||||||
|
namesString,
|
||||||
|
isDarkMode = false
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Tooltip title={labelList.join(', ')}>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||||
|
${isDarkMode ? 'bg-gray-600 text-gray-100' : 'bg-gray-200 text-gray-700'}
|
||||||
|
cursor-help
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{namesString}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomNumberLabel;
|
||||||
279
worklenz-frontend/src/components/LabelsSelector.tsx
Normal file
279
worklenz-frontend/src/components/LabelsSelector.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { PlusOutlined, TagOutlined } from '@ant-design/icons';
|
||||||
|
import { RootState } from '@/app/store';
|
||||||
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
|
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { Button, Checkbox, Tag } from '@/components';
|
||||||
|
|
||||||
|
interface LabelsSelectorProps {
|
||||||
|
task: IProjectTask;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LabelsSelector: React.FC<LabelsSelectorProps> = ({
|
||||||
|
task,
|
||||||
|
isDarkMode = false
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { labels } = useSelector((state: RootState) => state.taskLabelsReducer);
|
||||||
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
const { socket } = useSocket();
|
||||||
|
|
||||||
|
const filteredLabels = useMemo(() => {
|
||||||
|
return (labels as ITaskLabel[])?.filter(label =>
|
||||||
|
label.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
) || [];
|
||||||
|
}, [labels, searchQuery]);
|
||||||
|
|
||||||
|
// Update dropdown position
|
||||||
|
const updateDropdownPosition = useCallback(() => {
|
||||||
|
if (buttonRef.current) {
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
setDropdownPosition({
|
||||||
|
top: rect.bottom + window.scrollY + 2,
|
||||||
|
left: rect.left + window.scrollX,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside and handle scroll
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
|
||||||
|
buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (isOpen) {
|
||||||
|
updateDropdownPosition();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (isOpen) {
|
||||||
|
updateDropdownPosition();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
window.addEventListener('scroll', handleScroll, true);
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
window.removeEventListener('scroll', handleScroll, true);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [isOpen, updateDropdownPosition]);
|
||||||
|
|
||||||
|
const handleDropdownToggle = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
console.log('Labels dropdown toggle clicked, current state:', isOpen);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
updateDropdownPosition();
|
||||||
|
setIsOpen(true);
|
||||||
|
// Focus search input after opening
|
||||||
|
setTimeout(() => {
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const handleLabelToggle = (label: ITaskLabel) => {
|
||||||
|
const labelData = {
|
||||||
|
task_id: task.id,
|
||||||
|
label_id: label.id,
|
||||||
|
parent_task: task.parent_task_id,
|
||||||
|
team_id: currentSession?.team_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
socket?.emit(SocketEvents.TASK_LABELS_CHANGE.toString(), JSON.stringify(labelData));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateLabel = () => {
|
||||||
|
if (!searchQuery.trim()) return;
|
||||||
|
|
||||||
|
const labelData = {
|
||||||
|
task_id: task.id,
|
||||||
|
label: searchQuery.trim(),
|
||||||
|
parent_task: task.parent_task_id,
|
||||||
|
team_id: currentSession?.team_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
socket?.emit(SocketEvents.CREATE_LABEL.toString(), JSON.stringify(labelData));
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkLabelSelected = (labelId: string) => {
|
||||||
|
return task?.all_labels?.some(existingLabel => existingLabel.id === labelId) || false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
const existingLabel = filteredLabels.find(
|
||||||
|
label => label.name?.toLowerCase() === searchQuery.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingLabel && e.key === 'Enter') {
|
||||||
|
handleCreateLabel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
onClick={handleDropdownToggle}
|
||||||
|
className={`
|
||||||
|
w-5 h-5 rounded border border-dashed flex items-center justify-center
|
||||||
|
transition-colors duration-200
|
||||||
|
${isOpen
|
||||||
|
? isDarkMode
|
||||||
|
? 'border-blue-500 bg-blue-900/20'
|
||||||
|
: 'border-blue-500 bg-blue-50'
|
||||||
|
: isDarkMode
|
||||||
|
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800'
|
||||||
|
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-xs" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && createPortal(
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
className={`
|
||||||
|
fixed z-[9999] w-72 rounded-md shadow-lg border
|
||||||
|
${isDarkMode
|
||||||
|
? 'bg-gray-800 border-gray-600'
|
||||||
|
: 'bg-white border-gray-200'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
top: dropdownPosition.top,
|
||||||
|
left: dropdownPosition.left,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Search labels..."
|
||||||
|
className={`
|
||||||
|
w-full px-2 py-1 text-xs rounded border
|
||||||
|
${isDarkMode
|
||||||
|
? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500'
|
||||||
|
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500'
|
||||||
|
}
|
||||||
|
focus:outline-none focus:ring-1 focus:ring-blue-500
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Labels List */}
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{filteredLabels && filteredLabels.length > 0 ? (
|
||||||
|
filteredLabels.map((label) => (
|
||||||
|
<div
|
||||||
|
key={label.id}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-2 p-2 cursor-pointer transition-colors
|
||||||
|
${isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-50'}
|
||||||
|
`}
|
||||||
|
onClick={() => handleLabelToggle(label)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checkLabelSelected(label.id || '')}
|
||||||
|
onChange={() => handleLabelToggle(label)}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: label.color_code }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}>
|
||||||
|
{label.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||||
|
<div className="text-xs">No labels found</div>
|
||||||
|
{searchQuery.trim() && (
|
||||||
|
<button
|
||||||
|
onClick={handleCreateLabel}
|
||||||
|
className={`
|
||||||
|
mt-2 px-3 py-1 text-xs rounded border transition-colors
|
||||||
|
${isDarkMode
|
||||||
|
? 'border-gray-600 text-gray-300 hover:bg-gray-700'
|
||||||
|
: 'border-gray-300 text-gray-600 hover:bg-gray-50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
Create "{searchQuery}"
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
|
||||||
|
<button
|
||||||
|
className={`
|
||||||
|
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
|
||||||
|
transition-colors
|
||||||
|
${isDarkMode
|
||||||
|
? 'text-blue-400 hover:bg-gray-700'
|
||||||
|
: 'text-blue-600 hover:bg-blue-50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
// TODO: Implement manage labels functionality
|
||||||
|
console.log('Manage labels clicked');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TagOutlined />
|
||||||
|
Manage labels
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LabelsSelector;
|
||||||
84
worklenz-frontend/src/components/Progress.tsx
Normal file
84
worklenz-frontend/src/components/Progress.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ProgressProps {
|
||||||
|
percent: number;
|
||||||
|
type?: 'line' | 'circle';
|
||||||
|
size?: number;
|
||||||
|
strokeColor?: string;
|
||||||
|
strokeWidth?: number;
|
||||||
|
showInfo?: boolean;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Progress: React.FC<ProgressProps> = ({
|
||||||
|
percent,
|
||||||
|
type = 'line',
|
||||||
|
size = 24,
|
||||||
|
strokeColor = '#1890ff',
|
||||||
|
strokeWidth = 2,
|
||||||
|
showInfo = true,
|
||||||
|
isDarkMode = false,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
// Ensure percent is between 0 and 100
|
||||||
|
const normalizedPercent = Math.min(Math.max(percent, 0), 100);
|
||||||
|
|
||||||
|
if (type === 'circle') {
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = radius * 2 * Math.PI;
|
||||||
|
const strokeDasharray = circumference;
|
||||||
|
const strokeDashoffset = circumference - (normalizedPercent / 100) * circumference;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative inline-flex items-center justify-center ${className}`}>
|
||||||
|
<svg width={size} height={size} className="transform -rotate-90">
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke={isDarkMode ? '#4b5563' : '#e5e7eb'}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke={normalizedPercent === 100 ? '#52c41a' : strokeColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={strokeDasharray}
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="transition-all duration-300"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{showInfo && (
|
||||||
|
<span className={`absolute text-xs font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||||
|
{normalizedPercent}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`w-full rounded-full h-2 ${isDarkMode ? 'bg-gray-600' : 'bg-gray-200'} ${className}`}>
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${normalizedPercent}%`,
|
||||||
|
backgroundColor: normalizedPercent === 100 ? '#52c41a' : strokeColor
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showInfo && (
|
||||||
|
<div className={`mt-1 text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||||
|
{normalizedPercent}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Progress;
|
||||||
54
worklenz-frontend/src/components/Tag.tsx
Normal file
54
worklenz-frontend/src/components/Tag.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface TagProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
color?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
className?: string;
|
||||||
|
size?: 'small' | 'default';
|
||||||
|
variant?: 'default' | 'outlined';
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tag: React.FC<TagProps> = ({
|
||||||
|
children,
|
||||||
|
color = 'white',
|
||||||
|
backgroundColor = '#1890ff',
|
||||||
|
className = '',
|
||||||
|
size = 'default',
|
||||||
|
variant = 'default',
|
||||||
|
isDarkMode = false
|
||||||
|
}) => {
|
||||||
|
const sizeClasses = {
|
||||||
|
small: 'px-1 py-0.5 text-xs',
|
||||||
|
default: 'px-2 py-1 text-xs'
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClasses = `inline-flex items-center font-medium rounded ${sizeClasses[size]}`;
|
||||||
|
|
||||||
|
if (variant === 'outlined') {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`${baseClasses} border ${className}`}
|
||||||
|
style={{
|
||||||
|
borderColor: backgroundColor,
|
||||||
|
color: backgroundColor,
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`${baseClasses} ${className}`}
|
||||||
|
style={{ backgroundColor, color }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Tag;
|
||||||
35
worklenz-frontend/src/components/Tooltip.tsx
Normal file
35
worklenz-frontend/src/components/Tooltip.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
title: string | React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
placement?: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tooltip: React.FC<TooltipProps> = ({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
isDarkMode = false,
|
||||||
|
placement = 'top',
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const placementClasses = {
|
||||||
|
top: 'bottom-full left-1/2 transform -translate-x-1/2 mb-2',
|
||||||
|
bottom: 'top-full left-1/2 transform -translate-x-1/2 mt-2',
|
||||||
|
left: 'right-full top-1/2 transform -translate-y-1/2 mr-2',
|
||||||
|
right: 'left-full top-1/2 transform -translate-y-1/2 ml-2'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative group ${className}`}>
|
||||||
|
{children}
|
||||||
|
<div className={`absolute ${placementClasses[placement]} px-2 py-1 text-xs text-white ${isDarkMode ? 'bg-gray-700' : 'bg-gray-900'} rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-50 pointer-events-none min-w-max`}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Tooltip;
|
||||||
12
worklenz-frontend/src/components/index.ts
Normal file
12
worklenz-frontend/src/components/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// Reusable UI Components
|
||||||
|
export { default as AssigneeSelector } from './AssigneeSelector';
|
||||||
|
export { default as Avatar } from './Avatar';
|
||||||
|
export { default as AvatarGroup } from './AvatarGroup';
|
||||||
|
export { default as Button } from './Button';
|
||||||
|
export { default as Checkbox } from './Checkbox';
|
||||||
|
export { default as CustomColordLabel } from './CustomColordLabel';
|
||||||
|
export { default as CustomNumberLabel } from './CustomNumberLabel';
|
||||||
|
export { default as LabelsSelector } from './LabelsSelector';
|
||||||
|
export { default as Progress } from './Progress';
|
||||||
|
export { default as Tag } from './Tag';
|
||||||
|
export { default as Tooltip } from './Tooltip';
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { Button, Typography } from 'antd';
|
import { Button, Typography } from 'antd';
|
||||||
import { PlusOutlined, RightOutlined, DownOutlined } from '@ant-design/icons';
|
import { PlusOutlined, RightOutlined, DownOutlined } from '@ant-design/icons';
|
||||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
import { TaskGroup as TaskGroupType, Task } from '@/types/task-management.types';
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { taskManagementSelectors } from '@/features/task-management/task-management.slice';
|
||||||
import { IGroupBy, COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import TaskRow from './task-row';
|
import TaskRow from './task-row';
|
||||||
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
|
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
|
||||||
@@ -14,9 +13,9 @@ import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-tabl
|
|||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
interface TaskGroupProps {
|
interface TaskGroupProps {
|
||||||
group: ITaskListGroup;
|
group: TaskGroupType;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
currentGrouping: IGroupBy;
|
currentGrouping: 'status' | 'priority' | 'phase';
|
||||||
selectedTaskIds: string[];
|
selectedTaskIds: string[];
|
||||||
onAddTask?: (groupId: string) => void;
|
onAddTask?: (groupId: string) => void;
|
||||||
onToggleCollapse?: (groupId: string) => void;
|
onToggleCollapse?: (groupId: string) => void;
|
||||||
@@ -34,7 +33,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
onSelectTask,
|
onSelectTask,
|
||||||
onToggleSubtasks,
|
onToggleSubtasks,
|
||||||
}) => {
|
}) => {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(group.collapsed || false);
|
||||||
|
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
id: group.id,
|
id: group.id,
|
||||||
@@ -44,41 +43,37 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get column visibility from Redux store
|
// Get all tasks from the store
|
||||||
const columns = useSelector((state: RootState) => state.taskReducer.columns);
|
const allTasks = useSelector(taskManagementSelectors.selectAll);
|
||||||
|
|
||||||
// Helper function to check if a column is visible
|
// Get tasks for this group using memoization for performance
|
||||||
const isColumnVisible = (columnKey: string) => {
|
const groupTasks = useMemo(() => {
|
||||||
const column = columns.find(col => col.key === columnKey);
|
return group.taskIds
|
||||||
return column ? column.pinned : true; // Default to visible if column not found
|
.map(taskId => allTasks.find(task => task.id === taskId))
|
||||||
};
|
.filter((task): task is Task => task !== undefined);
|
||||||
|
}, [group.taskIds, allTasks]);
|
||||||
// Get task IDs for sortable context
|
|
||||||
const taskIds = group.tasks.map(task => task.id!);
|
|
||||||
|
|
||||||
// Calculate group statistics
|
// Calculate group statistics
|
||||||
const completedTasks = group.tasks.filter(
|
const completedTasks = useMemo(() => {
|
||||||
task => task.status_category?.is_done || task.complete_ratio === 100
|
return groupTasks.filter(task => task.progress === 100).length;
|
||||||
).length;
|
}, [groupTasks]);
|
||||||
const totalTasks = group.tasks.length;
|
|
||||||
|
const totalTasks = groupTasks.length;
|
||||||
const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
||||||
|
|
||||||
// Get group color based on grouping type
|
// Get group color based on grouping type
|
||||||
const getGroupColor = () => {
|
const getGroupColor = () => {
|
||||||
if (group.color_code) return group.color_code;
|
if (group.color) return group.color;
|
||||||
|
|
||||||
// Fallback colors based on group value
|
// Fallback colors based on group value
|
||||||
switch (currentGrouping) {
|
switch (currentGrouping) {
|
||||||
case 'status':
|
case 'status':
|
||||||
return group.id === 'todo' ? '#faad14' : group.id === 'doing' ? '#1890ff' : '#52c41a';
|
return group.groupValue === 'todo' ? '#faad14' :
|
||||||
|
group.groupValue === 'doing' ? '#1890ff' : '#52c41a';
|
||||||
case 'priority':
|
case 'priority':
|
||||||
return group.id === 'critical'
|
return group.groupValue === 'critical' ? '#ff4d4f' :
|
||||||
? '#ff4d4f'
|
group.groupValue === 'high' ? '#fa8c16' :
|
||||||
: group.id === 'high'
|
group.groupValue === 'medium' ? '#faad14' : '#52c41a';
|
||||||
? '#fa8c16'
|
|
||||||
: group.id === 'medium'
|
|
||||||
? '#faad14'
|
|
||||||
: '#52c41a';
|
|
||||||
case 'phase':
|
case 'phase':
|
||||||
return '#722ed1';
|
return '#722ed1';
|
||||||
default:
|
default:
|
||||||
@@ -118,7 +113,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
className="task-group-header-button"
|
className="task-group-header-button"
|
||||||
/>
|
/>
|
||||||
<Text strong className="task-group-header-text">
|
<Text strong className="task-group-header-text">
|
||||||
{group.name} ({totalTasks})
|
{group.title} ({totalTasks})
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,36 +143,24 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="task-table-scrollable-columns">
|
<div className="task-table-scrollable-columns">
|
||||||
{isColumnVisible(COLUMN_KEYS.PROGRESS) && (
|
<div className="task-table-cell task-table-header-cell" style={{ width: '90px' }}>
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '90px' }}>
|
<Text className="column-header-text">Progress</Text>
|
||||||
<Text className="column-header-text">Progress</Text>
|
</div>
|
||||||
</div>
|
<div className="task-table-cell task-table-header-cell" style={{ width: '150px' }}>
|
||||||
)}
|
<Text className="column-header-text">Members</Text>
|
||||||
{isColumnVisible(COLUMN_KEYS.ASSIGNEES) && (
|
</div>
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '150px' }}>
|
<div className="task-table-cell task-table-header-cell" style={{ width: '200px' }}>
|
||||||
<Text className="column-header-text">Members</Text>
|
<Text className="column-header-text">Labels</Text>
|
||||||
</div>
|
</div>
|
||||||
)}
|
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
||||||
{isColumnVisible(COLUMN_KEYS.LABELS) && (
|
<Text className="column-header-text">Status</Text>
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '150px' }}>
|
</div>
|
||||||
<Text className="column-header-text">Labels</Text>
|
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
||||||
</div>
|
<Text className="column-header-text">Priority</Text>
|
||||||
)}
|
</div>
|
||||||
{isColumnVisible(COLUMN_KEYS.STATUS) && (
|
<div className="task-table-cell task-table-header-cell" style={{ width: '120px' }}>
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
<Text className="column-header-text">Time Tracking</Text>
|
||||||
<Text className="column-header-text">Status</Text>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -189,7 +172,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
className="task-group-body"
|
className="task-group-body"
|
||||||
style={{ borderLeft: `4px solid ${getGroupColor()}` }}
|
style={{ borderLeft: `4px solid ${getGroupColor()}` }}
|
||||||
>
|
>
|
||||||
{group.tasks.length === 0 ? (
|
{groupTasks.length === 0 ? (
|
||||||
<div className="task-group-empty">
|
<div className="task-group-empty">
|
||||||
<div className="task-table-fixed-columns">
|
<div className="task-table-fixed-columns">
|
||||||
<div style={{ width: '380px', padding: '20px 12px' }}>
|
<div style={{ width: '380px', padding: '20px 12px' }}>
|
||||||
@@ -209,16 +192,16 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
|
<SortableContext items={group.taskIds} strategy={verticalListSortingStrategy}>
|
||||||
<div className="task-group-tasks">
|
<div className="task-group-tasks">
|
||||||
{group.tasks.map((task, index) => (
|
{groupTasks.map((task, index) => (
|
||||||
<TaskRow
|
<TaskRow
|
||||||
key={task.id}
|
key={task.id}
|
||||||
task={task}
|
task={task}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
groupId={group.id}
|
groupId={group.id}
|
||||||
currentGrouping={currentGrouping}
|
currentGrouping={currentGrouping}
|
||||||
isSelected={selectedTaskIds.includes(task.id!)}
|
isSelected={selectedTaskIds.includes(task.id)}
|
||||||
index={index}
|
index={index}
|
||||||
onSelect={onSelectTask}
|
onSelect={onSelectTask}
|
||||||
onToggleSubtasks={onToggleSubtasks}
|
onToggleSubtasks={onToggleSubtasks}
|
||||||
|
|||||||
@@ -17,13 +17,25 @@ import {
|
|||||||
} from '@dnd-kit/sortable';
|
} from '@dnd-kit/sortable';
|
||||||
import { Card, Spin, Empty } from 'antd';
|
import { Card, Spin, Empty } from 'antd';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import {
|
import {
|
||||||
IGroupBy,
|
taskManagementSelectors,
|
||||||
setGroup,
|
|
||||||
fetchTaskGroups,
|
|
||||||
reorderTasks,
|
reorderTasks,
|
||||||
} from '@/features/tasks/tasks.slice';
|
moveTaskToGroup,
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
setLoading,
|
||||||
|
fetchTasks
|
||||||
|
} from '@/features/task-management/task-management.slice';
|
||||||
|
import {
|
||||||
|
selectTaskGroups,
|
||||||
|
selectCurrentGrouping,
|
||||||
|
setCurrentGrouping
|
||||||
|
} from '@/features/task-management/grouping.slice';
|
||||||
|
import {
|
||||||
|
selectSelectedTaskIds,
|
||||||
|
toggleTaskSelection,
|
||||||
|
clearSelection
|
||||||
|
} from '@/features/task-management/selection.slice';
|
||||||
|
import { Task } from '@/types/task-management.types';
|
||||||
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
import TaskGroup from './task-group';
|
import TaskGroup from './task-group';
|
||||||
import TaskRow from './task-row';
|
import TaskRow from './task-row';
|
||||||
import BulkActionBar from './bulk-action-bar';
|
import BulkActionBar from './bulk-action-bar';
|
||||||
@@ -38,7 +50,7 @@ interface TaskListBoardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DragState {
|
interface DragState {
|
||||||
activeTask: IProjectTask | null;
|
activeTask: Task | null;
|
||||||
activeGroupId: string | null;
|
activeGroupId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,18 +61,16 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
activeGroupId: null,
|
activeGroupId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redux selectors
|
// Enable real-time socket updates for task changes
|
||||||
const {
|
useTaskSocketHandlers();
|
||||||
taskGroups,
|
|
||||||
loadingGroups,
|
|
||||||
error,
|
|
||||||
groupBy,
|
|
||||||
search,
|
|
||||||
archived,
|
|
||||||
} = useSelector((state: RootState) => state.taskReducer);
|
|
||||||
|
|
||||||
// Selection state
|
// Redux selectors using new task management slices
|
||||||
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([]);
|
const tasks = useSelector(taskManagementSelectors.selectAll);
|
||||||
|
const taskGroups = useSelector(selectTaskGroups);
|
||||||
|
const currentGrouping = useSelector(selectCurrentGrouping);
|
||||||
|
const selectedTaskIds = useSelector(selectSelectedTaskIds);
|
||||||
|
const loading = useSelector((state: RootState) => state.taskManagement.loading);
|
||||||
|
const error = useSelector((state: RootState) => state.taskManagement.error);
|
||||||
|
|
||||||
// Drag and Drop sensors
|
// Drag and Drop sensors
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@@ -77,24 +87,25 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
// Fetch task groups when component mounts or dependencies change
|
// Fetch task groups when component mounts or dependencies change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
dispatch(fetchTaskGroups(projectId));
|
// Fetch real tasks from API
|
||||||
|
dispatch(fetchTasks(projectId));
|
||||||
}
|
}
|
||||||
}, [dispatch, projectId, groupBy, search, archived]);
|
}, [dispatch, projectId, currentGrouping]);
|
||||||
|
|
||||||
// Memoized calculations
|
// Memoized calculations
|
||||||
const allTaskIds = useMemo(() => {
|
const allTaskIds = useMemo(() => {
|
||||||
return taskGroups.flatMap(group => group.tasks.map(task => task.id!));
|
return tasks.map(task => task.id);
|
||||||
}, [taskGroups]);
|
}, [tasks]);
|
||||||
|
|
||||||
const totalTasksCount = useMemo(() => {
|
const totalTasksCount = useMemo(() => {
|
||||||
return taskGroups.reduce((sum, group) => sum + group.tasks.length, 0);
|
return tasks.length;
|
||||||
}, [taskGroups]);
|
}, [tasks]);
|
||||||
|
|
||||||
const hasSelection = selectedTaskIds.length > 0;
|
const hasSelection = selectedTaskIds.length > 0;
|
||||||
|
|
||||||
// Handlers
|
// Handlers
|
||||||
const handleGroupingChange = (newGroupBy: IGroupBy) => {
|
const handleGroupingChange = (newGroupBy: typeof currentGrouping) => {
|
||||||
dispatch(setGroup(newGroupBy));
|
dispatch(setCurrentGrouping(newGroupBy));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
const handleDragStart = (event: DragStartEvent) => {
|
||||||
@@ -102,15 +113,17 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const taskId = active.id as string;
|
const taskId = active.id as string;
|
||||||
|
|
||||||
// Find the task and its group
|
// Find the task and its group
|
||||||
let activeTask: IProjectTask | null = null;
|
const activeTask = tasks.find(t => t.id === taskId) || null;
|
||||||
let activeGroupId: string | null = null;
|
let activeGroupId: string | null = null;
|
||||||
|
|
||||||
for (const group of taskGroups) {
|
if (activeTask) {
|
||||||
const task = group.tasks.find(t => t.id === taskId);
|
// Determine group ID based on current grouping
|
||||||
if (task) {
|
if (currentGrouping === 'status') {
|
||||||
activeTask = task;
|
activeGroupId = `status-${activeTask.status}`;
|
||||||
activeGroupId = group.id;
|
} else if (currentGrouping === 'priority') {
|
||||||
break;
|
activeGroupId = `priority-${activeTask.priority}`;
|
||||||
|
} else if (currentGrouping === 'phase') {
|
||||||
|
activeGroupId = `phase-${activeTask.phase}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,71 +152,76 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const activeTaskId = active.id as string;
|
const activeTaskId = active.id as string;
|
||||||
const overContainer = over.id as string;
|
const overContainer = over.id as string;
|
||||||
|
|
||||||
// Determine if dropping on a group or task
|
// Parse the group ID to get group type and value
|
||||||
const overGroup = taskGroups.find(g => g.id === overContainer);
|
const parseGroupId = (groupId: string) => {
|
||||||
|
const [groupType, ...groupValueParts] = groupId.split('-');
|
||||||
|
return {
|
||||||
|
groupType: groupType as 'status' | 'priority' | 'phase',
|
||||||
|
groupValue: groupValueParts.join('-')
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine target group
|
||||||
let targetGroupId = overContainer;
|
let targetGroupId = overContainer;
|
||||||
let targetIndex = -1;
|
let targetIndex = -1;
|
||||||
|
|
||||||
if (!overGroup) {
|
// Check if dropping on a task or a group
|
||||||
// Dropping on a task, find which group it belongs to
|
const targetTask = tasks.find(t => t.id === overContainer);
|
||||||
for (const group of taskGroups) {
|
if (targetTask) {
|
||||||
const taskIndex = group.tasks.findIndex(t => t.id === overContainer);
|
// Dropping on a task, determine its group
|
||||||
if (taskIndex !== -1) {
|
if (currentGrouping === 'status') {
|
||||||
targetGroupId = group.id;
|
targetGroupId = `status-${targetTask.status}`;
|
||||||
targetIndex = taskIndex;
|
} else if (currentGrouping === 'priority') {
|
||||||
break;
|
targetGroupId = `priority-${targetTask.priority}`;
|
||||||
}
|
} else if (currentGrouping === 'phase') {
|
||||||
|
targetGroupId = `phase-${targetTask.phase}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the index of the target task within its group
|
||||||
|
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
||||||
|
if (targetGroup) {
|
||||||
|
targetIndex = targetGroup.taskIds.indexOf(targetTask.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const sourceGroupInfo = parseGroupId(dragState.activeGroupId);
|
||||||
|
const targetGroupInfo = parseGroupId(targetGroupId);
|
||||||
|
|
||||||
|
// If moving between different groups, update the task's group property
|
||||||
|
if (dragState.activeGroupId !== targetGroupId) {
|
||||||
|
dispatch(moveTaskToGroup({
|
||||||
|
taskId: activeTaskId,
|
||||||
|
groupType: targetGroupInfo.groupType,
|
||||||
|
groupValue: targetGroupInfo.groupValue
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle reordering within the same group or between groups
|
||||||
const sourceGroup = taskGroups.find(g => g.id === dragState.activeGroupId);
|
const sourceGroup = taskGroups.find(g => g.id === dragState.activeGroupId);
|
||||||
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
||||||
|
|
||||||
if (!sourceGroup || !targetGroup) return;
|
if (sourceGroup && targetGroup) {
|
||||||
|
const sourceIndex = sourceGroup.taskIds.indexOf(activeTaskId);
|
||||||
|
const finalTargetIndex = targetIndex === -1 ? targetGroup.taskIds.length : targetIndex;
|
||||||
|
|
||||||
const sourceIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
|
// Calculate new order values
|
||||||
if (sourceIndex === -1) return;
|
const allTasksInTargetGroup = targetGroup.taskIds.map(id => tasks.find(t => t.id === id)!);
|
||||||
|
const newOrder = allTasksInTargetGroup.map((task, index) => {
|
||||||
|
if (index < finalTargetIndex) return task.order;
|
||||||
|
if (index === finalTargetIndex) return dragState.activeTask!.order;
|
||||||
|
return task.order + 1;
|
||||||
|
});
|
||||||
|
|
||||||
// Calculate new positions
|
// Dispatch reorder action
|
||||||
const finalTargetIndex = targetIndex === -1 ? targetGroup.tasks.length : targetIndex;
|
dispatch(reorderTasks({
|
||||||
|
taskIds: [activeTaskId, ...allTasksInTargetGroup.map(t => t.id)],
|
||||||
// Create updated task arrays
|
newOrder: [dragState.activeTask!.order, ...newOrder]
|
||||||
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) => {
|
const handleSelectTask = (taskId: string, selected: boolean) => {
|
||||||
setSelectedTaskIds(prev => {
|
dispatch(toggleTaskSelection(taskId));
|
||||||
if (selected) {
|
|
||||||
return [...prev, taskId];
|
|
||||||
} else {
|
|
||||||
return prev.filter(id => id !== taskId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleToggleSubtasks = (taskId: string) => {
|
const handleToggleSubtasks = (taskId: string) => {
|
||||||
@@ -240,15 +258,15 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
<BulkActionBar
|
<BulkActionBar
|
||||||
selectedTaskIds={selectedTaskIds}
|
selectedTaskIds={selectedTaskIds}
|
||||||
totalSelected={selectedTaskIds.length}
|
totalSelected={selectedTaskIds.length}
|
||||||
currentGrouping={groupBy}
|
currentGrouping={currentGrouping as any}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
onClearSelection={() => setSelectedTaskIds([])}
|
onClearSelection={() => dispatch(clearSelection())}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Task Groups Container */}
|
{/* Task Groups Container */}
|
||||||
<div className="task-groups-container">
|
<div className="task-groups-container">
|
||||||
{loadingGroups ? (
|
{loading ? (
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex justify-center items-center py-8">
|
<div className="flex justify-center items-center py-8">
|
||||||
<Spin size="large" />
|
<Spin size="large" />
|
||||||
@@ -275,7 +293,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
key={group.id}
|
key={group.id}
|
||||||
group={group}
|
group={group}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
currentGrouping={groupBy}
|
currentGrouping={currentGrouping}
|
||||||
selectedTaskIds={selectedTaskIds}
|
selectedTaskIds={selectedTaskIds}
|
||||||
onSelectTask={handleSelectTask}
|
onSelectTask={handleSelectTask}
|
||||||
onToggleSubtasks={handleToggleSubtasks}
|
onToggleSubtasks={handleToggleSubtasks}
|
||||||
@@ -289,7 +307,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
task={dragState.activeTask}
|
task={dragState.activeTask}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
groupId={dragState.activeGroupId!}
|
groupId={dragState.activeGroupId!}
|
||||||
currentGrouping={groupBy}
|
currentGrouping={currentGrouping}
|
||||||
isSelected={false}
|
isSelected={false}
|
||||||
isDragOverlay
|
isDragOverlay
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,26 +1,22 @@
|
|||||||
import React from 'react';
|
import React, { useMemo, useCallback } from 'react';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { Checkbox, Avatar, Tag, Progress, Typography, Space, Button, Tooltip } from 'antd';
|
|
||||||
import {
|
import {
|
||||||
HolderOutlined,
|
HolderOutlined,
|
||||||
EyeOutlined,
|
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
PaperClipOutlined,
|
PaperClipOutlined,
|
||||||
ClockCircleOutlined,
|
ClockCircleOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
import { IGroupBy, COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
|
import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tag, Tooltip } from '@/components';
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface TaskRowProps {
|
interface TaskRowProps {
|
||||||
task: IProjectTask;
|
task: Task;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
groupId: string;
|
groupId: string;
|
||||||
currentGrouping: IGroupBy;
|
currentGrouping: 'status' | 'priority' | 'phase';
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isDragOverlay?: boolean;
|
isDragOverlay?: boolean;
|
||||||
index?: number;
|
index?: number;
|
||||||
@@ -28,7 +24,7 @@ interface TaskRowProps {
|
|||||||
onToggleSubtasks?: (taskId: string) => void;
|
onToggleSubtasks?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TaskRow: React.FC<TaskRowProps> = ({
|
const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||||
task,
|
task,
|
||||||
projectId,
|
projectId,
|
||||||
groupId,
|
groupId,
|
||||||
@@ -47,7 +43,7 @@ const TaskRow: React.FC<TaskRowProps> = ({
|
|||||||
transition,
|
transition,
|
||||||
isDragging,
|
isDragging,
|
||||||
} = useSortable({
|
} = useSortable({
|
||||||
id: task.id!,
|
id: task.id,
|
||||||
data: {
|
data: {
|
||||||
type: 'task',
|
type: 'task',
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
@@ -56,33 +52,32 @@ const TaskRow: React.FC<TaskRowProps> = ({
|
|||||||
disabled: isDragOverlay,
|
disabled: isDragOverlay,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get column visibility from Redux store
|
// Get theme from Redux store
|
||||||
const columns = useSelector((state: RootState) => state.taskReducer.columns);
|
const themeMode = useSelector((state: RootState) => state.themeReducer?.mode || 'light');
|
||||||
|
|
||||||
// Helper function to check if a column is visible
|
// Memoize derived values for performance
|
||||||
const isColumnVisible = (columnKey: string) => {
|
const isDarkMode = useMemo(() => themeMode === 'dark', [themeMode]);
|
||||||
const column = columns.find(col => col.key === columnKey);
|
|
||||||
return column ? column.pinned : true; // Default to visible if column not found
|
|
||||||
};
|
|
||||||
|
|
||||||
const style = {
|
// Memoize style calculations
|
||||||
|
const style = useMemo(() => ({
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition,
|
transition,
|
||||||
opacity: isDragging ? 0.5 : 1,
|
opacity: isDragging ? 0.5 : 1,
|
||||||
};
|
}), [transform, transition, isDragging]);
|
||||||
|
|
||||||
const handleSelectChange = (checked: boolean) => {
|
// Memoize event handlers to prevent unnecessary re-renders
|
||||||
onSelect?.(task.id!, checked);
|
const handleSelectChange = useCallback((checked: boolean) => {
|
||||||
};
|
onSelect?.(task.id, checked);
|
||||||
|
}, [onSelect, task.id]);
|
||||||
|
|
||||||
const handleToggleSubtasks = () => {
|
const handleToggleSubtasks = useCallback(() => {
|
||||||
onToggleSubtasks?.(task.id!);
|
onToggleSubtasks?.(task.id);
|
||||||
};
|
}, [onToggleSubtasks, task.id]);
|
||||||
|
|
||||||
// Format due date
|
// Format due date - memoized for performance
|
||||||
const formatDueDate = (dateString?: string) => {
|
const dueDate = useMemo(() => {
|
||||||
if (!dateString) return null;
|
if (!task.dueDate) return null;
|
||||||
const date = new Date(dateString);
|
const date = new Date(task.dueDate);
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const diffTime = date.getTime() - now.getTime();
|
const diffTime = date.getTime() - now.getTime();
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||||
@@ -96,557 +91,286 @@ const TaskRow: React.FC<TaskRowProps> = ({
|
|||||||
} else {
|
} else {
|
||||||
return { text: `Due ${date.toLocaleDateString()}`, color: 'default' };
|
return { text: `Due ${date.toLocaleDateString()}`, color: 'default' };
|
||||||
}
|
}
|
||||||
|
}, [task.dueDate]);
|
||||||
|
|
||||||
|
// Memoize assignees for AvatarGroup to prevent unnecessary re-renders
|
||||||
|
const avatarGroupMembers = useMemo(() => {
|
||||||
|
return task.assignees?.map(assigneeId => ({
|
||||||
|
id: assigneeId,
|
||||||
|
team_member_id: assigneeId,
|
||||||
|
name: assigneeId // TODO: Map to actual user names
|
||||||
|
})) || [];
|
||||||
|
}, [task.assignees]);
|
||||||
|
|
||||||
|
// Memoize class names for better performance
|
||||||
|
const containerClassName = useMemo(() => `
|
||||||
|
border-b transition-all duration-300
|
||||||
|
${isDarkMode
|
||||||
|
? `border-gray-700 bg-gray-900 hover:bg-gray-800 ${isSelected ? 'bg-blue-900/20' : ''}`
|
||||||
|
: `border-gray-200 bg-white hover:bg-gray-50 ${isSelected ? 'bg-blue-50' : ''}`
|
||||||
|
}
|
||||||
|
${isSelected ? 'border-l-4 border-l-blue-500' : ''}
|
||||||
|
${isDragOverlay
|
||||||
|
? `rounded shadow-lg ${isDarkMode ? 'bg-gray-900 border border-gray-600' : 'bg-white border border-gray-300'}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
`, [isDarkMode, isSelected, isDragOverlay]);
|
||||||
|
|
||||||
|
const fixedColumnsClassName = 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 taskNameClassName = useMemo(() => `
|
||||||
|
text-sm font-medium flex-1
|
||||||
|
overflow-hidden text-ellipsis whitespace-nowrap transition-colors duration-300
|
||||||
|
${isDarkMode ? 'text-gray-100' : 'text-gray-900'}
|
||||||
|
${task.progress === 100
|
||||||
|
? `line-through ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
`, [isDarkMode, task.progress]);
|
||||||
|
|
||||||
|
// Get priority color
|
||||||
|
const getPriorityColor = (priority: string) => {
|
||||||
|
const colors = {
|
||||||
|
critical: '#ff4d4f',
|
||||||
|
high: '#ff7a45',
|
||||||
|
medium: '#faad14',
|
||||||
|
low: '#52c41a',
|
||||||
|
};
|
||||||
|
return colors[priority as keyof typeof colors] || '#d9d9d9';
|
||||||
};
|
};
|
||||||
|
|
||||||
const dueDate = formatDueDate(task.end_date);
|
// Get status color
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
const colors = {
|
||||||
|
todo: '#f0f0f0',
|
||||||
|
doing: '#1890ff',
|
||||||
|
done: '#52c41a',
|
||||||
|
};
|
||||||
|
return colors[status as keyof typeof colors] || '#d9d9d9';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create adapter for LabelsSelector to work with new Task type
|
||||||
|
const taskAdapter = useMemo(() => {
|
||||||
|
// Convert new Task type to IProjectTask for compatibility
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
name: task.title,
|
||||||
|
parent_task_id: null, // TODO: Add parent task support
|
||||||
|
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; // Type assertion for compatibility
|
||||||
|
}, [task.id, task.title, task.labels]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={`task-row ${isSelected ? 'task-row-selected' : ''} ${isDragOverlay ? 'task-row-drag-overlay' : ''}`}
|
className={containerClassName}
|
||||||
>
|
>
|
||||||
<div className="task-row-content">
|
<div className="flex h-10 max-h-10 overflow-visible relative min-w-[1200px]">
|
||||||
{/* Fixed Columns */}
|
{/* Fixed Columns */}
|
||||||
<div className="task-table-fixed-columns">
|
<div className={fixedColumnsClassName}>
|
||||||
{/* Drag Handle */}
|
{/* Drag Handle */}
|
||||||
<div className="task-table-cell task-table-cell-drag" style={{ width: '40px' }}>
|
<div className={`w-10 flex items-center justify-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
<Button
|
<Button
|
||||||
type="text"
|
variant="text"
|
||||||
size="small"
|
size="small"
|
||||||
icon={<HolderOutlined />}
|
icon={<HolderOutlined />}
|
||||||
className="drag-handle opacity-40 hover:opacity-100 cursor-grab active:cursor-grabbing"
|
className="opacity-40 hover:opacity-100 cursor-grab active:cursor-grabbing"
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
{...attributes}
|
{...attributes}
|
||||||
{...listeners}
|
{...listeners}
|
||||||
/>
|
>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selection Checkbox */}
|
{/* Selection Checkbox */}
|
||||||
<div className="task-table-cell task-table-cell-checkbox" style={{ width: '40px' }}>
|
<div className={`w-10 flex items-center justify-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onChange={(e) => handleSelectChange(e.target.checked)}
|
onChange={handleSelectChange}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task Key */}
|
{/* Task Key */}
|
||||||
<div className="task-table-cell task-table-cell-key" style={{ width: '80px' }}>
|
<div className={`w-20 flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
{task.project_id && task.task_key && (
|
<Tag
|
||||||
<Text code className="task-key">
|
backgroundColor={isDarkMode ? "#374151" : "#f0f0f0"}
|
||||||
{task.task_key}
|
color={isDarkMode ? "#d1d5db" : "#666"}
|
||||||
</Text>
|
className="truncate whitespace-nowrap max-w-full"
|
||||||
)}
|
>
|
||||||
|
{task.task_key}
|
||||||
|
</Tag>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task Name */}
|
{/* Task Name */}
|
||||||
<div className="task-table-cell task-table-cell-task" style={{ width: '475px' }}>
|
<div className="w-[475px] flex items-center px-2">
|
||||||
<div className="task-content">
|
<div className="flex-1 min-w-0 flex flex-col justify-center h-full overflow-hidden">
|
||||||
<div className="task-header">
|
<div className="flex items-center gap-2 h-5 overflow-hidden">
|
||||||
<Text
|
<span className={taskNameClassName}>
|
||||||
strong
|
{task.title}
|
||||||
className={`task-name ${task.complete_ratio === 100 ? 'task-completed' : ''}`}
|
</span>
|
||||||
>
|
|
||||||
{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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scrollable Columns */}
|
{/* Scrollable Columns */}
|
||||||
<div className="task-table-scrollable-columns">
|
<div className="flex flex-1 min-w-0">
|
||||||
{/* Progress */}
|
{/* Progress */}
|
||||||
{isColumnVisible(COLUMN_KEYS.PROGRESS) && (
|
<div className={`w-[90px] flex items-center justify-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
<div className="task-table-cell" style={{ width: '90px' }}>
|
{task.progress !== undefined && task.progress >= 0 && (
|
||||||
{task.complete_ratio !== undefined && task.complete_ratio >= 0 && (
|
<Progress
|
||||||
<div className="task-progress">
|
type="circle"
|
||||||
<Progress
|
percent={task.progress}
|
||||||
type="circle"
|
size={24}
|
||||||
percent={task.complete_ratio}
|
strokeColor={task.progress === 100 ? '#52c41a' : '#1890ff'}
|
||||||
size={32}
|
strokeWidth={2}
|
||||||
strokeColor={task.complete_ratio === 100 ? '#52c41a' : '#1890ff'}
|
showInfo={true}
|
||||||
strokeWidth={4}
|
isDarkMode={isDarkMode}
|
||||||
showInfo={true}
|
/>
|
||||||
format={(percent) => <span style={{ fontSize: '10px', fontWeight: '500' }}>{percent}%</span>}
|
)}
|
||||||
/>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Members */}
|
{/* Members */}
|
||||||
{isColumnVisible(COLUMN_KEYS.ASSIGNEES) && (
|
<div className={`w-[150px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
<div className="task-table-cell" style={{ width: '150px' }}>
|
<div className="flex items-center gap-2">
|
||||||
{task.assignees && task.assignees.length > 0 && (
|
{avatarGroupMembers.length > 0 && (
|
||||||
<Avatar.Group size="small" maxCount={3}>
|
<AvatarGroup
|
||||||
{task.assignees.map((assignee) => (
|
members={avatarGroupMembers}
|
||||||
<Tooltip key={assignee.id} title={assignee.name}>
|
size={24}
|
||||||
<Avatar
|
maxCount={3}
|
||||||
size="small"
|
isDarkMode={isDarkMode}
|
||||||
>
|
/>
|
||||||
{assignee.name?.charAt(0)?.toUpperCase()}
|
|
||||||
</Avatar>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</Avatar.Group>
|
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
className={`
|
||||||
|
w-6 h-6 rounded-full border border-dashed flex items-center justify-center
|
||||||
|
transition-colors duration-200
|
||||||
|
${isDarkMode
|
||||||
|
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
|
||||||
|
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
// TODO: Implement assignee selector functionality
|
||||||
|
console.log('Add assignee clicked for task:', task.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="text-xs">+</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Labels */}
|
{/* Labels */}
|
||||||
{isColumnVisible(COLUMN_KEYS.LABELS) && (
|
<div className={`w-[200px] max-w-[200px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
<div className="task-table-cell" style={{ width: '150px' }}>
|
<div className="flex items-center gap-1 flex-wrap h-full w-full overflow-visible relative">
|
||||||
{task.labels && task.labels.length > 0 && (
|
{task.labels?.map((label, index) => (
|
||||||
<div className="task-labels-column">
|
label.end && label.names && label.name ? (
|
||||||
{task.labels.slice(0, 3).map((label) => (
|
<CustomNumberLabel
|
||||||
<Tag
|
key={`${label.id}-${index}`}
|
||||||
key={label.id}
|
labelList={label.names}
|
||||||
className="task-label"
|
namesString={label.name}
|
||||||
style={{
|
isDarkMode={isDarkMode}
|
||||||
backgroundColor: label.color_code,
|
/>
|
||||||
border: 'none',
|
) : (
|
||||||
color: 'white',
|
<CustomColordLabel
|
||||||
}}
|
key={`${label.id}-${index}`}
|
||||||
>
|
label={label}
|
||||||
{label.name}
|
isDarkMode={isDarkMode}
|
||||||
</Tag>
|
/>
|
||||||
))}
|
)
|
||||||
{task.labels.length > 3 && (
|
))}
|
||||||
<Text type="secondary" className="task-labels-more">
|
<LabelsSelector
|
||||||
+{task.labels.length - 3}
|
task={taskAdapter}
|
||||||
</Text>
|
isDarkMode={isDarkMode}
|
||||||
)}
|
/>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* Status */}
|
{/* Status */}
|
||||||
{isColumnVisible(COLUMN_KEYS.STATUS) && (
|
<div className={`w-[100px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
<div className="task-table-cell" style={{ width: '100px' }}>
|
<Tag
|
||||||
{task.status_name && (
|
backgroundColor={getStatusColor(task.status)}
|
||||||
<div
|
color="white"
|
||||||
className="task-status"
|
className="text-xs font-medium uppercase"
|
||||||
style={{
|
>
|
||||||
backgroundColor: task.status_color,
|
{task.status}
|
||||||
color: 'white',
|
</Tag>
|
||||||
}}
|
</div>
|
||||||
>
|
|
||||||
{task.status_name}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Priority */}
|
{/* Priority */}
|
||||||
{isColumnVisible(COLUMN_KEYS.PRIORITY) && (
|
<div className={`w-[100px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
<div className="task-table-cell" style={{ width: '100px' }}>
|
<div className="flex items-center gap-2">
|
||||||
{task.priority_name && (
|
<div
|
||||||
<div className="task-priority">
|
className="w-2 h-2 rounded-full"
|
||||||
<div
|
style={{ backgroundColor: getPriorityColor(task.priority) }}
|
||||||
className="task-priority-indicator"
|
/>
|
||||||
style={{ backgroundColor: task.priority_color }}
|
<span className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-600'}`}>
|
||||||
/>
|
{task.priority}
|
||||||
<Text className="task-priority-text">{task.priority_name}</Text>
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Tracking */}
|
||||||
|
<div className={`w-[120px] flex items-center px-2 border-r ${isDarkMode ? 'border-gray-700' : 'border-gray-200'}`}>
|
||||||
|
<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>
|
</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>
|
</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: 14px;
|
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
}, (prevProps, nextProps) => {
|
||||||
|
// Custom comparison function for React.memo
|
||||||
|
// Only re-render if these specific props change
|
||||||
|
const labelsEqual = prevProps.task.labels.length === nextProps.task.labels.length &&
|
||||||
|
prevProps.task.labels.every((label, index) =>
|
||||||
|
label.id === nextProps.task.labels[index]?.id &&
|
||||||
|
label.name === nextProps.task.labels[index]?.name &&
|
||||||
|
label.color === nextProps.task.labels[index]?.color &&
|
||||||
|
label.end === nextProps.task.labels[index]?.end &&
|
||||||
|
JSON.stringify(label.names) === JSON.stringify(nextProps.task.labels[index]?.names)
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
prevProps.task.id === nextProps.task.id &&
|
||||||
|
prevProps.task.assignees === nextProps.task.assignees &&
|
||||||
|
prevProps.task.title === nextProps.task.title &&
|
||||||
|
prevProps.task.progress === nextProps.task.progress &&
|
||||||
|
prevProps.task.status === nextProps.task.status &&
|
||||||
|
prevProps.task.priority === nextProps.task.priority &&
|
||||||
|
labelsEqual &&
|
||||||
|
prevProps.isSelected === nextProps.isSelected &&
|
||||||
|
prevProps.isDragOverlay === nextProps.isDragOverlay &&
|
||||||
|
prevProps.groupId === nextProps.groupId
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TaskRow.displayName = 'TaskRow';
|
||||||
|
|
||||||
export default TaskRow;
|
export default TaskRow;
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { createSlice, createEntityAdapter, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, createEntityAdapter, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { Task, TaskManagementState } from '@/types/task-management.types';
|
import { Task, TaskManagementState } from '@/types/task-management.types';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
|
import { tasksApiService, ITaskListConfigV2 } from '@/api/tasks/tasks.api.service';
|
||||||
|
import logger from '@/utils/errorLogger';
|
||||||
|
|
||||||
// Entity adapter for normalized state
|
// Entity adapter for normalized state
|
||||||
const tasksAdapter = createEntityAdapter<Task>({
|
const tasksAdapter = createEntityAdapter<Task>({
|
||||||
selectId: (task) => task.id,
|
|
||||||
sortComparer: (a, b) => a.order - b.order,
|
sortComparer: (a, b) => a.order - b.order,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -15,6 +16,91 @@ const initialState: TaskManagementState = {
|
|||||||
error: null,
|
error: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Async thunk to fetch tasks from API
|
||||||
|
export const fetchTasks = createAsyncThunk(
|
||||||
|
'taskManagement/fetchTasks',
|
||||||
|
async (projectId: string, { rejectWithValue, getState }) => {
|
||||||
|
try {
|
||||||
|
const state = getState() as RootState;
|
||||||
|
const currentGrouping = state.grouping.currentGrouping;
|
||||||
|
|
||||||
|
const config: ITaskListConfigV2 = {
|
||||||
|
id: projectId,
|
||||||
|
archived: false,
|
||||||
|
group: currentGrouping,
|
||||||
|
field: '',
|
||||||
|
order: '',
|
||||||
|
search: '',
|
||||||
|
statuses: '',
|
||||||
|
members: '',
|
||||||
|
projects: '',
|
||||||
|
isSubtasksInclude: false,
|
||||||
|
labels: '',
|
||||||
|
priorities: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await tasksApiService.getTaskList(config);
|
||||||
|
|
||||||
|
// Helper function to safely convert time values
|
||||||
|
const convertTimeValue = (value: any): number => {
|
||||||
|
if (typeof value === 'number') return value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = parseFloat(value);
|
||||||
|
return isNaN(parsed) ? 0 : parsed;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
// Handle time objects like {hours: 2, minutes: 30}
|
||||||
|
if ('hours' in value || 'minutes' in value) {
|
||||||
|
const hours = Number(value.hours || 0);
|
||||||
|
const minutes = Number(value.minutes || 0);
|
||||||
|
return hours + (minutes / 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Transform the API response to our Task type
|
||||||
|
const tasks: Task[] = response.body.flatMap((group: any) =>
|
||||||
|
group.tasks.map((task: any) => ({
|
||||||
|
id: task.id,
|
||||||
|
task_key: task.task_key || '',
|
||||||
|
title: task.name || '',
|
||||||
|
description: task.description || '',
|
||||||
|
status: task.status_name?.toLowerCase() || 'todo',
|
||||||
|
priority: task.priority_name?.toLowerCase() || 'medium',
|
||||||
|
phase: task.phase_name || 'Development',
|
||||||
|
progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0,
|
||||||
|
assignees: task.assignees?.map((a: any) => a.team_member_id) || [],
|
||||||
|
labels: task.labels?.map((l: any) => ({
|
||||||
|
id: l.id || l.label_id,
|
||||||
|
name: l.name,
|
||||||
|
color: l.color_code || '#1890ff',
|
||||||
|
end: l.end,
|
||||||
|
names: l.names
|
||||||
|
})) || [],
|
||||||
|
dueDate: task.end_date,
|
||||||
|
timeTracking: {
|
||||||
|
estimated: convertTimeValue(task.total_time),
|
||||||
|
logged: convertTimeValue(task.time_spent),
|
||||||
|
},
|
||||||
|
customFields: {},
|
||||||
|
createdAt: task.created_at || new Date().toISOString(),
|
||||||
|
updatedAt: task.updated_at || new Date().toISOString(),
|
||||||
|
order: typeof task.sort_order === 'number' ? task.sort_order : 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return tasks;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fetch Tasks', error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
return rejectWithValue('Failed to fetch tasks');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const taskManagementSlice = createSlice({
|
const taskManagementSlice = createSlice({
|
||||||
name: 'taskManagement',
|
name: 'taskManagement',
|
||||||
initialState: tasksAdapter.getInitialState(initialState),
|
initialState: tasksAdapter.getInitialState(initialState),
|
||||||
@@ -99,6 +185,22 @@ const taskManagementSlice = createSlice({
|
|||||||
state.loading = false;
|
state.loading = false;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchTasks.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchTasks.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = null;
|
||||||
|
tasksAdapter.setAll(state, action.payload);
|
||||||
|
})
|
||||||
|
.addCase(fetchTasks.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string;
|
||||||
|
});
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export const {
|
export const {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
|
task_key: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
status: 'todo' | 'doing' | 'done';
|
status: 'todo' | 'doing' | 'done';
|
||||||
@@ -7,7 +8,7 @@ export interface Task {
|
|||||||
phase: string; // Custom phases like 'planning', 'development', 'testing', 'deployment'
|
phase: string; // Custom phases like 'planning', 'development', 'testing', 'deployment'
|
||||||
progress: number; // 0-100
|
progress: number; // 0-100
|
||||||
assignees: string[];
|
assignees: string[];
|
||||||
labels: string[];
|
labels: Label[];
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
timeTracking: {
|
timeTracking: {
|
||||||
estimated?: number;
|
estimated?: number;
|
||||||
@@ -56,6 +57,8 @@ export interface Label {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
end?: boolean;
|
||||||
|
names?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redux State Interfaces
|
// Redux State Interfaces
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default defineConfig(({ command, mode }) => {
|
|||||||
|
|
||||||
// **Development Server**
|
// **Development Server**
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 5173,
|
||||||
open: true,
|
open: true,
|
||||||
hmr: {
|
hmr: {
|
||||||
overlay: false,
|
overlay: false,
|
||||||
@@ -108,9 +108,6 @@ export default defineConfig(({ command, mode }) => {
|
|||||||
|
|
||||||
// **Preserve modules to avoid context issues**
|
// **Preserve modules to avoid context issues**
|
||||||
preserveEntrySignatures: 'strict',
|
preserveEntrySignatures: 'strict',
|
||||||
|
|
||||||
// **Ensure proper module interop**
|
|
||||||
interop: 'auto',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// **Experimental features for better performance**
|
// **Experimental features for better performance**
|
||||||
|
|||||||
Reference in New Issue
Block a user