expand sub tasks

This commit is contained in:
chamiakJ
2025-07-03 01:31:05 +05:30
parent 3bef18901a
commit ecd4d29a38
435 changed files with 13150 additions and 11087 deletions

View File

@@ -22,10 +22,10 @@ interface AssigneeSelectorProps {
isDarkMode?: boolean;
}
const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
task,
groupId = null,
isDarkMode = false
const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
task,
groupId = null,
isDarkMode = false,
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
@@ -63,8 +63,12 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
// 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)) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
@@ -74,10 +78,12 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
// Check if the button is still visible in the viewport
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const isVisible = rect.top >= 0 && rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth;
const isVisible =
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth;
if (isVisible) {
updateDropdownPosition();
} else {
@@ -98,7 +104,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleResize);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', handleScroll, true);
@@ -113,10 +119,10 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
const handleDropdownToggle = (e: React.MouseEvent) => {
e.preventDefault();
e.stopPropagation();
if (!isOpen) {
updateDropdownPosition();
// Prepare team members data when opening
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
const membersData = (members?.data || []).map(member => ({
@@ -125,7 +131,7 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
}));
const sortedMembers = sortTeamMembers(membersData);
setTeamMembers({ data: sortedMembers });
setIsOpen(true);
// Focus search input after opening
setTimeout(() => {
@@ -160,11 +166,9 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
// Update local team members state for dropdown UI
setTeamMembers(prev => ({
...prev,
data: (prev.data || []).map(member =>
member.id === memberId
? { ...member, selected: checked }
: member
)
data: (prev.data || []).map(member =>
member.id === memberId ? { ...member, selected: checked } : member
),
}));
const body = {
@@ -178,12 +182,9 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
// Emit socket event - the socket handler will update Redux with proper types
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body));
socket?.once(
SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(),
(data: any) => {
dispatch(updateEnhancedKanbanTaskAssignees(data));
}
);
socket?.once(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), (data: any) => {
dispatch(updateEnhancedKanbanTaskAssignees(data));
});
// Remove from pending changes after a short delay (optimistic)
setTimeout(() => {
@@ -198,9 +199,10 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
const checkMemberSelected = (memberId: string) => {
if (!memberId) return false;
// Use optimistic assignees if available, otherwise fall back to task assignees
const assignees = optimisticAssignees.length > 0
? optimisticAssignees
: task?.assignees?.map(assignee => assignee.team_member_id) || [];
const assignees =
optimisticAssignees.length > 0
? optimisticAssignees
: task?.assignees?.map(assignee => assignee.team_member_id) || [];
return assignees.includes(memberId);
};
@@ -217,149 +219,159 @@ const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
className={`
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
transition-colors duration-200
${isOpen
? isDarkMode
? 'border-blue-500 bg-blue-900/20 text-blue-400'
: 'border-blue-500 bg-blue-50 text-blue-600'
: 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'
${
isOpen
? isDarkMode
? 'border-blue-500 bg-blue-900/20 text-blue-400'
: 'border-blue-500 bg-blue-50 text-blue-600'
: 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'
}
`}
>
<PlusOutlined className="text-xs" />
</button>
{isOpen && createPortal(
<div
ref={dropdownRef}
onClick={e => e.stopPropagation()}
className={`
{isOpen &&
createPortal(
<div
ref={dropdownRef}
onClick={e => e.stopPropagation()}
className={`
fixed z-9999 w-72 rounded-md shadow-lg border
${isDarkMode
? 'bg-gray-800 border-gray-600'
: 'bg-white border-gray-200'
}
${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)}
placeholder="Search members..."
className={`
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)}
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'
${
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>
/>
</div>
{/* Members List */}
<div className="max-h-48 overflow-y-auto">
{filteredMembers && filteredMembers.length > 0 ? (
filteredMembers.map((member) => (
<div
key={member.id}
className={`
{/* 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'
${
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);
}
}}
style={{
// Add visual feedback for immediate response
transition: 'all 0.15s ease-in-out',
}}
>
<div className="relative">
<span onClick={e => e.stopPropagation()}>
<Checkbox
checked={checkMemberSelected(member.id || '')}
onChange={(checked) => handleMemberToggle(member.id || '', checked)}
disabled={member.pending_invitation || pendingChanges.has(member.id || '')}
isDarkMode={isDarkMode}
/>
</span>
{pendingChanges.has(member.id || '') && (
<div className={`absolute inset-0 flex items-center justify-center ${
isDarkMode ? 'bg-gray-800/50' : 'bg-white/50'
}`}>
<div className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${
isDarkMode ? 'border-blue-400' : 'border-blue-600'
}`} />
</div>
)}
</div>
<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>
onClick={() => {
if (!member.pending_invitation) {
const isSelected = checkMemberSelected(member.id || '');
handleMemberToggle(member.id || '', !isSelected);
}
}}
style={{
// Add visual feedback for immediate response
transition: 'all 0.15s ease-in-out',
}}
>
<div className="relative">
<span onClick={e => e.stopPropagation()}>
<Checkbox
checked={checkMemberSelected(member.id || '')}
onChange={checked => handleMemberToggle(member.id || '', checked)}
disabled={
member.pending_invitation || pendingChanges.has(member.id || '')
}
isDarkMode={isDarkMode}
/>
</span>
{pendingChanges.has(member.id || '') && (
<div
className={`absolute inset-0 flex items-center justify-center ${
isDarkMode ? 'bg-gray-800/50' : 'bg-white/50'
}`}
>
<div
className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${
isDarkMode ? 'border-blue-400' : 'border-blue-600'
}`}
/>
</div>
)}
</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={`
<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'
}
${isDarkMode ? 'text-blue-400 hover:bg-gray-700' : 'text-blue-600 hover:bg-blue-50'}
`}
onClick={handleInviteProjectMemberDrawer}
>
<UserAddOutlined />
Invite member
</button>
</div>
</div>,
document.body
)}
onClick={handleInviteProjectMemberDrawer}
>
<UserAddOutlined />
Invite member
</button>
</div>
</div>,
document.body
)}
</>
);
};
export default AssigneeSelector;
export default AssigneeSelector;

View File

@@ -11,47 +11,63 @@ interface AvatarProps {
style?: React.CSSProperties;
}
const Avatar: React.FC<AvatarProps> = ({
name = '',
size = 'default',
isDarkMode = false,
const Avatar: React.FC<AvatarProps> = ({
name = '',
size = 'default',
isDarkMode = false,
className = '',
src,
backgroundColor,
onClick,
style = {}
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' }
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'
'#f56565',
'#4299e1',
'#48bb78',
'#ed8936',
'#9f7aea',
'#ed64a6',
'#667eea',
'#38b2ac',
'#f6ad55',
'#4fd1c7',
];
const darkColors = [
'#e53e3e', '#3182ce', '#38a169', '#dd6b20', '#805ad5',
'#d53f8c', '#5a67d8', '#319795', '#d69e2e', '#319795'
'#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);
@@ -60,7 +76,7 @@ const Avatar: React.FC<AvatarProps> = ({
const avatarStyle = {
...sizeStyle,
backgroundColor: defaultBgColor,
...style
...style,
};
if (src) {
@@ -74,9 +90,9 @@ const Avatar: React.FC<AvatarProps> = ({
/>
);
}
return (
<div
<div
onClick={handleClick}
className={`rounded-full flex items-center justify-center text-white font-medium shadow-sm cursor-pointer ${className}`}
style={avatarStyle}
@@ -86,4 +102,4 @@ const Avatar: React.FC<AvatarProps> = ({
);
};
export default Avatar;
export default Avatar;

View File

@@ -20,42 +20,49 @@ interface AvatarGroupProps {
onClick?: (e: React.MouseEvent) => void;
}
const AvatarGroup: React.FC<AvatarGroupProps> = ({
members,
maxCount,
size = 28,
const AvatarGroup: React.FC<AvatarGroupProps> = ({
members,
maxCount,
size = 28,
isDarkMode = false,
className = '',
onClick
onClick,
}) => {
const stopPropagation = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
onClick?.(e);
}, [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}
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}
backgroundColor={member.color_code}
onClick={stopPropagation}
className="border-2 border-white"
style={isDarkMode ? { borderColor: '#374151' } : {}}
/>
</Tooltip>
);
}, [stopPropagation, size, 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;
@@ -73,13 +80,13 @@ const AvatarGroup: React.FC<AvatarGroupProps> = ({
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' }
large: { width: 48, height: 48, fontSize: '18px' },
};
return sizeMap[size];
};
@@ -87,15 +94,10 @@ const AvatarGroup: React.FC<AvatarGroupProps> = ({
<div onClick={stopPropagation} className={`flex -space-x-1 ${className}`}>
{avatarElements}
{remainingCount > 0 && (
<Tooltip
title={`${remainingCount} more`}
isDarkMode={isDarkMode}
>
<div
<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'
isDarkMode ? 'bg-gray-600 border-gray-700' : 'bg-gray-400 border-white'
}`}
style={getSizeStyle()}
onClick={stopPropagation}
@@ -108,4 +110,4 @@ const AvatarGroup: React.FC<AvatarGroupProps> = ({
);
};
export default AvatarGroup;
export default AvatarGroup;

View File

@@ -12,25 +12,25 @@ interface ButtonProps {
type?: 'button' | 'submit' | 'reset';
}
const Button: React.FC<ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>> = ({
children,
onClick,
variant = 'default',
size = 'default',
className = '',
icon,
isDarkMode = false,
const Button: React.FC<ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>> = ({
children,
onClick,
variant = 'default',
size = 'default',
className = '',
icon,
isDarkMode = false,
disabled = false,
type = 'button',
...props
...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-2 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`;
const variantClasses = {
text: isDarkMode
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
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
@@ -38,15 +38,15 @@ const Button: React.FC<ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElemen
: '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'
: 'bg-red-500 text-white hover:bg-red-600',
};
const sizeClasses = {
small: 'px-2 py-1 text-xs rounded-sm',
default: 'px-3 py-2 text-sm rounded-md',
large: 'px-4 py-3 text-base rounded-lg'
large: 'px-4 py-3 text-base rounded-lg',
};
return (
<button
type={type}
@@ -55,10 +55,10 @@ const Button: React.FC<ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElemen
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
{...props}
>
{icon && <span className={children ? "mr-1" : ""}>{icon}</span>}
{icon && <span className={children ? 'mr-1' : ''}>{icon}</span>}
{children}
</button>
);
};
export default Button;
export default Button;

View File

@@ -9,36 +9,48 @@ interface CheckboxProps {
indeterminate?: boolean;
}
const Checkbox: React.FC<CheckboxProps> = ({
checked,
onChange,
isDarkMode = false,
const Checkbox: React.FC<CheckboxProps> = ({
checked,
onChange,
isDarkMode = false,
className = '',
disabled = false,
indeterminate = false
indeterminate = false,
}) => {
return (
<label className={`inline-flex items-center cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}>
<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)}
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 || indeterminate
? `${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' : ''}`}>
<div
className={`w-4 h-4 border-2 rounded transition-all duration-200 flex items-center justify-center ${
checked || indeterminate
? `${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 && !indeterminate && (
<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" />
<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>
)}
{indeterminate && (
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
<path
fillRule="evenodd"
d="M3 10a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z"
clipRule="evenodd"
/>
</svg>
)}
</div>
@@ -46,4 +58,4 @@ const Checkbox: React.FC<CheckboxProps> = ({
);
};
export default Checkbox;
export default Checkbox;

View File

@@ -7,13 +7,9 @@ interface CustomColordLabelProps {
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;
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}>
@@ -27,4 +23,4 @@ const CustomColordLabel: React.FC<CustomColordLabelProps> = ({
);
};
export default CustomColordLabel;
export default CustomColordLabel;

View File

@@ -7,10 +7,10 @@ interface CustomNumberLabelProps {
isDarkMode?: boolean;
}
const CustomNumberLabel: React.FC<CustomNumberLabelProps> = ({
labelList,
namesString,
isDarkMode = false
const CustomNumberLabel: React.FC<CustomNumberLabelProps> = ({
labelList,
namesString,
isDarkMode = false,
}) => {
return (
<Tooltip title={labelList.join(', ')}>
@@ -27,4 +27,4 @@ const CustomNumberLabel: React.FC<CustomNumberLabelProps> = ({
);
};
export default CustomNumberLabel;
export default CustomNumberLabel;

View File

@@ -27,7 +27,7 @@ class ErrorBoundary extends React.Component<Props, State> {
logger.error('Error caught by ErrorBoundary:', {
error: error.message,
stack: error.stack,
componentStack: errorInfo.componentStack
componentStack: errorInfo.componentStack,
});
console.error('Error caught by ErrorBoundary:', error);
}
@@ -70,4 +70,4 @@ const ErrorFallback: React.FC<{ error?: Error }> = ({ error }) => {
);
};
export default ErrorBoundary;
export default ErrorBoundary;

View File

@@ -21,4 +21,4 @@ const HubSpot = () => {
return null;
};
export default HubSpot;
export default HubSpot;

View File

@@ -15,10 +15,7 @@ interface LabelsSelectorProps {
isDarkMode?: boolean;
}
const LabelsSelector: React.FC<LabelsSelectorProps> = ({
task,
isDarkMode = false
}) => {
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 });
@@ -31,9 +28,11 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({
const { socket } = useSocket();
const filteredLabels = useMemo(() => {
return (labels as ITaskLabel[])?.filter(label =>
label.name?.toLowerCase().includes(searchQuery.toLowerCase())
) || [];
return (
(labels as ITaskLabel[])?.filter(label =>
label.name?.toLowerCase().includes(searchQuery.toLowerCase())
) || []
);
}, [labels, searchQuery]);
// Update dropdown position
@@ -50,8 +49,12 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({
// 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)) {
if (
dropdownRef.current &&
!dropdownRef.current.contains(event.target as Node) &&
buttonRef.current &&
!buttonRef.current.contains(event.target as Node)
) {
setIsOpen(false);
}
};
@@ -61,10 +64,12 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({
// Check if the button is still visible in the viewport
if (buttonRef.current) {
const rect = buttonRef.current.getBoundingClientRect();
const isVisible = rect.top >= 0 && rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth;
const isVisible =
rect.top >= 0 &&
rect.left >= 0 &&
rect.bottom <= window.innerHeight &&
rect.right <= window.innerWidth;
if (isVisible) {
updateDropdownPosition();
} else {
@@ -85,7 +90,7 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({
document.addEventListener('mousedown', handleClickOutside);
window.addEventListener('scroll', handleScroll, true);
window.addEventListener('resize', handleResize);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
window.removeEventListener('scroll', handleScroll, true);
@@ -101,7 +106,7 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({
e.preventDefault();
e.stopPropagation();
console.log('Labels dropdown toggle clicked, current state:', isOpen);
if (!isOpen) {
updateDropdownPosition();
setIsOpen(true);
@@ -114,8 +119,6 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({
}
};
const handleLabelToggle = (label: ITaskLabel) => {
const labelData = {
task_id: task.id,
@@ -129,14 +132,14 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({
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('');
};
@@ -149,7 +152,7 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({
const existingLabel = filteredLabels.find(
label => label.name?.toLowerCase() === searchQuery.toLowerCase()
);
if (!existingLabel && e.key === 'Enter') {
handleCreateLabel();
}
@@ -163,130 +166,132 @@ const LabelsSelector: React.FC<LabelsSelectorProps> = ({
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 text-blue-400'
: 'border-blue-500 bg-blue-50 text-blue-600'
: 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'
${
isOpen
? isDarkMode
? 'border-blue-500 bg-blue-900/20 text-blue-400'
: 'border-blue-500 bg-blue-50 text-blue-600'
: 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'
}
`}
>
<PlusOutlined className="text-xs" />
</button>
{isOpen && createPortal(
<div
ref={dropdownRef}
className={`
{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'
}
${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={`
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'
${
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>
/>
</div>
{/* Labels List */}
<div className="max-h-48 overflow-y-auto">
{filteredLabels && filteredLabels.length > 0 ? (
filteredLabels.map((label) => (
<div
key={label.id}
className={`
{/* 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 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}
onClick={() => handleLabelToggle(label)}
>
<Checkbox
checked={checkLabelSelected(label.id || '')}
onChange={() => handleLabelToggle(label)}
isDarkMode={isDarkMode}
/>
<div
className="w-3 h-3 rounded-full 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>
))
) : (
<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={`
))
) : (
<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'
${
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>
>
Create "{searchQuery}"
</button>
)}
</div>
)}
</div>
{/* Footer */}
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
<button
className={`
{/* 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'
}
${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
)}
onClick={() => {
// TODO: Implement manage labels functionality
console.log('Manage labels clicked');
}}
>
<TagOutlined />
Manage labels
</button>
</div>
</div>,
document.body
)}
</>
);
};
export default LabelsSelector;
export default LabelsSelector;

View File

@@ -11,15 +11,15 @@ interface ProgressProps {
className?: string;
}
const Progress: React.FC<ProgressProps> = ({
percent,
type = 'line',
size = 24,
strokeColor = '#1890ff',
strokeWidth = 2,
const Progress: React.FC<ProgressProps> = ({
percent,
type = 'line',
size = 24,
strokeColor = '#1890ff',
strokeWidth = 2,
showInfo = true,
isDarkMode = false,
className = ''
className = '',
}) => {
// Ensure percent is between 0 and 100
const normalizedPercent = Math.min(Math.max(percent, 0), 100);
@@ -29,7 +29,7 @@ const Progress: React.FC<ProgressProps> = ({
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">
@@ -55,21 +55,25 @@ const Progress: React.FC<ProgressProps> = ({
/>
</svg>
{showInfo && (
<span className={`absolute text-xs font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
<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={`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
style={{
width: `${normalizedPercent}%`,
backgroundColor: normalizedPercent === 100 ? '#52c41a' : strokeColor,
}}
/>
{showInfo && (
@@ -81,4 +85,4 @@ const Progress: React.FC<ProgressProps> = ({
);
};
export default Progress;
export default Progress;

View File

@@ -10,30 +10,30 @@ interface TagProps {
isDarkMode?: boolean;
}
const Tag: React.FC<TagProps> = ({
children,
color = 'white',
backgroundColor = '#1890ff',
const Tag: React.FC<TagProps> = ({
children,
color = 'white',
backgroundColor = '#1890ff',
className = '',
size = 'default',
variant = 'default',
isDarkMode = false
isDarkMode = false,
}) => {
const sizeClasses = {
small: 'px-1 py-0.5 text-xs',
default: 'px-2 py-1 text-xs'
default: 'px-2 py-1 text-xs',
};
const baseClasses = `inline-flex items-center font-medium rounded-sm ${sizeClasses[size]}`;
if (variant === 'outlined') {
return (
<span
className={`${baseClasses} border ${className}`}
style={{
borderColor: backgroundColor,
style={{
borderColor: backgroundColor,
color: backgroundColor,
backgroundColor: 'transparent'
backgroundColor: 'transparent',
}}
>
{children}
@@ -42,13 +42,10 @@ const Tag: React.FC<TagProps> = ({
}
return (
<span
className={`${baseClasses} ${className}`}
style={{ backgroundColor, color }}
>
<span className={`${baseClasses} ${className}`} style={{ backgroundColor, color }}>
{children}
</span>
);
};
export default Tag;
export default Tag;

View File

@@ -20,7 +20,7 @@ const TawkTo: React.FC<TawkToProps> = ({ propertyId, widgetId }) => {
s1.async = true;
s1.src = `https://embed.tawk.to/${propertyId}/${widgetId}`;
s1.setAttribute('crossorigin', '*');
const s0 = document.getElementsByTagName('script')[0];
s0.parentNode?.insertBefore(s1, s0);
@@ -31,13 +31,13 @@ const TawkTo: React.FC<TawkToProps> = ({ propertyId, widgetId }) => {
if (tawkScript && tawkScript.parentNode) {
tawkScript.parentNode.removeChild(tawkScript);
}
// Remove the tawk.to iframe
const tawkIframe = document.getElementById('tawk-iframe');
if (tawkIframe) {
tawkIframe.remove();
}
// Reset Tawk globals
delete window.Tawk_API;
delete window.Tawk_LoadStart;
@@ -47,4 +47,4 @@ const TawkTo: React.FC<TawkToProps> = ({ propertyId, widgetId }) => {
return null;
};
export default TawkTo;
export default TawkTo;

View File

@@ -8,28 +8,30 @@ interface TooltipProps {
className?: string;
}
const Tooltip: React.FC<TooltipProps> = ({
title,
children,
isDarkMode = false,
const Tooltip: React.FC<TooltipProps> = ({
title,
children,
isDarkMode = false,
placement = 'top',
className = ''
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'
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-sm shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-50 pointer-events-none min-w-max`}>
<div
className={`absolute ${placementClasses[placement]} px-2 py-1 text-xs text-white ${isDarkMode ? 'bg-gray-700' : 'bg-gray-900'} rounded-sm 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;
export default Tooltip;

View File

@@ -39,7 +39,9 @@ export const TasksStep: React.FC<Props> = ({ onEnter, styles, isDarkMode }) => {
const updateTask = (id: number, value: string) => {
const sanitizedValue = sanitizeInput(value);
dispatch(setTasks(tasks.map(task => (task.id === id ? { ...task, value: sanitizedValue } : task))));
dispatch(
setTasks(tasks.map(task => (task.id === id ? { ...task, value: sanitizedValue } : task)))
);
};
const handleKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {

View File

@@ -18,7 +18,9 @@ const AccountStorage = ({ themeMode }: IAccountStorageProps) => {
const dispatch = useAppDispatch();
const [subscriptionType, setSubscriptionType] = useState<string>(SUBSCRIPTION_STATUS.TRIALING);
const { loadingBillingInfo, billingInfo, storageInfo } = useAppSelector(state => state.adminCenterReducer);
const { loadingBillingInfo, billingInfo, storageInfo } = useAppSelector(
state => state.adminCenterReducer
);
const formatBytes = useMemo(
() =>

View File

@@ -10,7 +10,10 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
import { useMediaQuery } from 'react-responsive';
import { useTranslation } from 'react-i18next';
import { fetchBillingInfo, fetchFreePlanSettings } from '@/features/admin-center/admin-center.slice';
import {
fetchBillingInfo,
fetchFreePlanSettings,
} from '@/features/admin-center/admin-center.slice';
import CurrentPlanDetails from './current-plan-details/current-plan-details';
import AccountStorage from './account-storage/account-storage';
@@ -68,10 +71,7 @@ const CurrentBill: React.FC = () => {
</div>
<div style={{ marginTop: '1.5rem' }}>
<Card
title={<span style={titleStyle}>{t('invoices')}</span>}
style={{ marginTop: '16px' }}
>
<Card title={<span style={titleStyle}>{t('invoices')}</span>} style={{ marginTop: '16px' }}>
<InvoicesTable />
</Card>
</div>
@@ -92,7 +92,8 @@ const CurrentBill: React.FC = () => {
) : (
renderMobileView()
)}
{currentSession?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE && renderChargesAndInvoices()}
{currentSession?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
renderChargesAndInvoices()}
</div>
);
};

View File

@@ -7,7 +7,20 @@ import {
} from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import logger from '@/utils/errorLogger';
import { Button, Card, Flex, Modal, Space, Tooltip, Typography, Statistic, Select, Form, Row, Col } from 'antd/es';
import {
Button,
Card,
Flex,
Modal,
Space,
Tooltip,
Typography,
Statistic,
Select,
Form,
Row,
Col,
} from 'antd/es';
import RedeemCodeDrawer from '../drawers/redeem-code-drawer/redeem-code-drawer';
import {
fetchBillingInfo,
@@ -44,8 +57,9 @@ const CurrentPlanDetails = () => {
const browserTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
type SeatOption = { label: string; value: number | string };
const seatCountOptions: SeatOption[] = [1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90]
.map(value => ({ label: value.toString(), value }));
const seatCountOptions: SeatOption[] = [
1, 2, 3, 4, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60, 65, 70, 75, 80, 85, 90,
].map(value => ({ label: value.toString(), value }));
seatCountOptions.push({ label: '100+', value: '100+' });
const handleSubscriptionAction = async (action: 'pause' | 'resume') => {
@@ -127,8 +141,10 @@ const CurrentPlanDetails = () => {
const shouldShowAddSeats = () => {
if (!billingInfo) return false;
return billingInfo.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
billingInfo.status === SUBSCRIPTION_STATUS.ACTIVE;
return (
billingInfo.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
billingInfo.status === SUBSCRIPTION_STATUS.ACTIVE
);
};
const renderExtra = () => {
@@ -199,13 +215,13 @@ const CurrentPlanDetails = () => {
const getExpirationMessage = (expireDate: string) => {
const today = new Date();
today.setHours(0, 0, 0, 0); // Set to start of day for comparison
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const expDate = new Date(expireDate);
expDate.setHours(0, 0, 0, 0); // Set to start of day for comparison
if (expDate.getTime() === today.getTime()) {
return t('expirestoday', 'today');
} else if (expDate.getTime() === tomorrow.getTime()) {
@@ -230,14 +246,13 @@ const CurrentPlanDetails = () => {
</Typography.Text>
<Tooltip title={formatDate(new Date(trialExpireDate))}>
<Typography.Text>
{isExpired
{isExpired
? t('trialExpired', {
trial_expire_string: getExpirationMessage(trialExpireDate)
trial_expire_string: getExpirationMessage(trialExpireDate),
})
: t('trialInProgress', {
trial_expire_string: getExpirationMessage(trialExpireDate)
})
}
trial_expire_string: getExpirationMessage(trialExpireDate),
})}
</Typography.Text>
</Tooltip>
</Flex>
@@ -268,25 +283,24 @@ const CurrentPlanDetails = () => {
{billingInfo?.billing_type === 'year'
? billingInfo.unit_price_per_month
: billingInfo?.unit_price}
&nbsp;{t('perMonthPerUser')}
</Typography.Text>
</Flex>
{shouldShowAddSeats() && billingInfo?.total_seats && (
<div style={{ marginTop: '16px' }}>
<Row gutter={16} align="middle">
<Col span={6}>
<Statistic
title={t('totalSeats')}
value={billingInfo.total_seats}
<Statistic
title={t('totalSeats')}
value={billingInfo.total_seats}
valueStyle={{ fontSize: '24px', fontWeight: 'bold' }}
/>
</Col>
<Col span={8}>
<Button
type="primary"
icon={<PlusOutlined />}
<Button
type="primary"
icon={<PlusOutlined />}
onClick={handleAddMoreSeats}
style={{ backgroundColor: '#1890ff', borderColor: '#1890ff' }}
>
@@ -294,9 +308,9 @@ const CurrentPlanDetails = () => {
</Button>
</Col>
<Col span={6}>
<Statistic
title={t('availableSeats')}
value={calculateRemainingSeats()}
<Statistic
title={t('availableSeats')}
value={calculateRemainingSeats()}
valueStyle={{ fontSize: '24px', fontWeight: 'bold' }}
/>
</Col>
@@ -308,16 +322,24 @@ const CurrentPlanDetails = () => {
};
const renderCreditSubscriptionInfo = () => {
return <Flex vertical>
<Typography.Text strong>{t('creditPlan','Credit Plan')}</Typography.Text>
</Flex>
return (
<Flex vertical>
<Typography.Text strong>{t('creditPlan', 'Credit Plan')}</Typography.Text>
</Flex>
);
};
const renderCustomSubscriptionInfo = () => {
return <Flex vertical>
<Typography.Text strong>{t('customPlan','Custom Plan')}</Typography.Text>
<Typography.Text>{t('planValidTill','Your plan is valid till {{date}}',{date: billingInfo?.valid_till_date})}</Typography.Text>
</Flex>
return (
<Flex vertical>
<Typography.Text strong>{t('customPlan', 'Custom Plan')}</Typography.Text>
<Typography.Text>
{t('planValidTill', 'Your plan is valid till {{date}}', {
date: billingInfo?.valid_till_date,
})}
</Typography.Text>
</Flex>
);
};
return (
@@ -326,7 +348,6 @@ const CurrentPlanDetails = () => {
title={
<Typography.Text
style={{
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
fontWeight: 500,
fontSize: '16px',
@@ -340,12 +361,16 @@ const CurrentPlanDetails = () => {
>
<Flex vertical>
<div style={{ marginBottom: '14px' }}>
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.LIFE_TIME_DEAL && renderLtdDetails()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.LIFE_TIME_DEAL &&
renderLtdDetails()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.TRIAL && renderTrialDetails()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.FREE && renderFreePlan()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE && renderPaddleSubscriptionInfo()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CREDIT && renderCreditSubscriptionInfo()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CUSTOM && renderCustomSubscriptionInfo()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.PADDLE &&
renderPaddleSubscriptionInfo()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CREDIT &&
renderCreditSubscriptionInfo()}
{billingInfo?.subscription_type === ISUBSCRIPTION_TYPE.CUSTOM &&
renderCustomSubscriptionInfo()}
</div>
{shouldShowRedeemButton() && (
@@ -370,7 +395,7 @@ const CurrentPlanDetails = () => {
>
{browserTimeZone === 'Asia/Colombo' ? <UpgradePlansLKR /> : <UpgradePlans />}
</Modal>
<Modal
title={t('addMoreSeats')}
open={isMoreSeatsModalVisible}
@@ -380,18 +405,22 @@ const CurrentPlanDetails = () => {
centered
>
<Flex vertical gap="middle" style={{ marginTop: '8px' }}>
<Typography.Paragraph style={{ fontSize: '16px', margin: '0 0 16px 0', fontWeight: 500 }}>
{t('purchaseSeatsText','To continue, you\'ll need to purchase additional seats.')}
<Typography.Paragraph
style={{ fontSize: '16px', margin: '0 0 16px 0', fontWeight: 500 }}
>
{t('purchaseSeatsText', "To continue, you'll need to purchase additional seats.")}
</Typography.Paragraph>
<Typography.Paragraph style={{ margin: '0 0 16px 0' }}>
{t('currentSeatsText','You currently have {{seats}} seats available.',{seats: billingInfo?.total_seats})}
{t('currentSeatsText', 'You currently have {{seats}} seats available.', {
seats: billingInfo?.total_seats,
})}
</Typography.Paragraph>
<Typography.Paragraph style={{ margin: '0 0 24px 0' }}>
{t('selectSeatsText','Please select the number of additional seats to purchase.')}
{t('selectSeatsText', 'Please select the number of additional seats to purchase.')}
</Typography.Paragraph>
<div style={{ marginBottom: '24px' }}>
<span style={{ color: '#ff4d4f', marginRight: '4px' }}>*</span>
<span style={{ marginRight: '8px' }}>Seats:</span>
@@ -402,28 +431,25 @@ const CurrentPlanDetails = () => {
style={{ width: '300px' }}
/>
</div>
<Flex justify="end">
{selectedSeatCount.toString() !== '100+' ? (
<Button
type="primary"
<Button
type="primary"
loading={addingSeats}
onClick={handlePurchaseMoreSeats}
style={{
minWidth: '100px',
style={{
minWidth: '100px',
backgroundColor: '#1890ff',
borderColor: '#1890ff',
borderRadius: '2px'
borderRadius: '2px',
}}
>
{t('purchase','Purchase')}
{t('purchase', 'Purchase')}
</Button>
) : (
<Button
type="primary"
size="middle"
>
{t('contactSales','Contact sales')}
<Button type="primary" size="middle">
{t('contactSales', 'Contact sales')}
</Button>
)}
</Flex>

View File

@@ -1,5 +1,17 @@
import { useEffect, useState } from 'react';
import { Button, Card, Col, Flex, Form, Row, Select, Tag, Tooltip, Typography, message } from 'antd/es';
import {
Button,
Card,
Col,
Flex,
Form,
Row,
Select,
Tag,
Tooltip,
Typography,
message,
} from 'antd/es';
import { useTranslation } from 'react-i18next';
import { adminCenterApiService } from '@/api/admin-center/admin-center.api.service';
@@ -106,7 +118,7 @@ const UpgradePlans = () => {
const handlePaddleCallback = (data: any) => {
console.log('Paddle event:', data);
switch (data.event) {
case 'Checkout.Loaded':
setSwitchingToPaddlePlan(false);
@@ -144,13 +156,13 @@ const UpgradePlans = () => {
const initializePaddle = (data: IUpgradeSubscriptionPlanResponse) => {
setPaddleLoading(true);
setPaddleError(null);
// Check if Paddle is already loaded
if (window.Paddle) {
configurePaddle(data);
return;
}
const script = document.createElement('script');
script.src = 'https://cdn.paddle.com/paddle/paddle.js';
script.type = 'text/javascript';
@@ -159,7 +171,7 @@ const UpgradePlans = () => {
script.onload = () => {
configurePaddle(data);
};
script.onerror = () => {
setPaddleLoading(false);
setPaddleError('Failed to load Paddle checkout');
@@ -169,7 +181,7 @@ const UpgradePlans = () => {
document.getElementsByTagName('head')[0].appendChild(script);
};
const configurePaddle = (data: IUpgradeSubscriptionPlanResponse) => {
try {
if (data.sandbox) Paddle.Environment.set('sandbox');
@@ -193,7 +205,7 @@ const UpgradePlans = () => {
setSwitchingToPaddlePlan(true);
setPaddleLoading(true);
setPaddleError(null);
if (billingInfo?.trial_in_progress && billingInfo.status === SUBSCRIPTION_STATUS.TRIALING) {
const res = await billingApiService.upgradeToPaidPlan(planId, selectedSeatCount);
if (res.done) {
@@ -264,7 +276,6 @@ const UpgradePlans = () => {
const isSelected = (cardIndex: IPaddlePlans) =>
selectedPlan === cardIndex ? { border: '2px solid #1890ff' } : {};
const cardStyles = {
title: {
color: themeMode === 'dark' ? '#ffffffd9' : '#000000d9',
@@ -363,7 +374,6 @@ const UpgradePlans = () => {
title={<span style={cardStyles.title}>{t('freePlan')}</span>}
onClick={() => setSelectedCard(paddlePlans.FREE)}
>
<div style={cardStyles.priceContainer}>
<Flex justify="space-between" align="center">
<Typography.Title level={1}>$ 0.00</Typography.Title>
@@ -389,7 +399,6 @@ const UpgradePlans = () => {
<Card
style={{ ...isSelected(paddlePlans.ANNUAL), height: '100%' }}
hoverable
title={
<span style={cardStyles.title}>
{t('annualPlan')}{' '}
@@ -401,7 +410,6 @@ const UpgradePlans = () => {
onClick={() => setSelectedCard(paddlePlans.ANNUAL)}
>
<div style={cardStyles.priceContainer}>
<Flex justify="space-between" align="center">
<Typography.Title level={1}>$ {plans.annual_price}</Typography.Title>
<Typography.Text>seat / month</Typography.Text>
@@ -442,7 +450,6 @@ const UpgradePlans = () => {
hoverable
title={<span style={cardStyles.title}>{t('monthlyPlan')}</span>}
onClick={() => setSelectedCard(paddlePlans.MONTHLY)}
>
<div style={cardStyles.priceContainer}>
<Flex justify="space-between" align="center">
@@ -501,7 +508,9 @@ const UpgradePlans = () => {
onClick={continueWithPaddlePlan}
disabled={billingInfo?.plan_id === plans.annual_plan_id}
>
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE ? t('changeToPlan', {plan: t('annualPlan')}) : t('continueWith', {plan: t('annualPlan')})}
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
? t('changeToPlan', { plan: t('annualPlan') })
: t('continueWith', { plan: t('annualPlan') })}
</Button>
)}
{selectedPlan === paddlePlans.MONTHLY && (
@@ -512,7 +521,9 @@ const UpgradePlans = () => {
onClick={continueWithPaddlePlan}
disabled={billingInfo?.plan_id === plans.monthly_plan_id}
>
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE ? t('changeToPlan', {plan: t('monthlyPlan')}) : t('continueWith', {plan: t('monthlyPlan')})}
{billingInfo?.status === SUBSCRIPTION_STATUS.ACTIVE
? t('changeToPlan', { plan: t('monthlyPlan') })
: t('continueWith', { plan: t('monthlyPlan') })}
</Button>
)}
</Row>

View File

@@ -39,7 +39,7 @@ const Configuration: React.FC = () => {
}, []);
const handleSave = async (values: any) => {
try {
try {
setLoading(true);
const res = await adminCenterApiService.updateBillingConfiguration(values);
if (res.done) {
@@ -75,11 +75,7 @@ const Configuration: React.FC = () => {
}
style={{ marginTop: '16px' }}
>
<Form
form={form}
initialValues={configuration}
onFinish={handleSave}
>
<Form form={form} initialValues={configuration} onFinish={handleSave}>
<Row>
<Col span={8} style={{ padding: '0 12px', height: '86px' }}>
<Form.Item
@@ -180,7 +176,6 @@ const Configuration: React.FC = () => {
showSearch
placeholder="Country"
optionFilterProp="label"
allowClear
options={countryOptions}
/>

View File

@@ -68,11 +68,11 @@ const SettingTeamDrawer: React.FC<SettingTeamDrawerProps> = ({
const body = {
name: values.name,
teamMembers: teamData?.team_members || []
teamMembers: teamData?.team_members || [],
};
const response = await adminCenterApiService.updateTeam(teamId, body);
if (response.done) {
setIsSettingDrawerOpen(false);
}
@@ -108,7 +108,7 @@ const SettingTeamDrawer: React.FC<SettingTeamDrawerProps> = ({
if (value === 'Owner') {
return;
}
// Update the team member's role in teamData
if (teamData && teamData.team_members) {
const updatedMembers = teamData.team_members.map(member => {
@@ -117,20 +117,21 @@ const SettingTeamDrawer: React.FC<SettingTeamDrawerProps> = ({
}
return member;
});
setTeamData({
...teamData,
team_members: updatedMembers
team_members: updatedMembers,
});
}
};
const isDisabled = record.role_name === 'Owner' || record.pending_invitation;
const tooltipTitle = record.role_name === 'Owner'
? t('cannotChangeOwnerRole')
: record.pending_invitation
? t('pendingInvitation')
: '';
const tooltipTitle =
record.role_name === 'Owner'
? t('cannotChangeOwnerRole')
: record.pending_invitation
? t('pendingInvitation')
: '';
const selectComponent = (
<Select
@@ -145,9 +146,7 @@ const SettingTeamDrawer: React.FC<SettingTeamDrawerProps> = ({
return (
<div>
{isDisabled ? (
<Tooltip title={tooltipTitle}>
{selectComponent}
</Tooltip>
<Tooltip title={tooltipTitle}>{selectComponent}</Tooltip>
) : (
selectComponent
)}

View File

@@ -12,31 +12,34 @@ const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount }) => {
e.stopPropagation();
}, []);
const renderAvatar = useCallback((member: InlineMember, index: number) => (
<Tooltip
key={member.team_member_id || index}
title={member.end && member.names ? member.names.join(', ') : member.name}
>
{member.avatar_url ? (
<span onClick={stopPropagation}>
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
</span>
) : (
<span onClick={stopPropagation}>
<Avatar
size={28}
key={member.team_member_id || index}
style={{
backgroundColor: member.color_code || '#ececec',
fontSize: '14px',
}}
>
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
</Avatar>
</span>
)}
</Tooltip>
), [stopPropagation]);
const renderAvatar = useCallback(
(member: InlineMember, index: number) => (
<Tooltip
key={member.team_member_id || index}
title={member.end && member.names ? member.names.join(', ') : member.name}
>
{member.avatar_url ? (
<span onClick={stopPropagation}>
<Avatar src={member.avatar_url} size={28} key={member.team_member_id || index} />
</span>
) : (
<span onClick={stopPropagation}>
<Avatar
size={28}
key={member.team_member_id || index}
style={{
backgroundColor: member.color_code || '#ececec',
fontSize: '14px',
}}
>
{member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase()}
</Avatar>
</span>
)}
</Tooltip>
),
[stopPropagation]
);
const visibleMembers = useMemo(() => {
return maxCount ? members.slice(0, maxCount) : members;
@@ -48,9 +51,7 @@ const Avatars: React.FC<AvatarsProps> = React.memo(({ members, maxCount }) => {
return (
<div onClick={stopPropagation}>
<Avatar.Group>
{avatarElements}
</Avatar.Group>
<Avatar.Group>{avatarElements}</Avatar.Group>
</div>
);
});

View File

@@ -101,7 +101,6 @@ const BoardAssigneeSelector = ({ task, groupId = null }: BoardAssigneeSelectorPr
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
return assignees?.includes(memberId);
};
const membersDropdownContent = (
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
@@ -143,16 +142,16 @@ const BoardAssigneeSelector = ({ task, groupId = null }: BoardAssigneeSelectorPr
/>
</div>
<Flex vertical>
<Typography.Text>{member.name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{member.email}&nbsp;
{member.pending_invitation && (
<Typography.Text type="danger" style={{ fontSize: 10 }}>
({t('pendingInvitation')})
</Typography.Text>
)}
</Typography.Text>
</Flex>
<Typography.Text>{member.name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{member.email}&nbsp;
{member.pending_invitation && (
<Typography.Text type="danger" style={{ fontSize: 10 }}>
({t('pendingInvitation')})
</Typography.Text>
)}
</Typography.Text>
</Flex>
</List.Item>
))
) : (
@@ -201,7 +200,7 @@ const BoardAssigneeSelector = ({ task, groupId = null }: BoardAssigneeSelectorPr
type="dashed"
shape="circle"
size="small"
onClick={(e) => e.stopPropagation()}
onClick={e => e.stopPropagation()}
icon={
<PlusOutlined
style={{

View File

@@ -14,7 +14,7 @@ const CustomAvatarGroup = ({ task, sectionId }: CustomAvatarGroupProps) => {
<Flex
gap={4}
align="center"
onClick={(e) => e.stopPropagation()}
onClick={e => e.stopPropagation()}
style={{
borderRadius: 4,
cursor: 'pointer',

View File

@@ -86,7 +86,7 @@ const CustomDueDatePicker = ({
width: 26,
height: 26,
}}
onClick={(e) => {
onClick={e => {
e.stopPropagation(); // Keep this as a backup
setIsDatePickerOpen(true);
}}
@@ -98,4 +98,4 @@ const CustomDueDatePicker = ({
);
};
export default CustomDueDatePicker;
export default CustomDueDatePicker;

View File

@@ -31,7 +31,8 @@ const PrioritySection = ({ task }: PrioritySectionProps) => {
const iconProps = {
style: {
color: themeMode === 'dark' ? selectedPriority.color_code_dark : selectedPriority.color_code,
color:
themeMode === 'dark' ? selectedPriority.color_code_dark : selectedPriority.color_code,
marginRight: '0.25rem',
},
};
@@ -40,9 +41,19 @@ const PrioritySection = ({ task }: PrioritySectionProps) => {
case 'Low':
return <MinusOutlined {...iconProps} />;
case 'Medium':
return <PauseOutlined {...iconProps} style={{ ...iconProps.style, transform: 'rotate(90deg)' }} />;
return (
<PauseOutlined
{...iconProps}
style={{ ...iconProps.style, transform: 'rotate(90deg)' }}
/>
);
case 'High':
return <DoubleLeftOutlined {...iconProps} style={{ ...iconProps.style, transform: 'rotate(90deg)' }} />;
return (
<DoubleLeftOutlined
{...iconProps}
style={{ ...iconProps.style, transform: 'rotate(90deg)' }}
/>
);
default:
return null;
}
@@ -50,11 +61,7 @@ const PrioritySection = ({ task }: PrioritySectionProps) => {
if (!task.priority || !selectedPriority) return null;
return (
<Flex gap={4}>
{priorityIcon}
</Flex>
);
return <Flex gap={4}>{priorityIcon}</Flex>;
};
export default PrioritySection;

View File

@@ -2,8 +2,12 @@ import React, { Suspense } from 'react';
import { Spin } from 'antd';
// Lazy load chart components to reduce initial bundle size
const LazyBar = React.lazy(() => import('react-chartjs-2').then(module => ({ default: module.Bar })));
const LazyDoughnut = React.lazy(() => import('react-chartjs-2').then(module => ({ default: module.Doughnut })));
const LazyBar = React.lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Bar }))
);
const LazyDoughnut = React.lazy(() =>
import('react-chartjs-2').then(module => ({ default: module.Doughnut }))
);
interface ChartLoaderProps {
type: 'bar' | 'doughnut';
@@ -14,7 +18,7 @@ interface ChartLoaderProps {
const ChartLoader: React.FC<ChartLoaderProps> = ({ type, ...props }) => {
const ChartComponent = type === 'bar' ? LazyBar : LazyDoughnut;
return (
<Suspense fallback={<Spin size="large" />}>
<ChartComponent {...props} />
@@ -22,4 +26,4 @@ const ChartLoader: React.FC<ChartLoaderProps> = ({ type, ...props }) => {
);
};
export default ChartLoader;
export default ChartLoader;

View File

@@ -15,7 +15,9 @@ const Collapsible = ({ isOpen, children, className = '', color }: CollapsiblePro
marginTop: '6px',
}}
className={`transition-all duration-300 ease-in-out ${
isOpen ? 'max-h-[2000px] opacity-100 overflow-x-scroll' : 'max-h-0 opacity-0 overflow-hidden'
isOpen
? 'max-h-[2000px] opacity-100 overflow-x-scroll'
: 'max-h-0 opacity-0 overflow-hidden'
} ${className}`}
>
{children}

View File

@@ -1,7 +1,10 @@
import { AutoComplete, Button, Drawer, Flex, Form, message, Select, Spin, Typography } from 'antd';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { toggleInviteMemberDrawer, triggerTeamMembersRefresh } from '../../../features/settings/member/memberSlice';
import {
toggleInviteMemberDrawer,
triggerTeamMembersRefresh,
} from '../../../features/settings/member/memberSlice';
import { useTranslation } from 'react-i18next';
import { useState, useEffect, useCallback } from 'react';
import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.service';
@@ -177,4 +180,4 @@ const InviteTeamMembers = () => {
);
};
export default InviteTeamMembers;
export default InviteTeamMembers;

View File

@@ -91,4 +91,3 @@
.custom-template-list .selected-custom-template:hover {
background-color: var(--color-paleBlue);
}

View File

@@ -40,4 +40,4 @@
justify-content: center;
align-items: center;
min-height: 200px;
}
}

View File

@@ -16,10 +16,7 @@ import {
pointerWithin,
rectIntersection,
} from '@dnd-kit/core';
import {
SortableContext,
horizontalListSortingStrategy,
} from '@dnd-kit/sortable';
import { SortableContext, horizontalListSortingStrategy } from '@dnd-kit/sortable';
import { RootState } from '@/app/store';
import {
fetchEnhancedKanbanGroups,
@@ -49,7 +46,9 @@ import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
import { useAuthService } from '@/hooks/useAuth';
// Import the TaskListFilters component
const TaskListFilters = React.lazy(() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters'));
const TaskListFilters = React.lazy(
() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters')
);
interface EnhancedKanbanBoardProps {
projectId: string;
className?: string;
@@ -57,24 +56,22 @@ interface EnhancedKanbanBoardProps {
const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, className = '' }) => {
const dispatch = useDispatch();
const {
taskGroups,
loadingGroups,
error,
dragState,
performanceMetrics
} = useSelector((state: RootState) => state.enhancedKanbanReducer);
const { taskGroups, loadingGroups, error, dragState, performanceMetrics } = useSelector(
(state: RootState) => state.enhancedKanbanReducer
);
const { socket } = useSocket();
const authService = useAuthService();
const teamId = authService.getCurrentSession()?.team_id;
const groupBy = useSelector((state: RootState) => state.enhancedKanbanReducer.groupBy);
const project = useAppSelector((state: RootState) => state.projectReducer.project);
const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer);
const { statusCategories, status: existingStatuses } = useAppSelector(
state => state.taskStatusReducer
);
const themeMode = useAppSelector(state => state.themeReducer.mode);
// Load filter data
useFilterDataLoader();
// Set up socket event handlers for real-time updates
useTaskSocketHandlers();
@@ -106,22 +103,18 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
}, [dispatch, projectId]);
// Get all task IDs for sortable context
const allTaskIds = useMemo(() =>
taskGroups.flatMap(group => group.tasks.map(task => task.id!)),
[taskGroups]
);
const allGroupIds = useMemo(() =>
taskGroups.map(group => group.id),
const allTaskIds = useMemo(
() => taskGroups.flatMap(group => group.tasks.map(task => task.id!)),
[taskGroups]
);
const allGroupIds = useMemo(() => taskGroups.map(group => group.id), [taskGroups]);
// Enhanced collision detection
const collisionDetectionStrategy = (args: any) => {
// First, let's see if we're colliding with any droppable areas
const pointerIntersections = pointerWithin(args);
const intersections = pointerIntersections.length > 0
? pointerIntersections
: rectIntersection(args);
const intersections =
pointerIntersections.length > 0 ? pointerIntersections : rectIntersection(args);
let overId = getFirstCollision(intersections, 'id');
@@ -162,11 +155,13 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
setActiveGroup(foundGroup);
setActiveTask(null);
dispatch(setDragState({
activeTaskId: null,
activeGroupId: activeId,
isDragging: true,
}));
dispatch(
setDragState({
activeTaskId: null,
activeGroupId: activeId,
isDragging: true,
})
);
} else {
// Dragging a task
let foundTask = null;
@@ -184,11 +179,13 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
setActiveTask(foundTask);
setActiveGroup(null);
dispatch(setDragState({
activeTaskId: activeId,
activeGroupId: foundGroup?.id || null,
isDragging: true,
}));
dispatch(
setDragState({
activeTaskId: activeId,
activeGroupId: foundGroup?.id || null,
isDragging: true,
})
);
}
};
@@ -220,12 +217,14 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
setOverId(null);
// Reset Redux drag state
dispatch(setDragState({
activeTaskId: null,
activeGroupId: null,
overId: null,
isDragging: false,
}));
dispatch(
setDragState({
activeTaskId: null,
activeGroupId: null,
overId: null,
isDragging: false,
})
);
if (!over) return;
@@ -258,7 +257,7 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
// Call API to update status order
try {
const requestBody: ITaskStatusCreateRequest = {
status_order: columnOrder
status_order: columnOrder,
};
const response = await statusApiService.updateStatusOrder(requestBody, projectId);
@@ -267,7 +266,13 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
revertedGroups.splice(fromIndex, 0, movedBackGroup);
dispatch(reorderGroups({ fromIndex: toIndex, toIndex: fromIndex, reorderedGroups: revertedGroups }));
dispatch(
reorderGroups({
fromIndex: toIndex,
toIndex: fromIndex,
reorderedGroups: revertedGroups,
})
);
alertService.error('Failed to update column order', 'Please try again');
}
} catch (error) {
@@ -275,7 +280,13 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
const revertedGroups = [...reorderedGroups];
const [movedBackGroup] = revertedGroups.splice(toIndex, 1);
revertedGroups.splice(fromIndex, 0, movedBackGroup);
dispatch(reorderGroups({ fromIndex: toIndex, toIndex: fromIndex, reorderedGroups: revertedGroups }));
dispatch(
reorderGroups({
fromIndex: toIndex,
toIndex: fromIndex,
reorderedGroups: revertedGroups,
})
);
alertService.error('Failed to update column order', 'Please try again');
logger.error('Failed to update column order', error);
}
@@ -338,24 +349,28 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
}
// Synchronous UI update
dispatch(reorderTasks({
activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id,
fromIndex: sourceIndex,
toIndex: targetIndex,
task: movedTask,
updatedSourceTasks,
updatedTargetTasks,
}));
dispatch(reorderEnhancedKanbanTasks({
activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id,
fromIndex: sourceIndex,
toIndex: targetIndex,
task: movedTask,
updatedSourceTasks,
updatedTargetTasks,
}) as any);
dispatch(
reorderTasks({
activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id,
fromIndex: sourceIndex,
toIndex: targetIndex,
task: movedTask,
updatedSourceTasks,
updatedTargetTasks,
})
);
dispatch(
reorderEnhancedKanbanTasks({
activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id,
fromIndex: sourceIndex,
toIndex: targetIndex,
task: movedTask,
updatedSourceTasks,
updatedTargetTasks,
}) as any
);
// --- Socket emit for task sort order ---
if (socket && projectId && movedTask) {
@@ -368,7 +383,10 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
toSortOrder = -1;
toLastIndex = true;
} else if (targetGroup.tasks[targetIndex]) {
toSortOrder = typeof targetGroup.tasks[targetIndex].sort_order === 'number' ? targetGroup.tasks[targetIndex].sort_order! : -1;
toSortOrder =
typeof targetGroup.tasks[targetIndex].sort_order === 'number'
? targetGroup.tasks[targetIndex].sort_order!
: -1;
toLastIndex = false;
} else if (targetGroup.tasks.length > 0) {
const lastSortOrder = targetGroup.tasks[targetGroup.tasks.length - 1].sort_order;
@@ -490,4 +508,4 @@ const EnhancedKanbanBoard: React.FC<EnhancedKanbanBoardProps> = ({ projectId, cl
);
};
export default EnhancedKanbanBoard;
export default EnhancedKanbanBoard;

View File

@@ -7,7 +7,10 @@ import { nanoid } from '@reduxjs/toolkit';
import { useAppSelector } from '@/hooks/useAppSelector';
import { themeWiseColor } from '@/utils/themeWiseColor';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { IGroupBy, fetchEnhancedKanbanGroups } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import {
IGroupBy,
fetchEnhancedKanbanGroups,
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { createStatus, fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
import { ALPHA_CHANNEL } from '@/shared/constants';
@@ -19,10 +22,12 @@ import useIsProjectManager from '@/hooks/useIsProjectManager';
const EnhancedKanbanCreateSection: React.FC = () => {
const { t } = useTranslation('kanban-board');
const themeMode = useAppSelector((state) => state.themeReducer.mode);
const { projectId } = useAppSelector((state) => state.projectReducer);
const groupBy = useAppSelector((state) => state.enhancedKanbanReducer.groupBy);
const { statusCategories, status: existingStatuses } = useAppSelector((state) => state.taskStatusReducer);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const { projectId } = useAppSelector(state => state.projectReducer);
const groupBy = useAppSelector(state => state.enhancedKanbanReducer.groupBy);
const { statusCategories, status: existingStatuses } = useAppSelector(
state => state.taskStatusReducer
);
const dispatch = useAppDispatch();
const isOwnerorAdmin = useAuthService().isOwnerOrAdmin();
@@ -36,20 +41,20 @@ const EnhancedKanbanCreateSection: React.FC = () => {
const getUniqueSectionName = (baseName: string): string => {
// Check if the base name already exists
const existingNames = existingStatuses.map(status => status.name?.toLowerCase());
if (!existingNames.includes(baseName.toLowerCase())) {
return baseName;
}
// If the base name exists, add a number suffix
let counter = 1;
let newName = `${baseName.trim()} (${counter})`;
while (existingNames.includes(newName.toLowerCase())) {
counter++;
newName = `${baseName.trim()} (${counter})`;
}
return newName;
};
@@ -57,14 +62,14 @@ const EnhancedKanbanCreateSection: React.FC = () => {
const sectionId = nanoid();
const baseNameSection = 'Untitled section';
const sectionName = getUniqueSectionName(baseNameSection);
if (groupBy === IGroupBy.STATUS && projectId) {
// Find the "To do" category
const todoCategory = statusCategories.find(category =>
category.name?.toLowerCase() === 'to do' ||
category.name?.toLowerCase() === 'todo'
const todoCategory = statusCategories.find(
category =>
category.name?.toLowerCase() === 'to do' || category.name?.toLowerCase() === 'todo'
);
if (todoCategory && todoCategory.id) {
// Create a new status
const body = {
@@ -72,11 +77,13 @@ const EnhancedKanbanCreateSection: React.FC = () => {
project_id: projectId,
category_id: todoCategory.id,
};
try {
// Create the status
const response = await dispatch(createStatus({ body, currentProjectId: projectId })).unwrap();
const response = await dispatch(
createStatus({ body, currentProjectId: projectId })
).unwrap();
if (response.done && response.body) {
// Refresh the board to show the new section
dispatch(fetchEnhancedKanbanGroups(projectId));
@@ -87,7 +94,7 @@ const EnhancedKanbanCreateSection: React.FC = () => {
logger.error('Failed to create status:', error);
}
}
}
}
if (groupBy === IGroupBy.PHASE && projectId) {
const body = {
@@ -95,7 +102,7 @@ const EnhancedKanbanCreateSection: React.FC = () => {
project_id: projectId,
};
try {
try {
const response = await phasesApiService.addPhaseOption(projectId);
if (response.done && response.body) {
dispatch(fetchEnhancedKanbanGroups(projectId));
@@ -147,4 +154,4 @@ const EnhancedKanbanCreateSection: React.FC = () => {
);
};
export default React.memo(EnhancedKanbanCreateSection);
export default React.memo(EnhancedKanbanCreateSection);

View File

@@ -85,22 +85,27 @@ const EnhancedKanbanCreateSubtaskCard = ({
}, 0);
if (task.parent_task_id) {
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.parent_task_id);
socket?.once(SocketEvents.GET_TASK_PROGRESS.toString(), (data: {
id: string;
complete_ratio: number;
completed_count: number;
total_tasks_count: number;
parent_task: string;
}) => {
if (!data.parent_task) data.parent_task = task.parent_task_id || '';
dispatch(updateEnhancedKanbanTaskProgress({
id: task.id || '',
complete_ratio: data.complete_ratio,
completed_count: data.completed_count,
total_tasks_count: data.total_tasks_count,
parent_task: data.parent_task,
}));
});
socket?.once(
SocketEvents.GET_TASK_PROGRESS.toString(),
(data: {
id: string;
complete_ratio: number;
completed_count: number;
total_tasks_count: number;
parent_task: string;
}) => {
if (!data.parent_task) data.parent_task = task.parent_task_id || '';
dispatch(
updateEnhancedKanbanTaskProgress({
id: task.id || '',
complete_ratio: data.complete_ratio,
completed_count: data.completed_count,
total_tasks_count: data.total_tasks_count,
parent_task: data.parent_task,
})
);
}
);
}
});
} catch (error) {
@@ -143,7 +148,7 @@ const EnhancedKanbanCreateSubtaskCard = ({
cursor: 'pointer',
overflow: 'hidden',
}}
// className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline`}
// className={`outline-1 ${themeWiseColor('outline-[#edeae9]', 'outline-[#6a696a]', themeMode)} hover:outline`}
onBlur={handleCancelNewCard}
>
<Input
@@ -170,4 +175,4 @@ const EnhancedKanbanCreateSubtaskCard = ({
);
};
export default EnhancedKanbanCreateSubtaskCard;
export default EnhancedKanbanCreateSubtaskCard;

View File

@@ -89,7 +89,12 @@ const EnhancedKanbanCreateTaskCard: React.FC<EnhancedKanbanCreateTaskCardProps>
// Real-time socket event handler
const eventHandler = (task: IProjectTask) => {
dispatch(addTaskToGroup({ sectionId, task: { ...task, id: task.id || nanoid(), name: task.name || newTaskName.trim() } }));
dispatch(
addTaskToGroup({
sectionId,
task: { ...task, id: task.id || nanoid(), name: task.name || newTaskName.trim() },
})
);
socket?.off(SocketEvents.QUICK_TASK.toString(), eventHandler);
resetForNextTask();
};
@@ -159,4 +164,4 @@ const EnhancedKanbanCreateTaskCard: React.FC<EnhancedKanbanCreateTaskCardProps>
);
};
export default EnhancedKanbanCreateTaskCard;
export default EnhancedKanbanCreateTaskCard;

View File

@@ -127,7 +127,8 @@
}
@keyframes dropPulse {
0%, 100% {
0%,
100% {
opacity: 0.6;
transform: scaleX(0.8);
}
@@ -205,7 +206,7 @@
min-width: 240px;
max-width: 280px;
}
.enhanced-kanban-group-tasks {
max-height: 400px;
}
@@ -216,7 +217,7 @@
min-width: 200px;
max-width: 240px;
}
.enhanced-kanban-group-tasks {
max-height: 300px;
}
@@ -239,4 +240,4 @@
max-width: 220px;
display: inline-block;
vertical-align: middle;
}
}

View File

@@ -47,7 +47,7 @@
}
.enhanced-kanban-task-card.drop-target::before {
content: '';
content: "";
position: absolute;
top: -2px;
left: -2px;
@@ -60,7 +60,8 @@
}
@keyframes dropTargetPulse {
0%, 100% {
0%,
100% {
opacity: 0.3;
transform: scale(1);
}
@@ -117,4 +118,4 @@
font-size: 12px;
color: var(--ant-color-text-tertiary);
margin-top: 4px;
}
}

View File

@@ -19,7 +19,10 @@ import { ForkOutlined } from '@ant-design/icons';
import { Dayjs } from 'dayjs';
import dayjs from 'dayjs';
import { CaretDownFilled, CaretRightFilled } from '@ant-design/icons';
import { fetchBoardSubTasks, toggleTaskExpansion } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import {
fetchBoardSubTasks,
toggleTaskExpansion,
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
import { Divider } from 'antd';
import { List } from 'antd';
import { Skeleton } from 'antd';
@@ -46,227 +49,233 @@ const PRIORITY_COLORS = {
low: '#52c41a',
} as const;
const EnhancedKanbanTaskCard: React.FC<EnhancedKanbanTaskCardProps> = React.memo(({
task,
sectionId,
isActive = false,
isDragOverlay = false,
isDropTarget = false
}) => {
const dispatch = useAppDispatch();
const { t } = useTranslation('kanban-board');
const themeMode = useAppSelector(state => state.themeReducer.mode);
const [showNewSubtaskCard, setShowNewSubtaskCard] = useState(false);
const [dueDate, setDueDate] = useState<Dayjs | null>(
task?.end_date ? dayjs(task?.end_date) : null
);
const EnhancedKanbanTaskCard: React.FC<EnhancedKanbanTaskCardProps> = React.memo(
({ task, sectionId, isActive = false, isDragOverlay = false, isDropTarget = false }) => {
const dispatch = useAppDispatch();
const { t } = useTranslation('kanban-board');
const themeMode = useAppSelector(state => state.themeReducer.mode);
const [showNewSubtaskCard, setShowNewSubtaskCard] = useState(false);
const [dueDate, setDueDate] = useState<Dayjs | null>(
task?.end_date ? dayjs(task?.end_date) : null
);
const projectId = useAppSelector(state => state.projectReducer.projectId);
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
id: task.id!,
data: {
type: 'task',
task,
},
disabled: isDragOverlay,
animateLayoutChanges: defaultAnimateLayoutChanges,
});
const projectId = useAppSelector(state => state.projectReducer.projectId);
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: task.id!,
data: {
type: 'task',
task,
},
disabled: isDragOverlay,
animateLayoutChanges: defaultAnimateLayoutChanges,
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
};
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
};
const handleCardClick = useCallback((e: React.MouseEvent, id: string) => {
// Prevent the event from propagating to parent elements
e.stopPropagation();
const handleCardClick = useCallback(
(e: React.MouseEvent, id: string) => {
// Prevent the event from propagating to parent elements
e.stopPropagation();
// Don't handle click if we're dragging
if (isDragging) return;
dispatch(setSelectedTaskId(id));
dispatch(setShowTaskDrawer(true));
}, [dispatch, isDragging]);
// Don't handle click if we're dragging
if (isDragging) return;
dispatch(setSelectedTaskId(id));
dispatch(setShowTaskDrawer(true));
},
[dispatch, isDragging]
);
const renderLabels = useMemo(() => {
if (!task?.labels?.length) return null;
const renderLabels = useMemo(() => {
if (!task?.labels?.length) return null;
return (
<>
{task.labels.slice(0, 2).map((label: any) => (
<Tag key={label.id} style={{ marginRight: '2px' }} color={label?.color_code}>
<span style={{ color: themeMode === 'dark' ? '#383838' : '', fontSize: 10 }}>
{label.name}
</span>
</Tag>
))}
{task.labels.length > 2 && <Tag>+ {task.labels.length - 2}</Tag>}
</>
);
}, [task.labels, themeMode]);
const handleSubTaskExpand = useCallback(() => {
if (task && task.id && projectId) {
// Check if subtasks are already loaded and we have subtask data
if (task.sub_tasks && task.sub_tasks.length > 0 && task.sub_tasks_count > 0) {
// If subtasks are already loaded, just toggle visibility
dispatch(toggleTaskExpansion(task.id));
} else if (task.sub_tasks_count > 0) {
// If we have a subtask count but no loaded subtasks, fetch them
dispatch(toggleTaskExpansion(task.id));
dispatch(fetchBoardSubTasks({ taskId: task.id, projectId }));
} else {
// If no subtasks exist, just toggle visibility (will show empty state)
dispatch(toggleTaskExpansion(task.id));
}
}
}, [task, projectId, dispatch]);
const handleSubtaskButtonClick = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
handleSubTaskExpand();
},
[handleSubTaskExpand]
);
const handleAddSubtaskClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setShowNewSubtaskCard(true);
}, []);
return (
<>
{task.labels.slice(0, 2).map((label: any) => (
<Tag key={label.id} style={{ marginRight: '2px' }} color={label?.color_code}>
<span style={{ color: themeMode === 'dark' ? '#383838' : '', fontSize: 10 }}>
{label.name}
</span>
</Tag>
))}
{task.labels.length > 2 && <Tag>+ {task.labels.length - 2}</Tag>}
</>
);
}, [task.labels, themeMode]);
<div
ref={setNodeRef}
style={style}
className={`enhanced-kanban-task-card ${isActive ? 'active' : ''} ${isDragging ? 'dragging' : ''} ${isDragOverlay ? 'drag-overlay' : ''} ${isDropTarget ? 'drop-target' : ''}`}
{...attributes}
{...listeners}
>
<div className="task-content" onClick={e => handleCardClick(e, task.id || '')}>
<Flex align="center" justify="space-between" className="mb-2">
<Flex>{renderLabels}</Flex>
const handleSubTaskExpand = useCallback(() => {
if (task && task.id && projectId) {
// Check if subtasks are already loaded and we have subtask data
if (task.sub_tasks && task.sub_tasks.length > 0 && task.sub_tasks_count > 0) {
// If subtasks are already loaded, just toggle visibility
dispatch(toggleTaskExpansion(task.id));
} else if (task.sub_tasks_count > 0) {
// If we have a subtask count but no loaded subtasks, fetch them
dispatch(toggleTaskExpansion(task.id));
dispatch(fetchBoardSubTasks({ taskId: task.id, projectId }));
} else {
// If no subtasks exist, just toggle visibility (will show empty state)
dispatch(toggleTaskExpansion(task.id));
}
}
}, [task, projectId, dispatch]);
const handleSubtaskButtonClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
handleSubTaskExpand();
}, [handleSubTaskExpand]);
const handleAddSubtaskClick = useCallback((e: React.MouseEvent) => {
e.stopPropagation();
setShowNewSubtaskCard(true);
}, []);
return (
<div
ref={setNodeRef}
style={style}
className={`enhanced-kanban-task-card ${isActive ? 'active' : ''} ${isDragging ? 'dragging' : ''} ${isDragOverlay ? 'drag-overlay' : ''} ${isDropTarget ? 'drop-target' : ''}`}
{...attributes}
{...listeners}
>
<div className="task-content" onClick={e => handleCardClick(e, task.id || '')}>
<Flex align="center" justify="space-between" className="mb-2">
<Flex>
{renderLabels}
</Flex>
<Tooltip title={` ${task?.completed_count} / ${task?.total_tasks_count}`}>
<Progress type="circle" percent={task?.complete_ratio} size={24} strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7} />
</Tooltip>
</Flex>
<Flex gap={4} align="center">
{/* Action Icons */}
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: task.priority_color || '#d9d9d9' }}
/>
<Typography.Text
style={{ fontWeight: 500 }}
ellipsis={{ tooltip: task.name }}
>
{task.name}
</Typography.Text>
</Flex>
<Flex
align="center"
justify="space-between"
style={{
marginBlock: 8,
}}
>
<Flex align="center" gap={2}>
<AvatarGroup
members={task.names || []}
maxCount={3}
isDarkMode={themeMode === 'dark'}
size={24}
/>
<LazyAssigneeSelectorWrapper task={task} groupId={sectionId} isDarkMode={themeMode === 'dark'} />
<Tooltip title={` ${task?.completed_count} / ${task?.total_tasks_count}`}>
<Progress
type="circle"
percent={task?.complete_ratio}
size={24}
strokeWidth={(task.complete_ratio || 0) >= 100 ? 9 : 7}
/>
</Tooltip>
</Flex>
<Flex gap={4} align="center">
<CustomDueDatePicker task={task} onDateChange={setDueDate} />
{/* Subtask Section */}
<Button
onClick={handleSubtaskButtonClick}
size="small"
style={{
padding: 0,
}}
type="text"
>
<Tag
bordered={false}
style={{
display: 'flex',
alignItems: 'center',
margin: 0,
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
}}
>
<ForkOutlined rotate={90} />
<span>{task.sub_tasks_count}</span>
{task.show_sub_tasks ? <CaretDownFilled /> : <CaretRightFilled />}
</Tag>
</Button>
{/* Action Icons */}
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: task.priority_color || '#d9d9d9' }}
/>
<Typography.Text style={{ fontWeight: 500 }} ellipsis={{ tooltip: task.name }}>
{task.name}
</Typography.Text>
</Flex>
</Flex>
<Flex vertical gap={8}>
{task.show_sub_tasks && (
<Flex vertical>
<Divider style={{ marginBlock: 0 }} />
<List>
{task.sub_tasks_loading && (
<List.Item>
<Skeleton active paragraph={{ rows: 2 }} title={false} style={{ marginTop: 8 }} />
</List.Item>
)}
<Flex
align="center"
justify="space-between"
style={{
marginBlock: 8,
}}
>
<Flex align="center" gap={2}>
<AvatarGroup
members={task.names || []}
maxCount={3}
isDarkMode={themeMode === 'dark'}
size={24}
/>
<LazyAssigneeSelectorWrapper
task={task}
groupId={sectionId}
isDarkMode={themeMode === 'dark'}
/>
</Flex>
<Flex gap={4} align="center">
<CustomDueDatePicker task={task} onDateChange={setDueDate} />
{!task.sub_tasks_loading && task?.sub_tasks && task.sub_tasks.length > 0 &&
task.sub_tasks.map((subtask: any) => (
<BoardSubTaskCard key={subtask.id} subtask={subtask} sectionId={sectionId} />
))}
{!task.sub_tasks_loading && (!task?.sub_tasks || task.sub_tasks.length === 0) && task.sub_tasks_count === 0 && (
<List.Item>
<div style={{ padding: '8px 0', color: '#999', fontSize: '12px' }}>
{t('noSubtasks', 'No subtasks')}
</div>
</List.Item>
)}
{showNewSubtaskCard && (
<EnhancedKanbanCreateSubtaskCard
sectionId={sectionId}
parentTaskId={task.id || ''}
setShowNewSubtaskCard={setShowNewSubtaskCard}
/>
)}
</List>
{/* Subtask Section */}
<Button
type="text"
onClick={handleSubtaskButtonClick}
size="small"
style={{
width: 'fit-content',
borderRadius: 6,
boxShadow: 'none',
padding: 0,
}}
icon={<PlusOutlined />}
onClick={handleAddSubtaskClick}
type="text"
>
{t('addSubtask', 'Add Subtask')}
<Tag
bordered={false}
style={{
display: 'flex',
alignItems: 'center',
margin: 0,
backgroundColor: themeWiseColor('white', '#1e1e1e', themeMode),
}}
>
<ForkOutlined rotate={90} />
<span>{task.sub_tasks_count}</span>
{task.show_sub_tasks ? <CaretDownFilled /> : <CaretRightFilled />}
</Tag>
</Button>
</Flex>
)}
</Flex>
</div>
</div>
);
});
</Flex>
<Flex vertical gap={8}>
{task.show_sub_tasks && (
<Flex vertical>
<Divider style={{ marginBlock: 0 }} />
<List>
{task.sub_tasks_loading && (
<List.Item>
<Skeleton
active
paragraph={{ rows: 2 }}
title={false}
style={{ marginTop: 8 }}
/>
</List.Item>
)}
export default EnhancedKanbanTaskCard;
{!task.sub_tasks_loading &&
task?.sub_tasks &&
task.sub_tasks.length > 0 &&
task.sub_tasks.map((subtask: any) => (
<BoardSubTaskCard key={subtask.id} subtask={subtask} sectionId={sectionId} />
))}
{!task.sub_tasks_loading &&
(!task?.sub_tasks || task.sub_tasks.length === 0) &&
task.sub_tasks_count === 0 && (
<List.Item>
<div style={{ padding: '8px 0', color: '#999', fontSize: '12px' }}>
{t('noSubtasks', 'No subtasks')}
</div>
</List.Item>
)}
{showNewSubtaskCard && (
<EnhancedKanbanCreateSubtaskCard
sectionId={sectionId}
parentTaskId={task.id || ''}
setShowNewSubtaskCard={setShowNewSubtaskCard}
/>
)}
</List>
<Button
type="text"
style={{
width: 'fit-content',
borderRadius: 6,
boxShadow: 'none',
}}
icon={<PlusOutlined />}
onClick={handleAddSubtaskClick}
>
{t('addSubtask', 'Add Subtask')}
</Button>
</Flex>
)}
</Flex>
</div>
</div>
);
}
);
export default EnhancedKanbanTaskCard;

View File

@@ -93,9 +93,9 @@
width: 100%;
margin-bottom: 16px;
}
.performance-metrics {
grid-template-columns: 1fr;
gap: 8px;
}
}
}

View File

@@ -21,11 +21,16 @@ const PerformanceMonitor: React.FC = () => {
const getStatusColor = (status: string) => {
switch (status) {
case 'critical': return 'red';
case 'warning': return 'orange';
case 'good': return 'blue';
case 'excellent': return 'green';
default: return 'default';
case 'critical':
return 'red';
case 'warning':
return 'orange';
case 'good':
return 'blue';
case 'excellent':
return 'green';
default:
return 'default';
}
};
@@ -33,15 +38,15 @@ const PerformanceMonitor: React.FC = () => {
const statusColor = getStatusColor(status);
return (
<Card
size="small"
<Card
size="small"
className="performance-monitor"
title={
<div className="performance-monitor-header">
<span>Performance Monitor</span>
<Badge
status={statusColor as any}
text={status.toUpperCase()}
<Badge
status={statusColor as any}
text={status.toUpperCase()}
className="performance-status"
/>
</div>
@@ -56,7 +61,7 @@ const PerformanceMonitor: React.FC = () => {
valueStyle={{ fontSize: '16px' }}
/>
</Tooltip>
<Tooltip title="Largest group by number of tasks">
<Statistic
title="Largest Group"
@@ -65,7 +70,7 @@ const PerformanceMonitor: React.FC = () => {
valueStyle={{ fontSize: '16px' }}
/>
</Tooltip>
<Tooltip title="Average tasks per group">
<Statistic
title="Average Group"
@@ -74,18 +79,18 @@ const PerformanceMonitor: React.FC = () => {
valueStyle={{ fontSize: '16px' }}
/>
</Tooltip>
<Tooltip title="Virtualization is enabled for groups with more than 50 tasks">
<div className="virtualization-status">
<span className="status-label">Virtualization:</span>
<Badge
status={performanceMetrics.virtualizationEnabled ? 'success' : 'default'}
text={performanceMetrics.virtualizationEnabled ? 'Enabled' : 'Disabled'}
<Badge
status={performanceMetrics.virtualizationEnabled ? 'success' : 'default'}
text={performanceMetrics.virtualizationEnabled ? 'Enabled' : 'Disabled'}
/>
</div>
</Tooltip>
</div>
{performanceMetrics.totalTasks > 500 && (
<div className="performance-tips">
<h4>Performance Tips:</h4>
@@ -100,4 +105,4 @@ const PerformanceMonitor: React.FC = () => {
);
};
export default React.memo(PerformanceMonitor);
export default React.memo(PerformanceMonitor);

View File

@@ -57,4 +57,4 @@
.virtualized-task-list::-webkit-scrollbar-thumb:hover {
background: var(--ant-color-text-tertiary);
}
}

View File

@@ -22,45 +22,54 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = ({
onTaskRender,
}) => {
// Memoize task data to prevent unnecessary re-renders
const taskData = useMemo(() => ({
tasks,
activeTaskId,
overId,
onTaskRender,
}), [tasks, activeTaskId, overId, onTaskRender]);
const taskData = useMemo(
() => ({
tasks,
activeTaskId,
overId,
onTaskRender,
}),
[tasks, activeTaskId, overId, onTaskRender]
);
// Row renderer for virtualized list
const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => {
const task = tasks[index];
if (!task) return null;
const Row = useCallback(
({ index, style }: { index: number; style: React.CSSProperties }) => {
const task = tasks[index];
if (!task) return null;
// Call onTaskRender callback if provided
onTaskRender?.(task, index);
// Call onTaskRender callback if provided
onTaskRender?.(task, index);
return (
return (
<EnhancedKanbanTaskCard
task={task}
isActive={task.id === activeTaskId}
isDropTarget={overId === task.id}
sectionId={task.status || 'default'}
/>
);
}, [tasks, activeTaskId, overId, onTaskRender]);
);
},
[tasks, activeTaskId, overId, onTaskRender]
);
// Memoize the list component to prevent unnecessary re-renders
const VirtualizedList = useMemo(() => (
<List
height={height}
width="100%"
itemCount={tasks.length}
itemSize={itemHeight}
itemData={taskData}
overscanCount={10} // Increased overscan for smoother scrolling experience
className="virtualized-task-list"
>
{Row}
</List>
), [height, tasks.length, itemHeight, taskData, Row]);
const VirtualizedList = useMemo(
() => (
<List
height={height}
width="100%"
itemCount={tasks.length}
itemSize={itemHeight}
itemData={taskData}
overscanCount={10} // Increased overscan for smoother scrolling experience
className="virtualized-task-list"
>
{Row}
</List>
),
[height, tasks.length, itemHeight, taskData, Row]
);
if (tasks.length === 0) {
return (
@@ -73,4 +82,4 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = ({
return VirtualizedList;
};
export default React.memo(VirtualizedTaskList);
export default React.memo(VirtualizedTaskList);

View File

@@ -20,9 +20,7 @@ const HomeTasksStatusDropdown = ({ task, teamId }: HomeTasksStatusDropdownProps)
const { t } = useTranslation('task-list-table');
const { socket, connected } = useSocket();
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
const {
refetch
} = useGetMyTasksQuery(homeTasksConfig, {
const { refetch } = useGetMyTasksQuery(homeTasksConfig, {
skip: false, // Ensure this query runs
});

View File

@@ -1,110 +1,111 @@
import { useSocket } from "@/socket/socketContext";
import { IProjectTask } from "@/types/project/projectTasksViewModel.types";
import { DatePicker } from "antd";
import { useSocket } from '@/socket/socketContext';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { DatePicker } from 'antd';
import dayjs from 'dayjs';
import calendar from 'dayjs/plugin/calendar';
import { SocketEvents } from '@/shared/socket-events';
import type { Dayjs } from 'dayjs';
import { useTranslation } from "react-i18next";
import { useEffect, useState, useMemo } from "react";
import { useAppSelector } from "@/hooks/useAppSelector";
import { useGetMyTasksQuery } from "@/api/home-page/home-page.api.service";
import { getUserSession } from "@/utils/session-helper";
import { useTranslation } from 'react-i18next';
import { useEffect, useState, useMemo } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useGetMyTasksQuery } from '@/api/home-page/home-page.api.service';
import { getUserSession } from '@/utils/session-helper';
// Extend dayjs with the calendar plugin
dayjs.extend(calendar);
type HomeTasksDatePickerProps = {
record: IProjectTask;
record: IProjectTask;
};
const HomeTasksDatePicker = ({ record }: HomeTasksDatePickerProps) => {
const { socket, connected } = useSocket();
const { t } = useTranslation('home');
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
const { refetch } = useGetMyTasksQuery(homeTasksConfig, {
skip: false
const { socket, connected } = useSocket();
const { t } = useTranslation('home');
const { homeTasksConfig } = useAppSelector(state => state.homePageReducer);
const { refetch } = useGetMyTasksQuery(homeTasksConfig, {
skip: false,
});
// Use useMemo to avoid re-renders when record.end_date is the same
const initialDate = useMemo(
() => (record.end_date ? dayjs(record.end_date) : null),
[record.end_date]
);
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(initialDate);
// Update selected date when record changes
useEffect(() => {
setSelectedDate(initialDate);
}, [initialDate]);
const handleChangeReceived = (value: any) => {
refetch();
};
useEffect(() => {
socket?.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleChangeReceived);
socket?.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleChangeReceived);
return () => {
socket?.removeListener(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleChangeReceived);
socket?.removeListener(SocketEvents.TASK_STATUS_CHANGE.toString(), handleChangeReceived);
};
}, [connected]);
const handleEndDateChanged = (value: Dayjs | null, task: IProjectTask) => {
setSelectedDate(value);
if (!task.id) return;
const body = {
task_id: task.id,
end_date: value?.format('YYYY-MM-DD'),
parent_task: task.parent_task_id,
time_zone: getUserSession()?.timezone_name
? getUserSession()?.timezone_name
: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
socket?.emit(SocketEvents.TASK_END_DATE_CHANGE.toString(), JSON.stringify(body));
};
// Function to dynamically format the date based on the calendar rules
const getFormattedDate = (date: Dayjs | null) => {
if (!date) return '';
return date.calendar(null, {
sameDay: '[Today]',
nextDay: '[Tomorrow]',
nextWeek: 'MMM DD',
lastDay: '[Yesterday]',
lastWeek: 'MMM DD',
sameElse: date.year() === dayjs().year() ? 'MMM DD' : 'MMM DD, YYYY',
});
};
// Use useMemo to avoid re-renders when record.end_date is the same
const initialDate = useMemo(() =>
record.end_date ? dayjs(record.end_date) : null
, [record.end_date]);
const [selectedDate, setSelectedDate] = useState<Dayjs | null>(initialDate);
// Update selected date when record changes
useEffect(() => {
setSelectedDate(initialDate);
}, [initialDate]);
const handleChangeReceived = (value: any) => {
refetch();
};
useEffect(() => {
socket?.on(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleChangeReceived);
socket?.on(SocketEvents.TASK_STATUS_CHANGE.toString(), handleChangeReceived);
return () => {
socket?.removeListener(SocketEvents.TASK_END_DATE_CHANGE.toString(), handleChangeReceived);
socket?.removeListener(SocketEvents.TASK_STATUS_CHANGE.toString(), handleChangeReceived);
};
}, [connected]);
const handleEndDateChanged = (value: Dayjs | null, task: IProjectTask) => {
setSelectedDate(value);
if (!task.id) return;
const body = {
task_id: task.id,
end_date: value?.format('YYYY-MM-DD'),
parent_task: task.parent_task_id,
time_zone: getUserSession()?.timezone_name
? getUserSession()?.timezone_name
: Intl.DateTimeFormat().resolvedOptions().timeZone,
};
socket?.emit(SocketEvents.TASK_END_DATE_CHANGE.toString(), JSON.stringify(body));
};
// Function to dynamically format the date based on the calendar rules
const getFormattedDate = (date: Dayjs | null) => {
if (!date) return '';
return date.calendar(null, {
sameDay: '[Today]',
nextDay: '[Tomorrow]',
nextWeek: 'MMM DD',
lastDay: '[Yesterday]',
lastWeek: 'MMM DD',
sameElse: date.year() === dayjs().year() ? 'MMM DD' : 'MMM DD, YYYY',
});
};
return (
<DatePicker
allowClear
disabledDate={
record.start_date ? current => current.isBefore(dayjs(record.start_date)) : undefined
}
placeholder={t('tasks.dueDatePlaceholder')}
value={selectedDate}
onChange={value => handleEndDateChanged(value || null, record || null)}
format={(value) => getFormattedDate(value)} // Dynamically format the displayed value
style={{
color: selectedDate
? selectedDate.isSame(dayjs(), 'day') || selectedDate.isSame(dayjs().add(1, 'day'), 'day')
? '#52c41a'
: selectedDate.isAfter(dayjs().add(1, 'day'), 'day')
? undefined
: '#ff4d4f'
: undefined,
width: '125px', // Ensure the input takes full width
}}
inputReadOnly // Prevent manual input to avoid overflow issues
variant={'borderless'} // Make the DatePicker borderless
suffixIcon={null}
/>
);
return (
<DatePicker
allowClear
disabledDate={
record.start_date ? current => current.isBefore(dayjs(record.start_date)) : undefined
}
placeholder={t('tasks.dueDatePlaceholder')}
value={selectedDate}
onChange={value => handleEndDateChanged(value || null, record || null)}
format={value => getFormattedDate(value)} // Dynamically format the displayed value
style={{
color: selectedDate
? selectedDate.isSame(dayjs(), 'day') || selectedDate.isSame(dayjs().add(1, 'day'), 'day')
? '#52c41a'
: selectedDate.isAfter(dayjs().add(1, 'day'), 'day')
? undefined
: '#ff4d4f'
: undefined,
width: '125px', // Ensure the input takes full width
}}
inputReadOnly // Prevent manual input to avoid overflow issues
variant={'borderless'} // Make the DatePicker borderless
suffixIcon={null}
/>
);
};
export default HomeTasksDatePicker;
export default HomeTasksDatePicker;

View File

@@ -9,4 +9,4 @@ 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';
export { default as Tooltip } from './Tooltip';

View File

@@ -6,47 +6,40 @@ import { ITaskListGroup } from '@/types/tasks/taskList.types';
import { IGroupBy } from '@/features/tasks/tasks.slice';
interface SortableKanbanGroupProps {
group: ITaskListGroup;
projectId: string;
currentGrouping: IGroupBy;
selectedTaskIds: string[];
onAddTask?: (groupId: string) => void;
onToggleCollapse?: (groupId: string) => void;
onSelectTask?: (taskId: string, selected: boolean) => void;
onToggleSubtasks?: (taskId: string) => void;
activeTaskId?: string | null;
group: ITaskListGroup;
projectId: string;
currentGrouping: IGroupBy;
selectedTaskIds: string[];
onAddTask?: (groupId: string) => void;
onToggleCollapse?: (groupId: string) => void;
onSelectTask?: (taskId: string, selected: boolean) => void;
onToggleSubtasks?: (taskId: string) => void;
activeTaskId?: string | null;
}
const SortableKanbanGroup: React.FC<SortableKanbanGroupProps> = (props) => {
const { group, activeTaskId } = props;
const {
setNodeRef,
attributes,
listeners,
transform,
transition,
isDragging,
} = useSortable({
id: group.id,
data: { type: 'group', groupId: group.id },
});
const SortableKanbanGroup: React.FC<SortableKanbanGroupProps> = props => {
const { group, activeTaskId } = props;
const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
id: group.id,
data: { type: 'group', groupId: group.id },
});
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
};
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
zIndex: isDragging ? 10 : undefined,
};
return (
<div ref={setNodeRef} style={style}>
<KanbanGroup
{...props}
dragHandleProps={{ ...attributes, ...listeners }}
activeTaskId={activeTaskId}
/>
</div>
);
return (
<div ref={setNodeRef} style={style}>
<KanbanGroup
{...props}
dragHandleProps={{ ...attributes, ...listeners }}
activeTaskId={activeTaskId}
/>
</div>
);
};
export default SortableKanbanGroup;
export default SortableKanbanGroup;

View File

@@ -10,130 +10,122 @@ import KanbanTaskCard from './kanbanTaskCard';
const { Text } = Typography;
interface TaskGroupProps {
group: ITaskListGroup;
projectId: string;
currentGrouping: IGroupBy;
selectedTaskIds: string[];
onAddTask?: (groupId: string) => void;
onToggleCollapse?: (groupId: string) => void;
onSelectTask?: (taskId: string, selected: boolean) => void;
onToggleSubtasks?: (taskId: string) => void;
dragHandleProps?: any;
activeTaskId?: string | null;
group: ITaskListGroup;
projectId: string;
currentGrouping: IGroupBy;
selectedTaskIds: string[];
onAddTask?: (groupId: string) => void;
onToggleCollapse?: (groupId: string) => void;
onSelectTask?: (taskId: string, selected: boolean) => void;
onToggleSubtasks?: (taskId: string) => void;
dragHandleProps?: any;
activeTaskId?: string | null;
}
const KanbanGroup: React.FC<TaskGroupProps> = ({
group,
projectId,
currentGrouping,
selectedTaskIds,
onAddTask,
onToggleCollapse,
onSelectTask,
onToggleSubtasks,
dragHandleProps,
activeTaskId,
group,
projectId,
currentGrouping,
selectedTaskIds,
onAddTask,
onToggleCollapse,
onSelectTask,
onToggleSubtasks,
dragHandleProps,
activeTaskId,
}) => {
const [isCollapsed, setIsCollapsed] = useState(false);
const { setNodeRef, isOver } = useDroppable({
id: group.id,
data: {
type: 'group',
groupId: group.id,
},
});
const [isCollapsed, setIsCollapsed] = useState(false);
const { setNodeRef, isOver } = useDroppable({
id: group.id,
data: {
type: 'group',
groupId: group.id,
},
});
// Get task IDs for sortable context
const taskIds = group.tasks.map(task => task.id!);
// Get task IDs for sortable context
const taskIds = group.tasks.map(task => task.id!);
// Get group color based on grouping type
const getGroupColor = () => {
if (group.color_code) return group.color_code;
switch (currentGrouping) {
case 'status':
return group.id === 'todo' ? '#faad14' : group.id === 'doing' ? '#1890ff' : '#52c41a';
case 'priority':
return group.id === 'critical'
? '#ff4d4f'
: group.id === 'high'
? '#fa8c16'
: group.id === 'medium'
? '#faad14'
: '#52c41a';
case 'phase':
return '#722ed1';
default:
return '#d9d9d9';
}
};
// Get group color based on grouping type
const getGroupColor = () => {
if (group.color_code) return group.color_code;
switch (currentGrouping) {
case 'status':
return group.id === 'todo' ? '#faad14' : group.id === 'doing' ? '#1890ff' : '#52c41a';
case 'priority':
return group.id === 'critical'
? '#ff4d4f'
: group.id === 'high'
? '#fa8c16'
: group.id === 'medium'
? '#faad14'
: '#52c41a';
case 'phase':
return '#722ed1';
default:
return '#d9d9d9';
}
};
const handleAddTask = () => {
onAddTask?.(group.id);
};
const handleAddTask = () => {
onAddTask?.(group.id);
};
return (
<div
ref={setNodeRef}
className={`kanban-group-column${isOver ? ' drag-over' : ''}`}
>
{/* Group Header */}
<div className="kanban-group-header" style={{ backgroundColor: getGroupColor() }}>
{/* Drag handle for column */}
<Button
type="text"
size="small"
icon={<MenuOutlined />}
className="kanban-group-drag-handle"
style={{ marginRight: 8, cursor: 'grab', opacity: 0.7 }}
{...(dragHandleProps || {})}
return (
<div ref={setNodeRef} className={`kanban-group-column${isOver ? ' drag-over' : ''}`}>
{/* Group Header */}
<div className="kanban-group-header" style={{ backgroundColor: getGroupColor() }}>
{/* Drag handle for column */}
<Button
type="text"
size="small"
icon={<MenuOutlined />}
className="kanban-group-drag-handle"
style={{ marginRight: 8, cursor: 'grab', opacity: 0.7 }}
{...(dragHandleProps || {})}
/>
<Text strong className="kanban-group-header-text">
{group.name} <span className="kanban-group-count">({group.tasks.length})</span>
</Text>
</div>
{/* Tasks as Cards */}
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
<div className="kanban-group-tasks">
{group.tasks.length === 0 ? (
<div className="kanban-group-empty">
<Text type="secondary">No tasks in this group</Text>
</div>
) : (
group.tasks.map((task, index) =>
task.id === activeTaskId ? (
<div key={task.id} className="kanban-task-card kanban-task-card-placeholder" />
) : (
<KanbanTaskCard
key={task.id}
task={task}
projectId={projectId}
groupId={group.id}
currentGrouping={currentGrouping}
isSelected={selectedTaskIds.includes(task.id!)}
index={index}
onSelect={onSelectTask}
onToggleSubtasks={onToggleSubtasks}
/>
<Text strong className="kanban-group-header-text">
{group.name} <span className="kanban-group-count">({group.tasks.length})</span>
</Text>
</div>
)
)
)}
</div>
</SortableContext>
{/* Tasks as Cards */}
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
<div className="kanban-group-tasks">
{group.tasks.length === 0 ? (
<div className="kanban-group-empty">
<Text type="secondary">No tasks in this group</Text>
</div>
) : (
group.tasks.map((task, index) => (
task.id === activeTaskId ? (
<div key={task.id} className="kanban-task-card kanban-task-card-placeholder" />
) : (
<KanbanTaskCard
key={task.id}
task={task}
projectId={projectId}
groupId={group.id}
currentGrouping={currentGrouping}
isSelected={selectedTaskIds.includes(task.id!)}
index={index}
onSelect={onSelectTask}
onToggleSubtasks={onToggleSubtasks}
/>
)
))
)}
</div>
</SortableContext>
{/* Add Task Button */}
<div className="kanban-group-add-task">
<Button type="dashed" icon={<PlusOutlined />} block onClick={handleAddTask}>
Add Task
</Button>
</div>
{/* Add Task Button */}
<div className="kanban-group-add-task">
<Button
type="dashed"
icon={<PlusOutlined />}
block
onClick={handleAddTask}
>
Add Task
</Button>
</div>
<style>{`
<style>{`
.kanban-group-column {
display: flex;
flex-direction: column;
@@ -221,8 +213,8 @@ const KanbanGroup: React.FC<TaskGroupProps> = ({
border: 2px dashed var(--task-drag-over-border, #40a9ff);
}
`}</style>
</div>
);
</div>
);
};
export default KanbanGroup;

View File

@@ -36,14 +36,7 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
onSelect,
onToggleSubtasks,
}) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({
id: task.id!,
data: {
type: 'task',
@@ -93,7 +86,10 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
{...attributes}
{...listeners}
/>
<Text strong className={`kanban-task-title${task.complete_ratio === 100 ? ' kanban-task-completed' : ''}`}>
<Text
strong
className={`kanban-task-title${task.complete_ratio === 100 ? ' kanban-task-completed' : ''}`}
>
{task.name}
</Text>
{task.sub_tasks_count && task.sub_tasks_count > 0 && (
@@ -112,15 +108,23 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
{/* Task Key and Status */}
<div className="kanban-task-row">
{task.task_key && (
<Text code className="kanban-task-key">{task.task_key}</Text>
<Text code className="kanban-task-key">
{task.task_key}
</Text>
)}
{task.status_name && (
<Tag className="kanban-task-status" style={{ backgroundColor: task.status_color, color: 'white', marginLeft: 8 }}>
<Tag
className="kanban-task-status"
style={{ backgroundColor: task.status_color, color: 'white', marginLeft: 8 }}
>
{task.status_name}
</Tag>
)}
{task.priority_name && (
<Tag className="kanban-task-priority" style={{ backgroundColor: task.priority_color, color: 'white', marginLeft: 8 }}>
<Tag
className="kanban-task-priority"
style={{ backgroundColor: task.priority_color, color: 'white', marginLeft: 8 }}
>
{task.priority_name}
</Tag>
)}
@@ -139,7 +143,11 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
/>
)}
{dueDate && (
<Text type={dueDate.color as any} className="kanban-task-due-date" style={{ marginLeft: 12 }}>
<Text
type={dueDate.color as any}
className="kanban-task-due-date"
style={{ marginLeft: 12 }}
>
<ClockCircleOutlined style={{ marginRight: 4 }} />
{dueDate.text}
</Text>
@@ -149,7 +157,7 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
<div className="kanban-task-row">
{task.assignees && task.assignees.length > 0 && (
<Avatar.Group size="small" maxCount={3}>
{task.assignees.map((assignee) => (
{task.assignees.map(assignee => (
<Tooltip key={assignee.id} title={assignee.name}>
<Avatar size="small">{assignee.name?.charAt(0)?.toUpperCase()}</Avatar>
</Tooltip>
@@ -158,11 +166,16 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
)}
{task.labels && task.labels.length > 0 && (
<div className="kanban-task-labels">
{task.labels.slice(0, 2).map((label) => (
{task.labels.slice(0, 2).map(label => (
<Tag
key={label.id}
className="kanban-task-label"
style={{ backgroundColor: label.color_code, border: 'none', color: 'white', marginLeft: 4 }}
style={{
backgroundColor: label.color_code,
border: 'none',
color: 'white',
marginLeft: 4,
}}
>
{label.name}
</Tag>
@@ -198,7 +211,7 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
{/* Subtasks */}
{task.show_sub_tasks && task.sub_tasks && task.sub_tasks.length > 0 && (
<div className="kanban-task-subtasks">
{task.sub_tasks.map((subtask) => (
{task.sub_tasks.map(subtask => (
<KanbanTaskCard
key={subtask.id}
task={subtask}
@@ -398,4 +411,4 @@ const KanbanTaskCard: React.FC<TaskRowProps> = ({
);
};
export default KanbanTaskCard;
export default KanbanTaskCard;

View File

@@ -1,30 +1,25 @@
import React, { useEffect, useState, useMemo } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import {
DndContext,
DragOverlay,
DragStartEvent,
DragEndEvent,
DragOverEvent,
closestCorners,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DndContext,
DragOverlay,
DragStartEvent,
DragEndEvent,
DragOverEvent,
closestCorners,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
} from '@dnd-kit/core';
import {
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
horizontalListSortingStrategy,
SortableContext,
sortableKeyboardCoordinates,
} from '@dnd-kit/sortable';
import { Card, Spin, Empty, Flex } from 'antd';
import { RootState } from '@/app/store';
import {
IGroupBy,
setGroup,
fetchTaskGroups,
reorderTasks,
} from '@/features/tasks/tasks.slice';
import { IGroupBy, setGroup, fetchTaskGroups, reorderTasks } from '@/features/tasks/tasks.slice';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { AppDispatch } from '@/app/store';
import BoardSectionCard from '@/pages/projects/projectView/board/board-section/board-section-card/board-section-card';
@@ -38,269 +33,261 @@ import KanbanGroup from './kanbanGroup';
import KanbanTaskCard from './kanbanTaskCard';
import SortableKanbanGroup from './SortableKanbanGroup';
// Import the TaskListFilters component
const TaskListFilters = React.lazy(() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters'));
const TaskListFilters = React.lazy(
() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters')
);
interface TaskListBoardProps {
projectId: string;
className?: string;
projectId: string;
className?: string;
}
interface DragState {
activeTask: IProjectTask | null;
activeGroupId: string | null;
activeTask: IProjectTask | null;
activeGroupId: string | null;
}
const KanbanTaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = '' }) => {
const dispatch = useDispatch<AppDispatch>();
const [dragState, setDragState] = useState<DragState>({
activeTask: null,
activeGroupId: null,
});
// New state for active/over ids
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
const dispatch = useDispatch<AppDispatch>();
const [dragState, setDragState] = useState<DragState>({
activeTask: null,
activeGroupId: null,
});
// New state for active/over ids
const [activeTaskId, setActiveTaskId] = useState<string | null>(null);
const [overId, setOverId] = useState<string | null>(null);
// Redux selectors
// Redux selectors
const { taskGroups, groupBy, loadingGroups, error, search, archived } = useSelector((state: RootState) => state.boardReducer);
const { taskGroups, groupBy, loadingGroups, error, search, archived } = useSelector(
(state: RootState) => state.boardReducer
);
// Selection state
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([]);
// Selection state
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([]);
// Drag and Drop sensors
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const isOwnerorAdmin = useAuthService().isOwnerOrAdmin();
const isProjectManager = useIsProjectManager();
// Drag and Drop sensors
const sensors = useSensors(
useSensor(PointerSensor, {
activationConstraint: {
distance: 8,
},
}),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
})
);
const isOwnerorAdmin = useAuthService().isOwnerOrAdmin();
const isProjectManager = useIsProjectManager();
// Fetch task groups when component mounts or dependencies change
useEffect(() => {
if (projectId) {
dispatch(fetchTaskGroups(projectId));
}
}, [dispatch, projectId, groupBy, search, archived]);
// Memoized calculations
const allTaskIds = useMemo(() => {
return taskGroups.flatMap(group => group.tasks.map(task => task.id!));
}, [taskGroups]);
const totalTasksCount = useMemo(() => {
return taskGroups.reduce((sum, group) => sum + group.tasks.length, 0);
}, [taskGroups]);
const hasSelection = selectedTaskIds.length > 0;
// // Handlers
// const handleGroupingChange = (newGroupBy: IGroupBy) => {
// dispatch(setGroup(newGroupBy));
// };
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
const taskId = active.id as string;
setActiveTaskId(taskId);
setOverId(null);
// Find the task and its group
let activeTask: IProjectTask | null = null;
let activeGroupId: string | null = null;
for (const group of taskGroups) {
const task = group.tasks.find(t => t.id === taskId);
if (task) {
activeTask = task;
activeGroupId = group.id;
break;
}
}
setDragState({
activeTask,
activeGroupId,
});
};
const handleDragOver = (event: DragOverEvent) => {
setOverId(event.over?.id as string || null);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveTaskId(null);
setOverId(null);
setDragState({
activeTask: null,
activeGroupId: null,
});
if (!over || !dragState.activeTask || !dragState.activeGroupId) {
return;
}
const activeTaskId = active.id as string;
const overIdVal = over.id as string;
// Find the group and index for drop
let targetGroupId = overIdVal;
let targetIndex = -1;
let isOverTask = false;
// Check if over is a group or a task
const overGroup = taskGroups.find(g => g.id === overIdVal);
if (!overGroup) {
// Dropping on a task, find which group it belongs to
for (const group of taskGroups) {
const taskIndex = group.tasks.findIndex(t => t.id === overIdVal);
if (taskIndex !== -1) {
targetGroupId = group.id;
targetIndex = taskIndex;
isOverTask = true;
break;
}
}
}
const sourceGroup = taskGroups.find(g => g.id === dragState.activeGroupId);
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
if (!sourceGroup || !targetGroup) return;
const sourceIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
if (sourceIndex === -1) return;
// Calculate new positions
let finalTargetIndex = targetIndex;
if (!isOverTask || finalTargetIndex === -1) {
finalTargetIndex = targetGroup.tasks.length;
}
// If moving within the same group and after itself, adjust index
if (sourceGroup.id === targetGroup.id && sourceIndex < finalTargetIndex) {
finalTargetIndex--;
}
// Create updated task arrays
const updatedSourceTasks = [...sourceGroup.tasks];
const [movedTask] = updatedSourceTasks.splice(sourceIndex, 1);
let updatedTargetTasks: IProjectTask[];
if (sourceGroup.id === targetGroup.id) {
updatedTargetTasks = updatedSourceTasks;
updatedTargetTasks.splice(finalTargetIndex, 0, movedTask);
} else {
updatedTargetTasks = [...targetGroup.tasks];
updatedTargetTasks.splice(finalTargetIndex, 0, movedTask);
}
// Dispatch the reorder action
dispatch(reorderTasks({
activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id,
fromIndex: sourceIndex,
toIndex: finalTargetIndex,
task: movedTask,
updatedSourceTasks,
updatedTargetTasks,
}));
};
const handleSelectTask = (taskId: string, selected: boolean) => {
setSelectedTaskIds(prev => {
if (selected) {
return [...prev, taskId];
} else {
return prev.filter(id => id !== taskId);
}
});
};
const handleToggleSubtasks = (taskId: string) => {
// Implementation for toggling subtasks
console.log('Toggle subtasks for task:', taskId);
};
if (error) {
return (
<Card className={className}>
<Empty
description={`Error loading tasks: ${error}`}
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</Card>
);
// Fetch task groups when component mounts or dependencies change
useEffect(() => {
if (projectId) {
dispatch(fetchTaskGroups(projectId));
}
}, [dispatch, projectId, groupBy, search, archived]);
// Memoized calculations
const allTaskIds = useMemo(() => {
return taskGroups.flatMap(group => group.tasks.map(task => task.id!));
}, [taskGroups]);
const totalTasksCount = useMemo(() => {
return taskGroups.reduce((sum, group) => sum + group.tasks.length, 0);
}, [taskGroups]);
const hasSelection = selectedTaskIds.length > 0;
// // Handlers
// const handleGroupingChange = (newGroupBy: IGroupBy) => {
// dispatch(setGroup(newGroupBy));
// };
const handleDragStart = (event: DragStartEvent) => {
const { active } = event;
const taskId = active.id as string;
setActiveTaskId(taskId);
setOverId(null);
// Find the task and its group
let activeTask: IProjectTask | null = null;
let activeGroupId: string | null = null;
for (const group of taskGroups) {
const task = group.tasks.find(t => t.id === taskId);
if (task) {
activeTask = task;
activeGroupId = group.id;
break;
}
}
setDragState({
activeTask,
activeGroupId,
});
};
const handleDragOver = (event: DragOverEvent) => {
setOverId((event.over?.id as string) || null);
};
const handleDragEnd = (event: DragEndEvent) => {
const { active, over } = event;
setActiveTaskId(null);
setOverId(null);
setDragState({
activeTask: null,
activeGroupId: null,
});
if (!over || !dragState.activeTask || !dragState.activeGroupId) {
return;
}
const activeTaskId = active.id as string;
const overIdVal = over.id as string;
// Find the group and index for drop
let targetGroupId = overIdVal;
let targetIndex = -1;
let isOverTask = false;
// Check if over is a group or a task
const overGroup = taskGroups.find(g => g.id === overIdVal);
if (!overGroup) {
// Dropping on a task, find which group it belongs to
for (const group of taskGroups) {
const taskIndex = group.tasks.findIndex(t => t.id === overIdVal);
if (taskIndex !== -1) {
targetGroupId = group.id;
targetIndex = taskIndex;
isOverTask = true;
break;
}
}
}
const sourceGroup = taskGroups.find(g => g.id === dragState.activeGroupId);
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
if (!sourceGroup || !targetGroup) return;
const sourceIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
if (sourceIndex === -1) return;
// Calculate new positions
let finalTargetIndex = targetIndex;
if (!isOverTask || finalTargetIndex === -1) {
finalTargetIndex = targetGroup.tasks.length;
}
// If moving within the same group and after itself, adjust index
if (sourceGroup.id === targetGroup.id && sourceIndex < finalTargetIndex) {
finalTargetIndex--;
}
// Create updated task arrays
const updatedSourceTasks = [...sourceGroup.tasks];
const [movedTask] = updatedSourceTasks.splice(sourceIndex, 1);
let updatedTargetTasks: IProjectTask[];
if (sourceGroup.id === targetGroup.id) {
updatedTargetTasks = updatedSourceTasks;
updatedTargetTasks.splice(finalTargetIndex, 0, movedTask);
} else {
updatedTargetTasks = [...targetGroup.tasks];
updatedTargetTasks.splice(finalTargetIndex, 0, movedTask);
}
// Dispatch the reorder action
dispatch(
reorderTasks({
activeGroupId: sourceGroup.id,
overGroupId: targetGroup.id,
fromIndex: sourceIndex,
toIndex: finalTargetIndex,
task: movedTask,
updatedSourceTasks,
updatedTargetTasks,
})
);
};
const handleSelectTask = (taskId: string, selected: boolean) => {
setSelectedTaskIds(prev => {
if (selected) {
return [...prev, taskId];
} else {
return prev.filter(id => id !== taskId);
}
});
};
const handleToggleSubtasks = (taskId: string) => {
// Implementation for toggling subtasks
console.log('Toggle subtasks for task:', taskId);
};
if (error) {
return (
<div className={`task-list-board ${className}`}>
{/* Task Filters */}
<Card
size="small"
className="mb-4"
styles={{ body: { padding: '12px 16px' } }}
>
<React.Suspense fallback={<div>Loading filters...</div>}>
<TaskListFilters position="board" />
</React.Suspense>
</Card>
<Card className={className}>
<Empty description={`Error loading tasks: ${error}`} image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Card>
);
}
return (
<div className={`task-list-board ${className}`}>
{/* Task Filters */}
<Card size="small" className="mb-4" styles={{ body: { padding: '12px 16px' } }}>
<React.Suspense fallback={<div>Loading filters...</div>}>
<TaskListFilters position="board" />
</React.Suspense>
</Card>
{/* Task Groups Container */}
<div className="task-groups-outer-container">
{loadingGroups ? (
<Card>
<div className="flex justify-center items-center py-8">
<Spin size="large" />
</div>
</Card>
) : taskGroups.length === 0 ? (
<Card>
<Empty
description="No tasks found"
image={Empty.PRESENTED_IMAGE_SIMPLE}
/>
</Card>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<SortableContext
items={taskGroups.map(g => g.id)}
strategy={horizontalListSortingStrategy}
>
<div className="task-groups-container">
{taskGroups.map((group) => (
<SortableKanbanGroup
key={group.id}
group={group}
projectId={projectId}
currentGrouping={groupBy}
selectedTaskIds={selectedTaskIds}
onSelectTask={handleSelectTask}
onToggleSubtasks={handleToggleSubtasks}
activeTaskId={activeTaskId}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{dragState.activeTask ? (
<KanbanTaskCard
task={dragState.activeTask}
projectId={projectId}
groupId={dragState.activeGroupId!}
currentGrouping={groupBy}
isSelected={false}
isDragOverlay
/>
) : null}
</DragOverlay>
</DndContext>
)}
{/* Task Groups Container */}
<div className="task-groups-outer-container">
{loadingGroups ? (
<Card>
<div className="flex justify-center items-center py-8">
<Spin size="large" />
</div>
</Card>
) : taskGroups.length === 0 ? (
<Card>
<Empty description="No tasks found" image={Empty.PRESENTED_IMAGE_SIMPLE} />
</Card>
) : (
<DndContext
sensors={sensors}
collisionDetection={closestCorners}
onDragStart={handleDragStart}
onDragOver={handleDragOver}
onDragEnd={handleDragEnd}
>
<SortableContext
items={taskGroups.map(g => g.id)}
strategy={horizontalListSortingStrategy}
>
<div className="task-groups-container">
{taskGroups.map(group => (
<SortableKanbanGroup
key={group.id}
group={group}
projectId={projectId}
currentGrouping={groupBy}
selectedTaskIds={selectedTaskIds}
onSelectTask={handleSelectTask}
onToggleSubtasks={handleToggleSubtasks}
activeTaskId={activeTaskId}
/>
))}
</div>
</SortableContext>
<DragOverlay>
{dragState.activeTask ? (
<KanbanTaskCard
task={dragState.activeTask}
projectId={projectId}
groupId={dragState.activeGroupId!}
currentGrouping={groupBy}
isSelected={false}
isDragOverlay
/>
) : null}
</DragOverlay>
</DndContext>
)}
</div>
<style>{`
<style>{`
.task-groups-outer-container {
width: 100%;
overflow-x: auto;
@@ -405,8 +392,8 @@ const KanbanTaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, classNam
--task-drag-over-border: #40a9ff;
}
`}</style>
</div>
);
</div>
);
};
export default KanbanTaskListBoard;
export default KanbanTaskListBoard;

View File

@@ -83,7 +83,10 @@ const InvitationItem: React.FC<InvitationItemProps> = ({ item, isUnreadNotificat
You have been invited to work with <b>{item.team_name}</b>.
</div>
{isUnreadNotifications && (
<div className="mt-2" style={{ display: 'flex', gap: '8px', justifyContent: 'space-between' }}>
<div
className="mt-2"
style={{ display: 'flex', gap: '8px', justifyContent: 'space-between' }}
>
<button
onClick={() => acceptInvite(true)}
disabled={inProgress()}

View File

@@ -164,15 +164,15 @@ const NotificationDrawer = () => {
await handleVerifyAuth();
}
if (notification.project && notification.task_id) {
navigate(`${notification.url}${toQueryString({task: notification.params?.task, tab: notification.params?.tab})}`);
navigate(
`${notification.url}${toQueryString({ task: notification.params?.task, tab: notification.params?.tab })}`
);
}
} catch (error) {
console.error('Error navigating to URL:', error);
} finally {
setIsLoading(false);
}
}
};

View File

@@ -27,9 +27,9 @@ const NotificationTemplate: React.FC<NotificationTemplateProps> = ({
const goToUrl = async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
console.log('goToUrl triggered', { url: item.url, teamId: item.team_id });
if (item.url) {
dispatch(toggleDrawer());
@@ -92,4 +92,4 @@ const NotificationTemplate: React.FC<NotificationTemplateProps> = ({
);
};
export default NotificationTemplate;
export default NotificationTemplate;

View File

@@ -4,4 +4,4 @@
.notification-content.clickable:hover {
background-color: rgba(0, 0, 0, 0.02);
}
}

View File

@@ -5,7 +5,11 @@ import { toQueryString } from '@/utils/toQueryString';
import { BankOutlined } from '@ant-design/icons';
import './push-notification-template.css';
const PushNotificationTemplate = ({ notification: notificationData }: { notification: IWorklenzNotification }) => {
const PushNotificationTemplate = ({
notification: notificationData,
}: {
notification: IWorklenzNotification;
}) => {
const handleClick = async () => {
if (notificationData.url) {
let url = notificationData.url;
@@ -23,23 +27,25 @@ const PushNotificationTemplate = ({ notification: notificationData }: { notifica
};
return (
<div
onClick={handleClick}
<div
onClick={handleClick}
className={`notification-content ${notificationData.url ? 'clickable' : ''}`}
style={{
style={{
cursor: notificationData.url ? 'pointer' : 'default',
padding: '8px 0',
borderRadius: '8px'
borderRadius: '8px',
}}
>
<div style={{
display: 'flex',
alignItems: 'center',
marginBottom: '8px',
color: '#262626',
fontSize: '14px',
fontWeight: 500
}}>
<div
style={{
display: 'flex',
alignItems: 'center',
marginBottom: '8px',
color: '#262626',
fontSize: '14px',
fontWeight: 500,
}}
>
{notificationData.team && (
<>
<BankOutlined style={{ marginRight: '8px', color: '#1890ff' }} />
@@ -48,14 +54,14 @@ const PushNotificationTemplate = ({ notification: notificationData }: { notifica
)}
{!notificationData.team && 'Worklenz'}
</div>
<div
style={{
<div
style={{
color: '#595959',
fontSize: '13px',
lineHeight: '1.5',
marginTop: '4px'
marginTop: '4px',
}}
dangerouslySetInnerHTML={{ __html: notificationData.message }}
dangerouslySetInnerHTML={{ __html: notificationData.message }}
/>
</div>
);
@@ -66,10 +72,10 @@ let isProcessing = false;
const processNotificationQueue = () => {
if (isProcessing || notificationQueue.length === 0) return;
isProcessing = true;
const notificationData = notificationQueue.shift();
if (notificationData) {
notification.info({
message: null,
@@ -81,12 +87,12 @@ const processNotificationQueue = () => {
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
padding: '12px 16px',
minWidth: '300px',
maxWidth: '400px'
maxWidth: '400px',
},
onClose: () => {
isProcessing = false;
processNotificationQueue();
}
},
});
} else {
isProcessing = false;

View File

@@ -1,48 +1,48 @@
import React, { useMemo } from 'react';
import {
Card,
Col,
Empty,
Row,
Skeleton,
Typography,
Progress,
Tooltip,
Badge,
import {
Card,
Col,
Empty,
Row,
Skeleton,
Typography,
Progress,
Tooltip,
Badge,
Space,
Avatar,
theme,
Divider
Divider,
} from 'antd';
import {
ClockCircleOutlined,
TeamOutlined,
import {
ClockCircleOutlined,
TeamOutlined,
CheckCircleOutlined,
ProjectOutlined,
UserOutlined,
SettingOutlined,
InboxOutlined,
MoreOutlined
MoreOutlined,
} from '@ant-design/icons';
import { ProjectGroupListProps } from '@/types/project/project.types';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { themeWiseColor } from '@/utils/themeWiseColor';
import {
fetchProjectData,
setProjectId,
toggleProjectDrawer
import {
fetchProjectData,
setProjectId,
toggleProjectDrawer,
} from '@/features/project/project-drawer.slice';
import {
toggleArchiveProject,
toggleArchiveProjectForAll
import {
toggleArchiveProject,
toggleArchiveProjectForAll,
} from '@/features/projects/projectsSlice';
import { useAuthService } from '@/hooks/useAuth';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
import {
evt_projects_settings_click,
evt_projects_archive,
evt_projects_archive_all
import {
evt_projects_settings_click,
evt_projects_archive,
evt_projects_archive_all,
} from '@/shared/worklenz-analytics-events';
import logger from '@/utils/errorLogger';
@@ -53,7 +53,7 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
navigate,
onProjectSelect,
loading,
t
t,
}) => {
// Preload project view components on hover for smoother navigation
const handleProjectHover = React.useCallback((project_id: string) => {
@@ -62,7 +62,7 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
import('@/pages/projects/projectView/project-view').catch(() => {
// Silently fail if preload doesn't work
});
// Also preload critical task management components
import('@/components/task-management/task-list-board').catch(() => {
// Silently fail if preload doesn't work
@@ -83,16 +83,16 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
// Enhanced color processing for better contrast
const processColor = (color: string | undefined, fallback?: string) => {
if (!color) return fallback || token.colorPrimary;
if (color.startsWith('#')) {
if (themeMode === 'dark') {
const hex = color.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
if (brightness < 100) {
const factor = 1.5;
const newR = Math.min(255, Math.floor(r * factor));
@@ -105,9 +105,9 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
const brightness = (r * 299 + g * 587 + b * 114) / 1000;
if (brightness > 200) {
const factor = 0.7;
const newR = Math.floor(r * factor);
@@ -117,7 +117,7 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
}
}
}
return color;
};
@@ -130,7 +130,11 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
dispatch(toggleProjectDrawer());
};
const handleArchiveClick = async (e: React.MouseEvent, projectId: string, isArchived: boolean) => {
const handleArchiveClick = async (
e: React.MouseEvent,
projectId: string,
isArchived: boolean
) => {
e.stopPropagation();
try {
if (isOwnerOrAdmin) {
@@ -146,184 +150,187 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
};
// Memoized styles for better performance
const styles = useMemo(() => ({
container: {
padding: '0',
background: 'transparent',
},
groupSection: {
marginBottom: '24px',
background: 'transparent',
},
groupHeader: {
background: getThemeAwareColor(
`linear-gradient(135deg, ${token.colorFillAlter} 0%, ${token.colorFillTertiary} 100%)`,
`linear-gradient(135deg, ${token.colorFillQuaternary} 0%, ${token.colorFillSecondary} 100%)`
),
borderRadius: token.borderRadius,
padding: '12px 16px',
marginBottom: '12px',
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
boxShadow: getThemeAwareColor(
'0 1px 4px rgba(0, 0, 0, 0.06)',
'0 1px 4px rgba(0, 0, 0, 0.15)'
),
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
},
groupTitle: {
margin: 0,
color: getThemeAwareColor(token.colorText, token.colorTextBase),
fontSize: '16px',
fontWeight: 600,
letterSpacing: '-0.01em',
},
groupMeta: {
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
fontSize: '12px',
marginTop: '2px',
},
projectCard: {
height: '100%',
borderRadius: token.borderRadius,
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
boxShadow: getThemeAwareColor(
'0 1px 4px rgba(0, 0, 0, 0.04)',
'0 1px 4px rgba(0, 0, 0, 0.12)'
),
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
cursor: 'pointer',
overflow: 'hidden',
background: getThemeAwareColor(token.colorBgContainer, token.colorBgElevated),
},
projectCardHover: {
transform: 'translateY(-2px)',
boxShadow: getThemeAwareColor(
'0 4px 12px rgba(0, 0, 0, 0.08)',
'0 4px 12px rgba(0, 0, 0, 0.20)'
),
borderColor: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
},
statusBar: {
height: '3px',
background: 'linear-gradient(90deg, transparent 0%, currentColor 100%)',
borderRadius: '0 0 2px 2px',
},
projectContent: {
padding: '12px',
height: '100%',
display: 'flex',
flexDirection: 'column' as const,
minHeight: '200px', // Ensure minimum height for consistent card sizes
},
projectTitle: {
margin: '0 0 6px 0',
color: getThemeAwareColor(token.colorText, token.colorTextBase),
fontSize: '14px',
fontWeight: 600,
lineHeight: 1.3,
},
clientName: {
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
fontSize: '12px',
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '4px',
},
progressSection: {
marginBottom: '10px',
// Remove flex: 1 to prevent it from taking all available space
},
progressLabel: {
fontSize: '10px',
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
marginBottom: '4px',
fontWeight: 500,
textTransform: 'uppercase' as const,
letterSpacing: '0.3px',
},
metaGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '8px',
marginTop: 'auto', // This pushes the meta section to the bottom
paddingTop: '10px',
borderTop: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
flexShrink: 0, // Prevent the meta section from shrinking
},
metaItem: {
display: 'flex',
flexDirection: 'row' as const,
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderRadius: token.borderRadiusSM,
background: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
transition: 'all 0.2s ease',
},
metaContent: {
display: 'flex',
flexDirection: 'column' as const,
gap: '1px',
flex: 1,
},
metaIcon: {
fontSize: '12px',
color: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
},
metaValue: {
fontSize: '11px',
fontWeight: 600,
color: getThemeAwareColor(token.colorText, token.colorTextBase),
lineHeight: 1,
},
metaLabel: {
fontSize: '9px',
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
lineHeight: 1,
textTransform: 'uppercase' as const,
letterSpacing: '0.2px',
},
actionButtons: {
position: 'absolute' as const,
top: '8px',
right: '8px',
display: 'flex',
gap: '4px',
opacity: 0,
transition: 'opacity 0.2s ease',
},
actionButton: {
width: '24px',
height: '24px',
borderRadius: '4px',
border: 'none',
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
transition: 'all 0.2s ease',
backdropFilter: 'blur(4px)',
'&:hover': {
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
transform: 'scale(1.1)',
}
},
emptyState: {
padding: '60px 20px',
textAlign: 'center' as const,
background: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
borderRadius: token.borderRadiusLG,
border: `2px dashed ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
},
loadingContainer: {
padding: '40px 20px',
}
}), [token, themeMode, getThemeAwareColor]);
const styles = useMemo(
() => ({
container: {
padding: '0',
background: 'transparent',
},
groupSection: {
marginBottom: '24px',
background: 'transparent',
},
groupHeader: {
background: getThemeAwareColor(
`linear-gradient(135deg, ${token.colorFillAlter} 0%, ${token.colorFillTertiary} 100%)`,
`linear-gradient(135deg, ${token.colorFillQuaternary} 0%, ${token.colorFillSecondary} 100%)`
),
borderRadius: token.borderRadius,
padding: '12px 16px',
marginBottom: '12px',
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
boxShadow: getThemeAwareColor(
'0 1px 4px rgba(0, 0, 0, 0.06)',
'0 1px 4px rgba(0, 0, 0, 0.15)'
),
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
},
groupTitle: {
margin: 0,
color: getThemeAwareColor(token.colorText, token.colorTextBase),
fontSize: '16px',
fontWeight: 600,
letterSpacing: '-0.01em',
},
groupMeta: {
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
fontSize: '12px',
marginTop: '2px',
},
projectCard: {
height: '100%',
borderRadius: token.borderRadius,
border: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
boxShadow: getThemeAwareColor(
'0 1px 4px rgba(0, 0, 0, 0.04)',
'0 1px 4px rgba(0, 0, 0, 0.12)'
),
transition: 'all 0.3s cubic-bezier(0.4, 0, 0.2, 1)',
cursor: 'pointer',
overflow: 'hidden',
background: getThemeAwareColor(token.colorBgContainer, token.colorBgElevated),
},
projectCardHover: {
transform: 'translateY(-2px)',
boxShadow: getThemeAwareColor(
'0 4px 12px rgba(0, 0, 0, 0.08)',
'0 4px 12px rgba(0, 0, 0, 0.20)'
),
borderColor: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
},
statusBar: {
height: '3px',
background: 'linear-gradient(90deg, transparent 0%, currentColor 100%)',
borderRadius: '0 0 2px 2px',
},
projectContent: {
padding: '12px',
height: '100%',
display: 'flex',
flexDirection: 'column' as const,
minHeight: '200px', // Ensure minimum height for consistent card sizes
},
projectTitle: {
margin: '0 0 6px 0',
color: getThemeAwareColor(token.colorText, token.colorTextBase),
fontSize: '14px',
fontWeight: 600,
lineHeight: 1.3,
},
clientName: {
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
fontSize: '12px',
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '4px',
},
progressSection: {
marginBottom: '10px',
// Remove flex: 1 to prevent it from taking all available space
},
progressLabel: {
fontSize: '10px',
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
marginBottom: '4px',
fontWeight: 500,
textTransform: 'uppercase' as const,
letterSpacing: '0.3px',
},
metaGrid: {
display: 'grid',
gridTemplateColumns: 'repeat(2, 1fr)',
gap: '8px',
marginTop: 'auto', // This pushes the meta section to the bottom
paddingTop: '10px',
borderTop: `1px solid ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
flexShrink: 0, // Prevent the meta section from shrinking
},
metaItem: {
display: 'flex',
flexDirection: 'row' as const,
alignItems: 'center',
gap: '8px',
padding: '8px 12px',
borderRadius: token.borderRadiusSM,
background: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
transition: 'all 0.2s ease',
},
metaContent: {
display: 'flex',
flexDirection: 'column' as const,
gap: '1px',
flex: 1,
},
metaIcon: {
fontSize: '12px',
color: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
},
metaValue: {
fontSize: '11px',
fontWeight: 600,
color: getThemeAwareColor(token.colorText, token.colorTextBase),
lineHeight: 1,
},
metaLabel: {
fontSize: '9px',
color: getThemeAwareColor(token.colorTextTertiary, token.colorTextQuaternary),
lineHeight: 1,
textTransform: 'uppercase' as const,
letterSpacing: '0.2px',
},
actionButtons: {
position: 'absolute' as const,
top: '8px',
right: '8px',
display: 'flex',
gap: '4px',
opacity: 0,
transition: 'opacity 0.2s ease',
},
actionButton: {
width: '24px',
height: '24px',
borderRadius: '4px',
border: 'none',
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '12px',
transition: 'all 0.2s ease',
backdropFilter: 'blur(4px)',
'&:hover': {
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
transform: 'scale(1.1)',
},
},
emptyState: {
padding: '60px 20px',
textAlign: 'center' as const,
background: getThemeAwareColor(token.colorFillAlter, token.colorFillQuaternary),
borderRadius: token.borderRadiusLG,
border: `2px dashed ${getThemeAwareColor(token.colorBorderSecondary, token.colorBorder)}`,
},
loadingContainer: {
padding: '40px 20px',
},
}),
[token, themeMode, getThemeAwareColor]
);
// Early return for loading state
if (loading) {
@@ -338,7 +345,7 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
if (groups.length === 0) {
return (
<div style={styles.emptyState}>
<Empty
<Empty
image={<ProjectOutlined style={{ fontSize: '48px', color: token.colorTextTertiary }} />}
description={
<div>
@@ -356,19 +363,19 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
);
}
const renderProjectCard = (project: any) => {
const projectColor = processColor(project.color_code, token.colorPrimary);
const statusColor = processColor(project.status_color, token.colorPrimary);
const progress = project.progress || 0;
const completedTasks = project.completed_tasks_count || 0;
const totalTasks = project.all_tasks_count || 0;
const membersCount = project.members_count || 0;
const renderProjectCard = (project: any) => {
const projectColor = processColor(project.color_code, token.colorPrimary);
const statusColor = processColor(project.status_color, token.colorPrimary);
const progress = project.progress || 0;
const completedTasks = project.completed_tasks_count || 0;
const totalTasks = project.all_tasks_count || 0;
const membersCount = project.members_count || 0;
return (
<Col key={project.id} xs={24} sm={12} md={8} lg={6} xl={4}>
<Card
style={{ ...styles.projectCard, position: 'relative' }}
onMouseEnter={(e) => {
onMouseEnter={e => {
Object.assign(e.currentTarget.style, styles.projectCardHover);
const actionButtons = e.currentTarget.querySelector('.action-buttons') as HTMLElement;
if (actionButtons) {
@@ -377,7 +384,7 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
// Preload components for smoother navigation
handleProjectHover(project.id);
}}
onMouseLeave={(e) => {
onMouseLeave={e => {
Object.assign(e.currentTarget.style, styles.projectCard);
const actionButtons = e.currentTarget.querySelector('.action-buttons') as HTMLElement;
if (actionButtons) {
@@ -392,15 +399,15 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
<Tooltip title={t('setting')}>
<button
style={styles.actionButton}
onClick={(e) => handleSettingsClick(e, project.id)}
onMouseEnter={(e) => {
onClick={e => handleSettingsClick(e, project.id)}
onMouseEnter={e => {
Object.assign(e.currentTarget.style, {
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
transform: 'scale(1.1)',
});
}}
onMouseLeave={(e) => {
onMouseLeave={e => {
Object.assign(e.currentTarget.style, {
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
@@ -414,15 +421,15 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
<Tooltip title={project.archived ? t('unarchive') : t('archive')}>
<button
style={styles.actionButton}
onClick={(e) => handleArchiveClick(e, project.id, project.archived)}
onMouseEnter={(e) => {
onClick={e => handleArchiveClick(e, project.id, project.archived)}
onMouseEnter={e => {
Object.assign(e.currentTarget.style, {
background: getThemeAwareColor(token.colorPrimary, token.colorPrimaryActive),
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
transform: 'scale(1.1)',
});
}}
onMouseLeave={(e) => {
onMouseLeave={e => {
Object.assign(e.currentTarget.style, {
background: getThemeAwareColor('rgba(255,255,255,0.9)', 'rgba(0,0,0,0.7)'),
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
@@ -434,20 +441,24 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
</button>
</Tooltip>
</div>
{/* Project color indicator bar */}
<div
style={{
...styles.statusBar,
color: projectColor,
}}
/>
{/* Project color indicator bar */}
<div
style={{
...styles.statusBar,
color: projectColor,
}}
/>
<div style={styles.projectContent}>
{/* Project title */}
<Title level={5} ellipsis={{ rows: 2, tooltip: project.name }} style={styles.projectTitle}>
<Title
level={5}
ellipsis={{ rows: 2, tooltip: project.name }}
style={styles.projectTitle}
>
{project.name}
</Title>
{/* Client name */}
{project.client_name && (
<div style={styles.clientName}>
@@ -457,45 +468,47 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
</Text>
</div>
)}
{/* Progress section */}
<div style={styles.progressSection}>
<div style={styles.progressLabel}>
Progress
</div>
<Progress
percent={progress}
size="small"
strokeColor={{
'0%': projectColor,
'100%': statusColor,
}}
trailColor={getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)}
strokeWidth={4}
showInfo={false}
/>
<Text style={{
fontSize: '10px',
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
marginTop: '2px',
display: 'block'
}}>
{progress}%
</Text>
<div style={styles.progressLabel}>Progress</div>
<Progress
percent={progress}
size="small"
strokeColor={{
'0%': projectColor,
'100%': statusColor,
}}
trailColor={getThemeAwareColor(token.colorFillSecondary, token.colorFillTertiary)}
strokeWidth={4}
showInfo={false}
/>
<Text
style={{
fontSize: '10px',
color: getThemeAwareColor(token.colorTextSecondary, token.colorTextTertiary),
marginTop: '2px',
display: 'block',
}}
>
{progress}%
</Text>
</div>
{/* Meta information grid */}
<div style={styles.metaGrid}>
<Tooltip title="Tasks completed">
<div style={styles.metaItem}>
<CheckCircleOutlined style={styles.metaIcon} />
<div style={styles.metaContent}>
<span style={styles.metaValue}>{completedTasks}/{totalTasks}</span>
<span style={styles.metaValue}>
{completedTasks}/{totalTasks}
</span>
<span style={styles.metaLabel}>Tasks</span>
</div>
</div>
</Tooltip>
<Tooltip title="Team members">
<div style={styles.metaItem}>
<TeamOutlined style={styles.metaIcon} />
@@ -521,14 +534,16 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
<Space align="center" style={{ width: '100%', justifyContent: 'space-between' }}>
<Space align="center">
{group.groupColor && (
<div style={{
width: '16px',
height: '16px',
borderRadius: '50%',
backgroundColor: processColor(group.groupColor),
flexShrink: 0,
border: `2px solid ${getThemeAwareColor('rgba(255,255,255,0.8)', 'rgba(0,0,0,0.3)')}`
}} />
<div
style={{
width: '16px',
height: '16px',
borderRadius: '50%',
backgroundColor: processColor(group.groupColor),
flexShrink: 0,
border: `2px solid ${getThemeAwareColor('rgba(255,255,255,0.8)', 'rgba(0,0,0,0.3)')}`,
}}
/>
)}
<div>
<Title level={4} style={styles.groupTitle}>
@@ -539,10 +554,10 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
</div>
</div>
</Space>
<Badge
count={group.projects.length}
style={{
<Badge
count={group.projects.length}
style={{
backgroundColor: processColor(group.groupColor, token.colorPrimary),
color: getThemeAwareColor('#fff', token.colorTextLightSolid),
fontWeight: 600,
@@ -551,24 +566,24 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
height: '24px',
lineHeight: '22px',
borderRadius: '12px',
border: `2px solid ${getThemeAwareColor(token.colorBgContainer, token.colorBgElevated)}`
border: `2px solid ${getThemeAwareColor(token.colorBgContainer, token.colorBgElevated)}`,
}}
/>
</Space>
</div>
{/* Projects grid */}
<Row gutter={[16, 16]}>
{group.projects.map(renderProjectCard)}
</Row>
<Row gutter={[16, 16]}>{group.projects.map(renderProjectCard)}</Row>
{/* Add spacing between groups except for the last one */}
{groupIndex < groups.length - 1 && (
<Divider style={{
margin: '32px 0 0 0',
borderColor: getThemeAwareColor(token.colorBorderSecondary, token.colorBorder),
opacity: 0.5
}} />
<Divider
style={{
margin: '32px 0 0 0',
borderColor: getThemeAwareColor(token.colorBorderSecondary, token.colorBorder),
opacity: 0.5,
}}
/>
)}
</div>
))}
@@ -576,4 +591,4 @@ const ProjectGroupList: React.FC<ProjectGroupListProps> = ({
);
};
export default ProjectGroupList;
export default ProjectGroupList;

View File

@@ -1,6 +1,10 @@
import { useGetProjectsQuery } from '@/api/projects/projects.v1.api.service';
import { AppDispatch } from '@/app/store';
import { fetchProjectData, setProjectId, toggleProjectDrawer } from '@/features/project/project-drawer.slice';
import {
fetchProjectData,
setProjectId,
toggleProjectDrawer,
} from '@/features/project/project-drawer.slice';
import {
toggleArchiveProjectForAll,
toggleArchiveProject,
@@ -12,7 +16,11 @@ import { IProjectViewModel } from '@/types/project/projectViewModel.types';
import logger from '@/utils/errorLogger';
import { SettingOutlined, InboxOutlined } from '@ant-design/icons';
import { Tooltip, Button, Popconfirm, Space } from 'antd';
import { evt_projects_archive, evt_projects_archive_all, evt_projects_settings_click } from '@/shared/worklenz-analytics-events';
import {
evt_projects_archive,
evt_projects_archive_all,
evt_projects_settings_click,
} from '@/shared/worklenz-analytics-events';
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
interface ActionButtonsProps {
@@ -71,7 +79,9 @@ export const ActionButtons: React.FC<ActionButtonsProps> = ({
icon={<SettingOutlined />}
/>
</Tooltip>
<Tooltip title={isEditable ? (record.archived ? t('unarchive') : t('archive')) : t('noPermission')}>
<Tooltip
title={isEditable ? (record.archived ? t('unarchive') : t('archive')) : t('noPermission')}
>
<Popconfirm
title={record.archived ? t('unarchive') : t('archive')}
description={record.archived ? t('unarchiveConfirm') : t('archiveConfirm')}

View File

@@ -11,10 +11,8 @@ export const CategoryCell: React.FC<{
t: TFunction;
}> = ({ record, t }) => {
if (!record.category_name) return '-';
const { requestParams } = useAppSelector(
state => state.projectsReducer
);
const { requestParams } = useAppSelector(state => state.projectsReducer);
const dispatch = useAppDispatch();
const newParams: Partial<typeof requestParams> = {};
const filterByCategory = (categoryId: string | undefined) => {

View File

@@ -37,7 +37,8 @@ export const ProjectRateCell: React.FC<{
);
useEffect(() => {
setIsFavorite(record.favorite);}, [record.favorite]);
setIsFavorite(record.favorite);
}, [record.favorite]);
return (
<ConfigProvider wave={{ disabled: true }}>
@@ -48,7 +49,7 @@ export const ProjectRateCell: React.FC<{
style={{ backgroundColor: colors.transparent }}
shape="circle"
icon={<StarFilled style={{ color: checkIconColor, fontSize: '20px' }} />}
onClick={(e) => {
onClick={e => {
e.stopPropagation();
handleFavorite();
}}
@@ -56,4 +57,4 @@ export const ProjectRateCell: React.FC<{
</Tooltip>
</ConfigProvider>
);
};
};

View File

@@ -12,10 +12,7 @@ import { deleteStatusToggleDrawer } from '@/features/projects/status/DeleteStatu
import { Drawer, Alert, Card, Select, Button, Typography, Badge } from 'antd';
import { DownOutlined } from '@ant-design/icons';
import { useSelector } from 'react-redux';
import {
deleteSection,
IGroupBy,
} from '@features/board/board-slice';
import { deleteSection, IGroupBy } from '@features/board/board-slice';
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
import logger from '@/utils/errorLogger';
@@ -24,124 +21,123 @@ const { Title, Text } = Typography;
const { Option } = Select;
const DeleteStatusDrawer: React.FC = () => {
const [currentStatus, setCurrentStatus] = useState<string>('');
const [deletingStatus, setDeletingStatus] = useState(false);
const dispatch = useAppDispatch();
const { trackMixpanelEvent } = useMixpanelTracking();
const { projectView } = useTabSearchParam();
const [form] = Form.useForm();
const { t } = useTranslation('task-list-filters');
const { editableSectionId, groupBy } = useAppSelector(state => state.boardReducer);
const isDelteStatusDrawerOpen = useAppSelector(
state => state.deleteStatusReducer.isDeleteStatusDrawerOpen
);
const { isDeleteStatusDrawerOpen, status: selectedForDelete } = useAppSelector(
(state) => state.deleteStatusReducer
);
const { status, statusCategories } = useAppSelector(state => state.taskStatusReducer);
const { projectId } = useAppSelector(state => state.projectReducer);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const [currentStatus, setCurrentStatus] = useState<string>('');
const [deletingStatus, setDeletingStatus] = useState(false);
const dispatch = useAppDispatch();
const { trackMixpanelEvent } = useMixpanelTracking();
const { projectView } = useTabSearchParam();
const [form] = Form.useForm();
const { t } = useTranslation('task-list-filters');
const { editableSectionId, groupBy } = useAppSelector(state => state.boardReducer);
const isDelteStatusDrawerOpen = useAppSelector(
state => state.deleteStatusReducer.isDeleteStatusDrawerOpen
);
const { isDeleteStatusDrawerOpen, status: selectedForDelete } = useAppSelector(
state => state.deleteStatusReducer
);
const { status, statusCategories } = useAppSelector(state => state.taskStatusReducer);
const { projectId } = useAppSelector(state => state.projectReducer);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const refreshTasks = useCallback(() => {
if (!projectId) return;
const fetchAction = projectView === 'list' ? fetchTaskGroups : fetchEnhancedKanbanGroups;
dispatch(fetchAction(projectId) as any);
}, [projectId, projectView, dispatch]);
const refreshTasks = useCallback(() => {
if (!projectId) return;
const fetchAction = projectView === 'list' ? fetchTaskGroups : fetchEnhancedKanbanGroups;
dispatch(fetchAction(projectId) as any);
}, [projectId, projectView, dispatch]);
const handleDrawerOpenChange = () => {
if (status.length === 0) {
dispatch(fetchStatusesCategories());
const handleDrawerOpenChange = () => {
if (status.length === 0) {
dispatch(fetchStatusesCategories());
}
};
const setReplacingStatus = (value: string) => {
setCurrentStatus(value);
};
const moveAndDelete = async () => {
const groupId = selectedForDelete?.id;
if (!projectId || !currentStatus || !groupId) return;
setDeletingStatus(true);
try {
if (groupBy === IGroupBy.STATUS) {
const replacingStatusId = currentStatus;
if (!replacingStatusId) return;
const res = await statusApiService.deleteStatus(groupId, projectId, replacingStatusId);
if (res.done) {
dispatch(deleteSection({ sectionId: groupId }));
dispatch(deleteStatusToggleDrawer());
dispatch(fetchStatuses(projectId));
refreshTasks();
dispatch(fetchStatusesCategories());
} else {
console.error('Error deleting status', res);
}
};
const setReplacingStatus = (value: string) => {
setCurrentStatus(value);
};
const moveAndDelete = async () => {
const groupId = selectedForDelete?.id;
if (!projectId || !currentStatus || !groupId) return;
setDeletingStatus(true);
try {
if (groupBy === IGroupBy.STATUS) {
const replacingStatusId = currentStatus;
if (!replacingStatusId) return;
const res = await statusApiService.deleteStatus(groupId, projectId, replacingStatusId);
if (res.done) {
dispatch(deleteSection({ sectionId: groupId }));
dispatch(deleteStatusToggleDrawer());
dispatch(fetchStatuses(projectId));
refreshTasks();
dispatch(fetchStatusesCategories());
} else {
console.error('Error deleting status', res);
}
} else if (groupBy === IGroupBy.PHASE) {
const res = await phasesApiService.deletePhaseOption(groupId, projectId);
if (res.done) {
dispatch(deleteSection({ sectionId: groupId }));
}
}
} catch (error) {
logger.error('Error deleting section', error);
} finally {
setDeletingStatus(false);
} else if (groupBy === IGroupBy.PHASE) {
const res = await phasesApiService.deletePhaseOption(groupId, projectId);
if (res.done) {
dispatch(deleteSection({ sectionId: groupId }));
}
};
useEffect(() => {
setCurrentStatus(status[0]?.id || '');
}, [isDelteStatusDrawerOpen]);
}
} catch (error) {
logger.error('Error deleting section', error);
} finally {
setDeletingStatus(false);
}
};
useEffect(() => {
setCurrentStatus(status[0]?.id || '');
}, [isDelteStatusDrawerOpen]);
return (
<Drawer
title="You are deleting a status"
onClose={() => dispatch(deleteStatusToggleDrawer())}
open={isDelteStatusDrawerOpen}
afterOpenChange={handleDrawerOpenChange}
return (
<Drawer
title="You are deleting a status"
onClose={() => dispatch(deleteStatusToggleDrawer())}
open={isDelteStatusDrawerOpen}
afterOpenChange={handleDrawerOpenChange}
>
<Alert type="warning" message={selectedForDelete?.message.replace('$', '')} />
<Card className="text-center" style={{ marginTop: 16 }}>
<Title level={5}>{selectedForDelete?.name}</Title>
<Title level={4} style={{ margin: '16px 0' }}>
<DownOutlined />
</Title>
<Select
value={currentStatus}
onChange={setReplacingStatus}
style={{ width: '100%' }}
optionLabelProp="name"
options={status.map(item => ({
key: item.id,
value: item.id,
name: item.name,
label: (
<Badge
color={item.color_code}
text={item?.name || null}
style={{
opacity: item.id === selectedForDelete?.id ? 0.5 : undefined,
}}
/>
),
disabled: item.id === selectedForDelete?.id,
}))}
/>
<Button
type="primary"
block
loading={deletingStatus}
disabled={deletingStatus}
onClick={moveAndDelete}
style={{ marginTop: 16 }}
>
<Alert type="warning" message={selectedForDelete?.message.replace("$", "")} />
<Card className="text-center" style={{ marginTop: 16 }}>
<Title level={5}>{selectedForDelete?.name}</Title>
<Title level={4} style={{ margin: '16px 0' }}>
<DownOutlined />
</Title>
<Select
value={currentStatus}
onChange={setReplacingStatus}
style={{ width: '100%' }}
optionLabelProp='name'
options={status.map((item) => ({
key: item.id,
value: item.id,
name: item.name,
label: (
<Badge
color={item.color_code}
text={item?.name || null}
style={{
opacity: item.id === selectedForDelete?.id ? 0.5 : undefined
}}
/>
),
disabled: item.id === selectedForDelete?.id
}))}
/>
<Button
type="primary"
block
loading={deletingStatus}
disabled={deletingStatus}
onClick={moveAndDelete}
style={{ marginTop: 16 }}
>
Done
</Button>
</Card>
</Drawer>
);
Done
</Button>
</Card>
</Drawer>
);
};
export default DeleteStatusDrawer;
export default DeleteStatusDrawer;

View File

@@ -37,7 +37,7 @@ const ColumnConfigurationModal: React.FC<ColumnConfigurationModalProps> = ({
}, [currentConfig, open]);
const handleToggleColumn = (key: string) => {
const newConfig = config.map(col =>
const newConfig = config.map(col =>
col.key === key ? { ...col, showInDropdown: !col.showInDropdown } : col
);
setConfig(newConfig);
@@ -76,14 +76,17 @@ const ColumnConfigurationModal: React.FC<ColumnConfigurationModalProps> = ({
setHasChanges(false);
};
const groupedColumns = config.reduce((groups, column) => {
const category = column.category || 'other';
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(column);
return groups;
}, {} as Record<string, ColumnConfig[]>);
const groupedColumns = config.reduce(
(groups, column) => {
const category = column.category || 'other';
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(column);
return groups;
},
{} as Record<string, ColumnConfig[]>
);
const categoryLabels: Record<string, string> = {
basic: 'Basic Information',
@@ -117,8 +120,8 @@ const ColumnConfigurationModal: React.FC<ColumnConfigurationModalProps> = ({
>
<div style={{ marginBottom: 16 }}>
<Typography.Text type="secondary">
Configure which columns appear in the "Show Fields" dropdown and their order.
Use the up/down arrows to reorder columns.
Configure which columns appear in the "Show Fields" dropdown and their order. Use the
up/down arrows to reorder columns.
</Typography.Text>
</div>
@@ -127,7 +130,7 @@ const ColumnConfigurationModal: React.FC<ColumnConfigurationModalProps> = ({
<Divider orientation="left">
<Typography.Text strong>{categoryLabels[category] || category}</Typography.Text>
</Divider>
{columns.map((column, index) => (
<div
key={column.key}
@@ -149,11 +152,11 @@ const ColumnConfigurationModal: React.FC<ColumnConfigurationModalProps> = ({
>
<Typography.Text>{column.label}</Typography.Text>
</Checkbox>
<Typography.Text type="secondary" style={{ fontSize: '12px', minWidth: '60px' }}>
Order: {column.order}
</Typography.Text>
<Space>
<Button
size="small"
@@ -176,4 +179,4 @@ const ColumnConfigurationModal: React.FC<ColumnConfigurationModalProps> = ({
);
};
export default ColumnConfigurationModal;
export default ColumnConfigurationModal;

View File

@@ -45,7 +45,7 @@ const GroupByFilterDropdown = () => {
const handleGroupChange = (key: string) => {
const group = key as IGroupBy;
if (projectView === 'list') {
setCurrentGroup(group);
dispatch(setGroup(group));
@@ -64,7 +64,7 @@ const GroupByFilterDropdown = () => {
trigger={['click']}
menu={{
items,
onClick: (info) => handleGroupChange(info.key),
onClick: info => handleGroupChange(info.key),
selectedKeys: [currentGroup],
}}
>
@@ -72,13 +72,13 @@ const GroupByFilterDropdown = () => {
{selectedLabel} <CaretDownFilled />
</Button>
</Dropdown>
{(currentGroup === IGroupBy.STATUS || currentGroup === IGroupBy.PHASE) && (isOwnerOrAdmin || isProjectManager) && (
<ConfigProvider wave={{ disabled: true }}>
{currentGroup === IGroupBy.PHASE && <ConfigPhaseButton />}
{currentGroup === IGroupBy.STATUS && <CreateStatusButton />}
</ConfigProvider>
)}
{(currentGroup === IGroupBy.STATUS || currentGroup === IGroupBy.PHASE) &&
(isOwnerOrAdmin || isProjectManager) && (
<ConfigProvider wave={{ disabled: true }}>
{currentGroup === IGroupBy.PHASE && <ConfigPhaseButton />}
{currentGroup === IGroupBy.STATUS && <CreateStatusButton />}
</ConfigProvider>
)}
</Flex>
);
};

View File

@@ -1,18 +1,18 @@
import { useMemo, useRef, useState, useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { CaretDownFilled } from '@ant-design/icons';
import {
Badge,
Button,
Card,
Checkbox,
Dropdown,
Empty,
Flex,
Input,
List,
Space,
Typography
import {
Badge,
Button,
Card,
Checkbox,
Dropdown,
Empty,
Flex,
Input,
List,
Space,
Typography,
} from 'antd';
import type { InputRef } from 'antd';
@@ -50,46 +50,49 @@ const MembersFilterDropdown = () => {
// Reset task assignees selections
const resetTaskMembers = taskAssignees.map(member => ({
...member,
selected: false
selected: false,
}));
dispatch(setMembers(resetTaskMembers));
// Reset board assignees selections
const resetBoardMembers = boardTaskAssignees.map(member => ({
...member,
selected: false
selected: false,
}));
dispatch(setBoardMembers(resetBoardMembers));
}
}, [projectId, dispatch]);
const selectedCount = useMemo(() => {
return projectView === 'list' ? taskAssignees.filter(member => member.selected).length : boardTaskAssignees.filter(member => member.selected).length;
return projectView === 'list'
? taskAssignees.filter(member => member.selected).length
: boardTaskAssignees.filter(member => member.selected).length;
}, [taskAssignees, boardTaskAssignees, projectView]);
const filteredMembersData = useMemo(() => {
const members = projectView === 'list' ? taskAssignees : boardTaskAssignees;
return members.filter(member =>
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
);
return members.filter(member => member.name?.toLowerCase().includes(searchQuery.toLowerCase()));
}, [taskAssignees, boardTaskAssignees, searchQuery, projectView]);
const handleSelectedFiltersCount = useCallback(async (memberId: string | undefined, checked: boolean) => {
if (!memberId || !projectId) return;
const handleSelectedFiltersCount = useCallback(
async (memberId: string | undefined, checked: boolean) => {
if (!memberId || !projectId) return;
const updateMembers = async (members: Member[], setAction: any, fetchAction: any) => {
const updatedMembers = members.map(member =>
member.id === memberId ? { ...member, selected: checked } : member
);
await dispatch(setAction(updatedMembers));
dispatch(fetchAction(projectId));
};
if (projectView === 'list') {
await updateMembers(taskAssignees as Member[], setMembers, fetchTaskGroups);
} else {
await updateMembers(boardTaskAssignees as Member[], setBoardMembers, fetchBoardTaskGroups);
}
}, [projectId, projectView, taskAssignees, boardTaskAssignees, dispatch]);
const updateMembers = async (members: Member[], setAction: any, fetchAction: any) => {
const updatedMembers = members.map(member =>
member.id === memberId ? { ...member, selected: checked } : member
);
await dispatch(setAction(updatedMembers));
dispatch(fetchAction(projectId));
};
if (projectView === 'list') {
await updateMembers(taskAssignees as Member[], setMembers, fetchTaskGroups);
} else {
await updateMembers(boardTaskAssignees as Member[], setBoardMembers, fetchBoardTaskGroups);
}
},
[projectId, projectView, taskAssignees, boardTaskAssignees, dispatch]
);
const renderMemberItem = (member: Member) => (
<List.Item
@@ -103,11 +106,7 @@ const MembersFilterDropdown = () => {
onChange={e => handleSelectedFiltersCount(member.id, e.target.checked)}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<SingleAvatar
avatarUrl={member.avatar_url}
name={member.name}
email={member.email}
/>
<SingleAvatar avatarUrl={member.avatar_url} name={member.name} email={member.email} />
<Flex vertical>
{member.name}
<Typography.Text style={{ fontSize: 12, color: colors.lightGray }}>
@@ -129,29 +128,36 @@ const MembersFilterDropdown = () => {
placeholder={t('searchInputPlaceholder')}
/>
<List style={{ padding: 0, maxHeight: 250, overflow: 'auto' }}>
{filteredMembersData.length ?
filteredMembersData.map((member, index) => renderMemberItem(member as Member)) :
{filteredMembersData.length ? (
filteredMembersData.map((member, index) => renderMemberItem(member as Member))
) : (
<Empty />
}
)}
</List>
</Flex>
</Card>
);
const handleMembersDropdownOpen = useCallback((open: boolean) => {
if (open) {
setTimeout(() => membersInputRef.current?.focus(), 0);
// Only sync the members if board members are empty
if (projectView === 'kanban' && boardTaskAssignees.length === 0 && taskAssignees.length > 0) {
dispatch(setBoardMembers(taskAssignees));
const handleMembersDropdownOpen = useCallback(
(open: boolean) => {
if (open) {
setTimeout(() => membersInputRef.current?.focus(), 0);
// Only sync the members if board members are empty
if (
projectView === 'kanban' &&
boardTaskAssignees.length === 0 &&
taskAssignees.length > 0
) {
dispatch(setBoardMembers(taskAssignees));
}
}
}
}, [dispatch, taskAssignees, boardTaskAssignees, projectView]);
},
[dispatch, taskAssignees, boardTaskAssignees, projectView]
);
const buttonStyle = {
backgroundColor: selectedCount > 0
? themeMode === 'dark' ? '#003a5c' : colors.paleBlue
: colors.transparent,
backgroundColor:
selectedCount > 0 ? (themeMode === 'dark' ? '#003a5c' : colors.paleBlue) : colors.transparent,
color: selectedCount > 0 ? (themeMode === 'dark' ? 'white' : colors.darkGray) : 'inherit',
};
@@ -162,11 +168,7 @@ const MembersFilterDropdown = () => {
dropdownRender={() => membersDropdownContent}
onOpenChange={handleMembersDropdownOpen}
>
<Button
icon={<CaretDownFilled />}
iconPosition="end"
style={buttonStyle}
>
<Button icon={<CaretDownFilled />} iconPosition="end" style={buttonStyle}>
<Space>
{t('membersText')}
{selectedCount > 0 && <Badge size="small" count={selectedCount} color={colors.skyBlue} />}

View File

@@ -6,7 +6,7 @@ import { Badge, Button, Card, Checkbox, Dropdown, List, Space } from 'antd';
import { useAppSelector } from '@/hooks/useAppSelector';
import { colors } from '@/styles/colors';
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
import { setPriorities } from '@/features/tasks/tasks.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import useTabSearchParam from '@/hooks/useTabSearchParam';
@@ -34,14 +34,12 @@ const PriorityFilterDropdown = ({ priorities }: PriorityFilterDropdownProps) =>
const { projectView } = useTabSearchParam();
const selectedCount = projectView === 'list'
? selectedPriorities.length
: boardSelectedPriorities.length;
const selectedCount =
projectView === 'list' ? selectedPriorities.length : boardSelectedPriorities.length;
const buttonStyle = {
backgroundColor: selectedCount > 0
? themeMode === 'dark' ? '#003a5c' : colors.paleBlue
: colors.transparent,
backgroundColor:
selectedCount > 0 ? (themeMode === 'dark' ? '#003a5c' : colors.paleBlue) : colors.transparent,
color: selectedCount > 0 ? (themeMode === 'dark' ? 'white' : colors.darkGray) : 'inherit',
};
@@ -55,58 +53,64 @@ const PriorityFilterDropdown = ({ priorities }: PriorityFilterDropdownProps) =>
}
}, [dispatch, projectId, selectedPriorities, boardSelectedPriorities, projectView]);
const handleSelectedPriority = useCallback((priorityId: string) => {
if (!projectId) return;
const handleSelectedPriority = useCallback(
(priorityId: string) => {
if (!projectId) return;
const updatePriorities = (currentPriorities: string[], setAction: any, fetchAction: any) => {
const newPriorities = currentPriorities.includes(priorityId)
? currentPriorities.filter(id => id !== priorityId)
: [...currentPriorities, priorityId];
dispatch(setAction(newPriorities));
dispatch(fetchAction(projectId));
};
const updatePriorities = (currentPriorities: string[], setAction: any, fetchAction: any) => {
const newPriorities = currentPriorities.includes(priorityId)
? currentPriorities.filter(id => id !== priorityId)
: [...currentPriorities, priorityId];
dispatch(setAction(newPriorities));
dispatch(fetchAction(projectId));
};
if (projectView === 'list') {
updatePriorities(selectedPriorities, setPriorities, fetchTaskGroupsList);
} else {
updatePriorities(boardSelectedPriorities, setBoardPriorities, fetchBoardTaskGroups);
}
}, [dispatch, projectId, projectView, selectedPriorities, boardSelectedPriorities]);
if (projectView === 'list') {
updatePriorities(selectedPriorities, setPriorities, fetchTaskGroupsList);
} else {
updatePriorities(boardSelectedPriorities, setBoardPriorities, fetchBoardTaskGroups);
}
},
[dispatch, projectId, projectView, selectedPriorities, boardSelectedPriorities]
);
const priorityDropdownContent = useMemo(() => (
<Card className="custom-card" style={{ width: 120 }} styles={{ body: { padding: 0 } }}>
<List style={{ padding: 0, maxHeight: 250, overflow: 'auto' }}>
{priorities?.map(priority => (
<List.Item
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
key={priority.id}
onClick={() => handleSelectedPriority(priority.id)}
style={{
display: 'flex',
gap: 8,
padding: '4px 8px',
border: 'none',
cursor: 'pointer',
}}
>
<Space>
<Checkbox
id={priority.id}
checked={
projectView === 'list'
? selectedPriorities.includes(priority.id)
: boardSelectedPriorities.includes(priority.id)
}
onChange={() => handleSelectedPriority(priority.id)}
/>
<Badge color={priority.color_code} />
{priority.name}
</Space>
</List.Item>
))}
</List>
</Card>
), [priorities, selectedPriorities, boardSelectedPriorities, themeMode, handleSelectedPriority]);
const priorityDropdownContent = useMemo(
() => (
<Card className="custom-card" style={{ width: 120 }} styles={{ body: { padding: 0 } }}>
<List style={{ padding: 0, maxHeight: 250, overflow: 'auto' }}>
{priorities?.map(priority => (
<List.Item
className={`custom-list-item ${themeMode === 'dark' ? 'dark' : ''}`}
key={priority.id}
onClick={() => handleSelectedPriority(priority.id)}
style={{
display: 'flex',
gap: 8,
padding: '4px 8px',
border: 'none',
cursor: 'pointer',
}}
>
<Space>
<Checkbox
id={priority.id}
checked={
projectView === 'list'
? selectedPriorities.includes(priority.id)
: boardSelectedPriorities.includes(priority.id)
}
onChange={() => handleSelectedPriority(priority.id)}
/>
<Badge color={priority.color_code} />
{priority.name}
</Space>
</List.Item>
))}
</List>
</Card>
),
[priorities, selectedPriorities, boardSelectedPriorities, themeMode, handleSelectedPriority]
);
return (
<Dropdown
@@ -114,16 +118,10 @@ const PriorityFilterDropdown = ({ priorities }: PriorityFilterDropdownProps) =>
trigger={['click']}
dropdownRender={() => priorityDropdownContent}
>
<Button
icon={<CaretDownFilled />}
iconPosition="end"
style={buttonStyle}
>
<Button icon={<CaretDownFilled />} iconPosition="end" style={buttonStyle}>
<Space>
{t('priorityText')}
{selectedCount > 0 && (
<Badge size="small" count={selectedCount} color={colors.skyBlue} />
)}
{selectedCount > 0 && <Badge size="small" count={selectedCount} color={colors.skyBlue} />}
</Space>
</Button>
</Dropdown>

View File

@@ -17,7 +17,6 @@ import { SearchOutlined } from '@ant-design/icons';
import { setBoardSearch } from '@/features/board/board-slice';
const SearchDropdown = () => {
const { t } = useTranslation('task-list-filters');
const dispatch = useDispatch();

View File

@@ -8,10 +8,7 @@ import React, { useState } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import {
updateColumnVisibility,
updateCustomColumnPinned,
} from '@/features/tasks/tasks.slice';
import { updateColumnVisibility, updateCustomColumnPinned } from '@/features/tasks/tasks.slice';
import { ITaskListColumn } from '@/types/tasks/taskList.types';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
@@ -37,14 +34,38 @@ const DEFAULT_COLUMN_CONFIG: ColumnConfig[] = [
{ key: 'LABELS', label: 'Labels', showInDropdown: true, order: 7, category: 'basic' },
{ key: 'PHASE', label: 'Phase', showInDropdown: true, order: 8, category: 'basic' },
{ key: 'PRIORITY', label: 'Priority', showInDropdown: true, order: 9, category: 'basic' },
{ key: 'TIME_TRACKING', label: 'Time Tracking', showInDropdown: true, order: 10, category: 'time' },
{
key: 'TIME_TRACKING',
label: 'Time Tracking',
showInDropdown: true,
order: 10,
category: 'time',
},
{ key: 'ESTIMATION', label: 'Estimation', showInDropdown: true, order: 11, category: 'time' },
{ key: 'START_DATE', label: 'Start Date', showInDropdown: true, order: 12, category: 'dates' },
{ key: 'DUE_DATE', label: 'Due Date', showInDropdown: true, order: 13, category: 'dates' },
{ key: 'DUE_TIME', label: 'Due Time', showInDropdown: true, order: 14, category: 'dates' },
{ key: 'COMPLETED_DATE', label: 'Completed Date', showInDropdown: true, order: 15, category: 'dates' },
{ key: 'CREATED_DATE', label: 'Created Date', showInDropdown: true, order: 16, category: 'dates' },
{ key: 'LAST_UPDATED', label: 'Last Updated', showInDropdown: true, order: 17, category: 'dates' },
{
key: 'COMPLETED_DATE',
label: 'Completed Date',
showInDropdown: true,
order: 15,
category: 'dates',
},
{
key: 'CREATED_DATE',
label: 'Created Date',
showInDropdown: true,
order: 16,
category: 'dates',
},
{
key: 'LAST_UPDATED',
label: 'Last Updated',
showInDropdown: true,
order: 17,
category: 'dates',
},
{ key: 'REPORTER', label: 'Reporter', showInDropdown: true, order: 18, category: 'basic' },
];
@@ -55,11 +76,11 @@ const useColumnConfig = (projectId?: string): ColumnConfig[] => {
// 2. User preferences from localStorage
// 3. Global settings from configuration
// 4. Team-level settings
// For now, return default configuration
// You can extend this to load from localStorage or API
const storedConfig = localStorage.getItem(`worklenz.column-config.${projectId}`);
if (storedConfig) {
try {
return JSON.parse(storedConfig);
@@ -67,7 +88,7 @@ const useColumnConfig = (projectId?: string): ColumnConfig[] => {
console.warn('Failed to parse stored column config, using default');
}
}
return DEFAULT_COLUMN_CONFIG;
};
@@ -85,7 +106,9 @@ const ShowFieldsFilterDropdown = () => {
const columnList = useAppSelector(state => state.taskReducer.columns);
const { projectId, project } = useAppSelector(state => state.projectReducer);
const [configModalOpen, setConfigModalOpen] = useState(false);
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(useColumnConfig(projectId || undefined));
const [columnConfig, setColumnConfig] = useState<ColumnConfig[]>(
useColumnConfig(projectId || undefined)
);
const saveColumnConfig = useSaveColumnConfig();
// Update config if projectId changes
@@ -99,15 +122,15 @@ const ShowFieldsFilterDropdown = () => {
if (column.key === 'selector' || column.key === 'TASK') {
return false;
}
// Find configuration for this column
const config = columnConfig.find(c => c.key === column.key);
// If no config found, show custom columns by default
if (!config) {
return column.custom_column;
}
// Return based on configuration
return config.showInDropdown;
});
@@ -116,13 +139,13 @@ const ShowFieldsFilterDropdown = () => {
const sortedColumns = visibilityChangableColumnList.sort((a, b) => {
const configA = columnConfig.find(c => c.key === a.key);
const configB = columnConfig.find(c => c.key === b.key);
const orderA = configA?.order ?? 999;
const orderB = configB?.order ?? 999;
return orderA - orderB;
});
const themeMode = useAppSelector(state => state.themeReducer.mode);
const handleColumnVisibilityChange = async (col: ITaskListColumn) => {

View File

@@ -82,14 +82,18 @@ const CreateProjectButton: React.FC<CreateProjectButtonProps> = ({ className })
template_id: currentTemplateId,
});
if (res.done) {
navigate(`/worklenz/projects/${res.body.project_id}?tab=tasks-list&pinned_tab=tasks-list`);
navigate(
`/worklenz/projects/${res.body.project_id}?tab=tasks-list&pinned_tab=tasks-list`
);
}
} else {
const res = await projectTemplatesApiService.createFromCustomTemplate({
template_id: currentTemplateId,
});
if (res.done) {
navigate(`/worklenz/projects/${res.body.project_id}?tab=tasks-list&pinned_tab=tasks-list`);
navigate(
`/worklenz/projects/${res.body.project_id}?tab=tasks-list&pinned_tab=tasks-list`
);
}
}
} catch (e) {

View File

@@ -140,7 +140,7 @@ const ProjectCategorySection = ({ categories, form, t, disabled }: ProjectCatego
onChange={e => setCategoryText(e.currentTarget.value)}
allowClear
onClear={() => {
setIsAddCategoryInputShow(false)
setIsAddCategoryInputShow(false);
}}
onPressEnter={() => handleAddCategoryItem(categoryText)}
onBlur={() => handleAddCategoryInputBlur(categoryText)}

View File

@@ -76,14 +76,16 @@ const ProjectMemberDrawer = () => {
const res = await dispatch(addProjectMember({ memberId, projectId })).unwrap();
if (res.done) {
form.resetFields();
dispatch(getTeamMembers({
index: 1,
size: 5,
field: null,
order: null,
search: null,
all: true,
}));
dispatch(
getTeamMembers({
index: 1,
size: 5,
field: null,
order: null,
search: null,
all: true,
})
);
await fetchProjectMembers();
}
} catch (error) {
@@ -135,14 +137,16 @@ const ProjectMemberDrawer = () => {
if (res.done) {
form.resetFields();
await fetchProjectMembers();
dispatch(getTeamMembers({
index: 1,
size: 5,
field: null,
order: null,
search: null,
all: true,
}));
dispatch(
getTeamMembers({
index: 1,
size: 5,
field: null,
order: null,
search: null,
all: true,
})
);
}
} catch (error) {
logger.error('Error sending invite:', error);

View File

@@ -13,7 +13,6 @@ const OverviewReportsProjectsTab = ({ teamsId = null }: OverviewReportsProjectsT
const { t } = useTranslation('reporting-projects-drawer');
const [searchQuery, setSearchQuery] = useState('');
return (
<Flex vertical gap={24}>
<CustomSearchbar

View File

@@ -44,7 +44,7 @@ interface ReportingOverviewProjectsTableProps {
const ReportingOverviewProjectsTable = ({
searchQuery,
teamsId,
teamsId,
}: ReportingOverviewProjectsTableProps) => {
const dispatch = useAppDispatch();
const { t } = useTranslation('reporting-projects');

View File

@@ -132,7 +132,11 @@ const TimeWiseFilter = () => {
{t(item.label)}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{item.dates ? dayjs(item.dates.split(' - ')[0]).format('MMM DD, YYYY') + ' - ' + dayjs(item.dates.split(' - ')[1]).format('MMM DD, YYYY') : ''}
{item.dates
? dayjs(item.dates.split(' - ')[0]).format('MMM DD, YYYY') +
' - ' +
dayjs(item.dates.split(' - ')[1]).format('MMM DD, YYYY')
: ''}
</Typography.Text>
</List.Item>
))}

View File

@@ -13,7 +13,7 @@ import ProjectTimelineModal from '@/features/schedule/ProjectTimelineModal';
const GranttChart = React.forwardRef(({ type, date }: { type: string; date: Date }, ref) => {
const [expandedProject, setExpandedProject] = useState<string | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedProjectId, setSelectedProjectId] = useState<string|undefined>(undefined);
const [selectedProjectId, setSelectedProjectId] = useState<string | undefined>(undefined);
const { teamData } = useAppSelector(state => state.scheduleReducer);
const { dateList, loading, dayCount } = useAppSelector(state => state.scheduleReducer);
@@ -217,7 +217,13 @@ const GranttChart = React.forwardRef(({ type, date }: { type: string; date: Date
{expandedProject === member.id && (
<div>
<Popover
content={<ProjectTimelineModal memberId={member?.team_member_id} projectId={selectedProjectId} setIsModalOpen={setIsModalOpen} />}
content={
<ProjectTimelineModal
memberId={member?.team_member_id}
projectId={selectedProjectId}
setIsModalOpen={setIsModalOpen}
/>
}
trigger={'click'}
open={isModalOpen}
></Popover>

View File

@@ -2,7 +2,10 @@ import { Badge, Button, Flex, Tooltip } from 'antd';
import React, { useCallback } from 'react';
import { useAppSelector } from '@/hooks/useAppSelector';
import CustomAvatar from '../../CustomAvatar';
import { fetchMemberProjects, toggleScheduleDrawer } from '../../../features/schedule/scheduleSlice';
import {
fetchMemberProjects,
toggleScheduleDrawer,
} from '../../../features/schedule/scheduleSlice';
import { CaretDownOutlined, CaretRightFilled } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
@@ -37,12 +40,10 @@ const GranttMembersTable = React.memo(
const handleToggleProject = useCallback(
(id: string) => {
if(expandedProject != id) {
if (expandedProject != id) {
dispatch(fetchMemberProjects({ id }));
}
setExpandedProject(expandedProject === id ? null : id);
},
[expandedProject, setExpandedProject]
);

View File

@@ -68,7 +68,13 @@ const ProjectTimelineBar = ({
return (
<Popover
content={<ProjectTimelineModal defaultData={defaultData} projectId={project?.id} setIsModalOpen={setIsModalOpen} />}
content={
<ProjectTimelineModal
defaultData={defaultData}
projectId={project?.id}
setIsModalOpen={setIsModalOpen}
/>
}
trigger={'click'}
open={isModalOpen}
>
@@ -127,7 +133,10 @@ const ProjectTimelineBar = ({
align="center"
justify="center"
style={{ width: '100%' }}
onClick={() => {setIsModalOpen(true);dispatch(getWorking());}}
onClick={() => {
setIsModalOpen(true);
dispatch(getWorking());
}}
>
<Typography.Text
style={{

View File

@@ -269,4 +269,4 @@ const UpdateMemberDrawer = ({ selectedMemberId, onRoleUpdate }: UpdateMemberDraw
);
};
export default UpdateMemberDrawer;
export default UpdateMemberDrawer;

View File

@@ -4,7 +4,7 @@ import { Skeleton } from 'antd';
// Lightweight loading component with skeleton animation
export const SuspenseFallback = memo(() => {
return (
<div
<div
style={{
position: 'fixed',
top: 0,
@@ -20,8 +20,8 @@ export const SuspenseFallback = memo(() => {
}}
>
<div style={{ width: '100%', maxWidth: '400px' }}>
<Skeleton
active
<Skeleton
active
paragraph={{ rows: 3, width: ['100%', '80%', '60%'] }}
title={{ width: '70%' }}
/>
@@ -33,7 +33,7 @@ export const SuspenseFallback = memo(() => {
// Lightweight fallback for internal components that doesn't cover the screen
export const InlineSuspenseFallback = memo(() => {
return (
<div
<div
style={{
padding: '40px 20px',
display: 'flex',
@@ -43,11 +43,7 @@ export const InlineSuspenseFallback = memo(() => {
}}
>
<div style={{ width: '100%', maxWidth: '300px' }}>
<Skeleton
active
paragraph={{ rows: 2, width: ['100%', '70%'] }}
title={{ width: '60%' }}
/>
<Skeleton active paragraph={{ rows: 2, width: ['100%', '70%'] }} title={{ width: '60%' }} />
</div>
</div>
);

View File

@@ -22,9 +22,9 @@ const TaskDrawerActivityLog = () => {
const { mode: themeMode } = useAppSelector(state => state.themeReducer);
const { t } = useTranslation();
useEffect(()=>{
useEffect(() => {
fetchActivityLogs();
},[taskFormViewModel]);
}, [taskFormViewModel]);
const fetchActivityLogs = async () => {
if (!selectedTaskId) return;
@@ -59,7 +59,8 @@ const TaskDrawerActivityLog = () => {
name={activity.assigned_user?.name}
/>
<Typography.Text>{truncateText(activity.assigned_user?.name)}</Typography.Text>
<ArrowRightOutlined />&nbsp;
<ArrowRightOutlined />
&nbsp;
<Tag color={'default'}>{truncateText(activity.log_type?.toUpperCase())}</Tag>
</Flex>
);
@@ -67,8 +68,11 @@ const TaskDrawerActivityLog = () => {
case IActivityLogAttributeTypes.LABEL:
return (
<Flex gap={4} align="center">
<Tag color={activity.label_data?.color_code}>{truncateText(activity.label_data?.name)}</Tag>
<ArrowRightOutlined />&nbsp;
<Tag color={activity.label_data?.color_code}>
{truncateText(activity.label_data?.name)}
</Tag>
<ArrowRightOutlined />
&nbsp;
<Tag color={'default'}>{activity.log_type === 'create' ? 'ADD' : 'REMOVE'}</Tag>
</Flex>
);
@@ -76,11 +80,24 @@ const TaskDrawerActivityLog = () => {
case IActivityLogAttributeTypes.STATUS:
return (
<Flex gap={4} align="center">
<Tag color={themeMode === 'dark' ? activity.previous_status?.color_code_dark : activity.previous_status?.color_code}>
<Tag
color={
themeMode === 'dark'
? activity.previous_status?.color_code_dark
: activity.previous_status?.color_code
}
>
{truncateText(activity.previous_status?.name) || 'None'}
</Tag>
<ArrowRightOutlined />&nbsp;
<Tag color={themeMode === 'dark' ? activity.next_status?.color_code_dark : activity.next_status?.color_code}>
<ArrowRightOutlined />
&nbsp;
<Tag
color={
themeMode === 'dark'
? activity.next_status?.color_code_dark
: activity.next_status?.color_code
}
>
{truncateText(activity.next_status?.name) || 'None'}
</Tag>
</Flex>
@@ -89,11 +106,24 @@ const TaskDrawerActivityLog = () => {
case IActivityLogAttributeTypes.PRIORITY:
return (
<Flex gap={4} align="center">
<Tag color={themeMode === 'dark' ? activity.previous_priority?.color_code_dark : activity.previous_priority?.color_code}>
<Tag
color={
themeMode === 'dark'
? activity.previous_priority?.color_code_dark
: activity.previous_priority?.color_code
}
>
{truncateText(activity.previous_priority?.name) || 'None'}
</Tag>
<ArrowRightOutlined />&nbsp;
<Tag color={themeMode === 'dark' ? activity.next_priority?.color_code_dark : activity.next_priority?.color_code}>
<ArrowRightOutlined />
&nbsp;
<Tag
color={
themeMode === 'dark'
? activity.next_priority?.color_code_dark
: activity.next_priority?.color_code
}
>
{truncateText(activity.next_priority?.name) || 'None'}
</Tag>
</Flex>
@@ -105,36 +135,31 @@ const TaskDrawerActivityLog = () => {
<Tag color={activity.previous_phase?.color_code}>
{truncateText(activity.previous_phase?.name) || 'None'}
</Tag>
<ArrowRightOutlined />&nbsp;
<ArrowRightOutlined />
&nbsp;
<Tag color={activity.next_phase?.color_code}>
{truncateText(activity.next_phase?.name) || 'None'}
</Tag>
</Flex>
);
case IActivityLogAttributeTypes.PROGRESS:
return (
<Flex gap={4} align="center">
<Tag color="blue">
{activity.previous || '0'}%
</Tag>
<ArrowRightOutlined />&nbsp;
<Tag color="blue">
{activity.current || '0'}%
</Tag>
<Tag color="blue">{activity.previous || '0'}%</Tag>
<ArrowRightOutlined />
&nbsp;
<Tag color="blue">{activity.current || '0'}%</Tag>
</Flex>
);
case IActivityLogAttributeTypes.WEIGHT:
return (
<Flex gap={4} align="center">
<Tag color="purple">
Weight: {activity.previous || '100'}
</Tag>
<ArrowRightOutlined />&nbsp;
<Tag color="purple">
Weight: {activity.current || '100'}
</Tag>
<Tag color="purple">Weight: {activity.previous || '100'}</Tag>
<ArrowRightOutlined />
&nbsp;
<Tag color="purple">Weight: {activity.current || '100'}</Tag>
</Flex>
);
@@ -142,7 +167,8 @@ const TaskDrawerActivityLog = () => {
return (
<Flex gap={4} align="center">
<Tag color={'default'}>{truncateText(activity.previous) || 'None'}</Tag>
<ArrowRightOutlined />&nbsp;
<ArrowRightOutlined />
&nbsp;
<Tag color={'default'}>{truncateText(activity.current) || 'None'}</Tag>
</Flex>
);

View File

@@ -1,14 +1,20 @@
import { useState } from "react";
import { ITaskAttachmentViewModel } from "@/types/tasks/task-attachment-view-model";
import { Button, Modal, Spin, Tooltip, Typography, Popconfirm, message } from "antd";
import { EyeOutlined, DownloadOutlined, DeleteOutlined, QuestionCircleOutlined, LoadingOutlined } from "@ant-design/icons";
import { attachmentsApiService } from "@/api/attachments/attachments.api.service";
import { IconsMap } from "@/shared/constants";
import { useState } from 'react';
import { ITaskAttachmentViewModel } from '@/types/tasks/task-attachment-view-model';
import { Button, Modal, Spin, Tooltip, Typography, Popconfirm, message } from 'antd';
import {
EyeOutlined,
DownloadOutlined,
DeleteOutlined,
QuestionCircleOutlined,
LoadingOutlined,
} from '@ant-design/icons';
import { attachmentsApiService } from '@/api/attachments/attachments.api.service';
import { IconsMap } from '@/shared/constants';
import './attachments-preview.css';
import taskAttachmentsApiService from "@/api/tasks/task-attachments.api.service";
import logger from "@/utils/errorLogger";
import taskCommentsApiService from "@/api/tasks/task-comments.api.service";
import { useAppSelector } from "@/hooks/useAppSelector";
import taskAttachmentsApiService from '@/api/tasks/task-attachments.api.service';
import logger from '@/utils/errorLogger';
import taskCommentsApiService from '@/api/tasks/task-comments.api.service';
import { useAppSelector } from '@/hooks/useAppSelector';
interface AttachmentsPreviewProps {
attachment: ITaskAttachmentViewModel;
@@ -16,10 +22,10 @@ interface AttachmentsPreviewProps {
isCommentAttachment?: boolean;
}
const AttachmentsPreview = ({
attachment,
const AttachmentsPreview = ({
attachment,
onDelete,
isCommentAttachment = false
isCommentAttachment = false,
}: AttachmentsPreviewProps) => {
const { selectedTaskId } = useAppSelector(state => state.taskDrawerReducer);
const [deleting, setDeleting] = useState(false);
@@ -32,12 +38,12 @@ const AttachmentsPreview = ({
const [previewedFileName, setPreviewedFileName] = useState<string | null>(null);
const getFileIcon = (type?: string) => {
if (!type) return "search.png";
return IconsMap[type] || "search.png";
if (!type) return 'search.png';
return IconsMap[type] || 'search.png';
};
const isImageFile = (): boolean => {
const imageTypes = ["jpeg", "jpg", "bmp", "gif", "webp", "png", "ico"];
const imageTypes = ['jpeg', 'jpg', 'bmp', 'gif', 'webp', 'png', 'ico'];
const type = attachment?.type;
if (type) return imageTypes.includes(type);
return false;
@@ -53,12 +59,12 @@ const AttachmentsPreview = ({
if (!id || !name) return;
try {
setDownloading(true);
const api = isCommentAttachment
const api = isCommentAttachment
? attachmentsApiService.downloadAttachment // Assuming this exists, adjust as needed
: attachmentsApiService.downloadAttachment;
const res = await api(id, name);
if (res && res.done) {
const link = document.createElement('a');
link.href = res.body || '';
@@ -126,7 +132,7 @@ const AttachmentsPreview = ({
if (!extension) return;
setIsVisible(true);
if (isImage(extension)) {
setCurrentFileType('image');
} else if (isVideo(extension)) {
@@ -149,9 +155,7 @@ const AttachmentsPreview = ({
<>
<div className="ant-upload-list-picture-card-container">
{attachment && (
<div
className="ant-upload-list-item ant-upload-list-item-done ant-upload-list-item-list-type-picture-card"
>
<div className="ant-upload-list-item ant-upload-list-item-done ant-upload-list-item-list-type-picture-card">
<Tooltip
title={
<div>
@@ -165,26 +169,26 @@ const AttachmentsPreview = ({
placement="bottom"
>
<div className="ant-upload-list-item-info">
<img
src={`/file-types/${getFileIcon(attachment.type)}`}
className="file-icon"
alt=""
<img
src={`/file-types/${getFileIcon(attachment.type)}`}
className="file-icon"
alt=""
/>
<div
className="ant-upload-span"
style={{
backgroundImage: isImageFile() ? `url(${attachment.url})` : ''
<div
className="ant-upload-span"
style={{
backgroundImage: isImageFile() ? `url(${attachment.url})` : '',
}}
>
<a
target="_blank"
<a
target="_blank"
rel="noopener noreferrer"
className="ant-upload-list-item-thumbnail"
href={attachment.url}
>
{!isImageFile() && (
<span
className="anticon anticon-file-unknown"
<span
className="anticon anticon-file-unknown"
style={{ fontSize: 34, color: '#cccccc' }}
/>
)}
@@ -194,18 +198,18 @@ const AttachmentsPreview = ({
</Tooltip>
<span className="ant-upload-list-item-actions">
<Button
type="text"
<Button
type="text"
size="small"
title="Preview file"
title="Preview file"
onClick={() => previewFile(attachment.url, attachment.id, attachment.name)}
className="ant-upload-list-item-card-actions-btn"
>
<EyeOutlined />
</Button>
<Button
type="text"
<Button
type="text"
size="small"
title="Download file"
onClick={() => download(attachment.id, attachment.name)}
@@ -223,8 +227,8 @@ const AttachmentsPreview = ({
okText="Yes"
cancelText="No"
>
<Button
type="text"
<Button
type="text"
size="small"
title="Remove file"
loading={deleting}
@@ -247,23 +251,23 @@ const AttachmentsPreview = ({
className="attachment-preview-modal"
footer={[
previewedFileId && previewedFileName && (
<Button
<Button
key="download"
onClick={() => download(previewedFileId, previewedFileName)}
onClick={() => download(previewedFileId, previewedFileName)}
loading={downloading}
>
<DownloadOutlined /> Download
</Button>
)
),
]}
>
<div className="preview-container text-center position-relative">
<div className="preview-container text-center position-relative">
{currentFileType === 'image' && (
<>
<img src={currentFileUrl || ''} className="img-fluid position-relative" alt="" />
</>
)}
{currentFileType === 'video' && (
<>
<video className="position-relative" controls>
@@ -271,7 +275,7 @@ const AttachmentsPreview = ({
</video>
</>
)}
{currentFileType === 'audio' && (
<>
<audio className="position-relative" controls>
@@ -279,23 +283,21 @@ const AttachmentsPreview = ({
</audio>
</>
)}
{currentFileType === 'document' && (
<>
{currentFileUrl && (
<iframe
<iframe
src={`https://docs.google.com/viewer?url=${encodeURIComponent(currentFileUrl)}&embedded=true`}
width="100%"
height="500px"
width="100%"
height="500px"
style={{ border: 'none' }}
/>
)}
</>
)}
{currentFileType === 'unknown' && (
<p>The preview for this file type is not available.</p>
)}
{currentFileType === 'unknown' && <p>The preview for this file type is not available.</p>}
</div>
</Modal>
</>

View File

@@ -5,7 +5,7 @@
.ant-upload-list.focused {
border-color: #1890ff;
background-color: #FAFAFA;
background-color: #fafafa;
}
.ant-upload-select-picture-card {
@@ -36,4 +36,4 @@
justify-content: center;
width: 100%;
height: 100%;
}
}

View File

@@ -1,7 +1,7 @@
import { LoadingOutlined, PlusOutlined } from '@ant-design/icons';
import React, { useRef, useState } from 'react';
import { TFunction } from 'i18next';
import './attachments-upload.css';
import './attachments-upload.css';
import { useAppSelector } from '@/hooks/useAppSelector';
interface AttachmentsUploadProps {
@@ -11,11 +11,11 @@ interface AttachmentsUploadProps {
onFilesSelected: (files: File[]) => void;
}
const AttachmentsUpload = ({
t,
loadingTask,
uploading,
onFilesSelected
const AttachmentsUpload = ({
t,
loadingTask,
uploading,
onFilesSelected,
}: AttachmentsUploadProps) => {
const fileInputRef = useRef<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
@@ -46,7 +46,7 @@ const AttachmentsUpload = ({
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
setIsDragOver(false);
if (!loadingTask && !uploading && e.dataTransfer.files.length > 0) {
const filesArray = Array.from(e.dataTransfer.files);
onFilesSelected(filesArray);
@@ -54,41 +54,45 @@ const AttachmentsUpload = ({
};
return (
<div
<div
className={`ant-upload-list ant-upload-list-picture-card ${isDragOver ? 'focused' : ''}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
>
<div className="ant-upload ant-upload-select ant-upload-select-picture-card">
<div
className="ant-upload"
tabIndex={0}
<div
className="ant-upload"
tabIndex={0}
role="button"
onClick={handleClick}
style={{
backgroundColor: themeMode === 'dark' ? '#292929' : '#fafafa',
}}
>
<input
type="file"
<input
type="file"
ref={fileInputRef}
style={{ display: 'none' }}
style={{ display: 'none' }}
onChange={handleFileChange}
disabled={loadingTask || uploading}
multiple
/>
<div>
{uploading ? <LoadingOutlined spin /> : <PlusOutlined />}
<div style={{
marginTop: '8px',
fontSize: '11px',
marginLeft: 'auto',
marginRight: 'auto',
paddingLeft: '8px',
paddingRight: '8px'
}}>
{uploading ? t('taskInfoTab.attachments.uploading') : t('taskInfoTab.attachments.chooseOrDropFileToUpload')}
<div
style={{
marginTop: '8px',
fontSize: '11px',
marginLeft: 'auto',
marginRight: 'auto',
paddingLeft: '8px',
paddingRight: '8px',
}}
>
{uploading
? t('taskInfoTab.attachments.uploading')
: t('taskInfoTab.attachments.chooseOrDropFileToUpload')}
</div>
</div>
</div>
@@ -97,4 +101,4 @@ const AttachmentsUpload = ({
);
};
export default AttachmentsUpload;
export default AttachmentsUpload;

View File

@@ -39,7 +39,7 @@
.focused {
border-color: #1890ff;
background-color: #FAFAFA;
background-color: #fafafa;
}
/* Conversation-like styles */
@@ -250,7 +250,7 @@
}
.comment-time-separator::before {
content: '';
content: "";
position: absolute;
left: 0;
top: 50%;

View File

@@ -61,7 +61,7 @@ const processMentions = (content: string) => {
const linkify = (text: string) => {
if (!text) return '';
// Regex to match URLs (http, https, www)
return text.replace(/(https?:\/\/[^\s]+|www\.[^\s]+)/g, (url) => {
return text.replace(/(https?:\/\/[^\s]+|www\.[^\s]+)/g, url => {
let href = url;
if (!href.startsWith('http')) {
href = 'http://' + href;
@@ -83,7 +83,7 @@ const processContent = (content: string) => {
return sanitizeHtml(processed);
};
const TaskComments = ({ taskId, t }: { taskId?: string, t: TFunction }) => {
const TaskComments = ({ taskId, t }: { taskId?: string; t: TFunction }) => {
const [loading, setLoading] = useState(true);
const [comments, setComments] = useState<ITaskCommentViewModel[]>([]);
const commentsViewRef = useRef<HTMLDivElement>(null);
@@ -290,9 +290,7 @@ const TaskComments = ({ taskId, t }: { taskId?: string, t: TFunction }) => {
key={item.id}
author={<span style={authorStyle}>{item.member_name}</span>}
datetime={<span style={dateStyle}>{fromNow(item.created_at || '')}</span>}
avatar={
<SingleAvatar name={item.member_name} avatarUrl={item.avatar_url}/>
}
avatar={<SingleAvatar name={item.member_name} avatarUrl={item.avatar_url} />}
content={
item.edit ? (
<TaskViewCommentEdit commentData={item} onUpdated={commentUpdated} />

View File

@@ -15,10 +15,10 @@ interface TaskViewCommentEditProps {
// Helper function to prepare content for editing by removing HTML tags
const prepareContentForEditing = (content: string): string => {
if (!content) return '';
// Replace mention spans with plain @mentions
const withoutMentionSpans = content.replace(/<span class="mentions">@(\w+)<\/span>/g, '@$1');
// Remove any other HTML tags
return withoutMentionSpans.replace(/<[^>]*>/g, '');
};
@@ -27,7 +27,7 @@ const TaskViewCommentEdit = ({ commentData, onUpdated }: TaskViewCommentEditProp
const themeMode = useAppSelector(state => state.themeReducer.mode);
const [loading, setLoading] = useState(false);
const [content, setContent] = useState('');
// Initialize content when component mounts
useEffect(() => {
if (commentData.content) {
@@ -42,18 +42,18 @@ const TaskViewCommentEdit = ({ commentData, onUpdated }: TaskViewCommentEditProp
const handleSave = async () => {
if (!commentData.id || !commentData.task_id) return;
try {
setLoading(true);
const res = await taskCommentsApiService.update(commentData.id, {
...commentData,
content: content,
});
if (res.done) {
commentData.content = content;
onUpdated(commentData);
// Dispatch event to notify that a comment was updated
document.dispatchEvent(new Event('task-comment-update'));
}
@@ -77,7 +77,7 @@ const TaskViewCommentEdit = ({ commentData, onUpdated }: TaskViewCommentEditProp
<Form.Item>
<Input.TextArea
value={content}
onChange={(e) => setContent(e.target.value)}
onChange={e => setContent(e.target.value)}
autoSize={{ minRows: 3, maxRows: 6 }}
style={textAreaStyle}
placeholder="Type your comment here... Use @username to mention someone"
@@ -96,4 +96,4 @@ const TaskViewCommentEdit = ({ commentData, onUpdated }: TaskViewCommentEditProp
);
};
export default TaskViewCommentEdit;
export default TaskViewCommentEdit;

View File

@@ -6,4 +6,4 @@
.custom-two-colors-row-table .ant-table-row:hover .dependency-actions {
opacity: 1;
}
}

View File

@@ -112,7 +112,13 @@ const DependenciesTable = ({
render: record => (
<Select
value={record.dependency_type}
options={[{ key: IDependencyType.BLOCKED_BY, value: IDependencyType.BLOCKED_BY, label: 'Blocked By' }]}
options={[
{
key: IDependencyType.BLOCKED_BY,
value: IDependencyType.BLOCKED_BY,
label: 'Blocked By',
},
]}
size="small"
/>
),
@@ -176,7 +182,13 @@ const DependenciesTable = ({
<Col span={6}>
<Form.Item name="blockedBy" style={{ marginBottom: 0 }}>
<Select
options={[{ key: IDependencyType.BLOCKED_BY, value: IDependencyType.BLOCKED_BY, label: 'Blocked By' }]}
options={[
{
key: IDependencyType.BLOCKED_BY,
value: IDependencyType.BLOCKED_BY,
label: 'Blocked By',
},
]}
size="small"
disabled
/>

View File

@@ -31,17 +31,20 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
link.as = 'script';
document.head.appendChild(link);
};
preloadTinyMCE();
}, []);
const handleDescriptionChange = () => {
if (!taskId) return;
socket?.emit(SocketEvents.TASK_DESCRIPTION_CHANGE.toString(), JSON.stringify({
task_id: taskId,
description: content || null,
parent_task: parentTaskId,
}));
socket?.emit(
SocketEvents.TASK_DESCRIPTION_CHANGE.toString(),
JSON.stringify({
task_id: taskId,
description: content || null,
parent_task: parentTaskId,
})
);
};
useEffect(() => {
@@ -51,7 +54,9 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
const isClickedInsideWrapper = wrapper && wrapper.contains(target);
const isClickedInsideEditor = document.querySelector('.tox-tinymce')?.contains(target);
const isClickedInsideToolbarPopup = document.querySelector('.tox-menu, .tox-pop, .tox-collection')?.contains(target);
const isClickedInsideToolbarPopup = document
.querySelector('.tox-menu, .tox-pop, .tox-collection')
?.contains(target);
if (
isEditorOpen &&
@@ -96,7 +101,9 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
setIsEditorLoading(true);
};
const darkModeStyles = themeMode === 'dark' ? `
const darkModeStyles =
themeMode === 'dark'
? `
body {
background-color: #1e1e1e !important;
color: #ffffff !important;
@@ -104,27 +111,33 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
body.mce-content-body[data-mce-placeholder]:not([contenteditable="false"]):before {
color: #666666 !important;
}
` : '';
`
: '';
return (
<div ref={wrapperRef}>
{isEditorOpen ? (
<div style={{
minHeight: '200px',
backgroundColor: themeMode === 'dark' ? '#1e1e1e' : '#ffffff'
}}>
<div
style={{
minHeight: '200px',
backgroundColor: themeMode === 'dark' ? '#1e1e1e' : '#ffffff',
}}
>
{isEditorLoading && (
<div style={{
position: 'absolute',
zIndex: 10,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '200px',
backgroundColor: themeMode === 'dark' ? 'rgba(30, 30, 30, 0.8)' : 'rgba(255, 255, 255, 0.8)',
color: themeMode === 'dark' ? '#ffffff' : '#000000'
}}>
<div
style={{
position: 'absolute',
zIndex: 10,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '200px',
backgroundColor:
themeMode === 'dark' ? 'rgba(30, 30, 30, 0.8)' : 'rgba(255, 255, 255, 0.8)',
color: themeMode === 'dark' ? '#ffffff' : '#000000',
}}
>
<div>Loading editor...</div>
</div>
)}
@@ -138,11 +151,25 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
menubar: false,
branding: false,
plugins: [
'advlist', 'autolink', 'lists', 'link', 'charmap', 'preview',
'anchor', 'searchreplace', 'visualblocks', 'code', 'fullscreen',
'insertdatetime', 'media', 'table', 'code', 'wordcount' // Added wordcount
'advlist',
'autolink',
'lists',
'link',
'charmap',
'preview',
'anchor',
'searchreplace',
'visualblocks',
'code',
'fullscreen',
'insertdatetime',
'media',
'table',
'code',
'wordcount', // Added wordcount
],
toolbar: 'blocks |' +
toolbar:
'blocks |' +
'bold italic underline strikethrough | ' +
'bullist numlist | link | removeformat | help',
content_style: `
@@ -157,30 +184,34 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
skin_url: `/tinymce/skins/ui/${themeMode === 'dark' ? 'oxide-dark' : 'oxide'}`,
content_css_cors: true,
auto_focus: true,
init_instance_callback: (editor) => {
editor.dom.setStyle(editor.getBody(), 'backgroundColor', themeMode === 'dark' ? '#1e1e1e' : '#ffffff');
}
init_instance_callback: editor => {
editor.dom.setStyle(
editor.getBody(),
'backgroundColor',
themeMode === 'dark' ? '#1e1e1e' : '#ffffff'
);
},
}}
onEditorChange={handleEditorChange}
/>
</div>
) : (
<div
<div
onClick={handleOpenEditor}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
style={{
minHeight: '32px',
style={{
minHeight: '32px',
padding: '4px 11px',
border: `1px solid ${isHovered ? (themeMode === 'dark' ? '#177ddc' : '#40a9ff') : 'transparent'}`,
borderRadius: '6px',
cursor: 'pointer',
color: themeMode === 'dark' ? '#ffffff' : '#000000',
transition: 'border-color 0.3s ease'
transition: 'border-color 0.3s ease',
}}
>
{content ? (
<div
<div
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(content) }}
style={{ color: 'inherit' }}
/>
@@ -195,4 +226,4 @@ const DescriptionEditor = ({ description, taskId, parentTaskId }: DescriptionEdi
);
};
export default DescriptionEditor;
export default DescriptionEditor;

View File

@@ -86,17 +86,17 @@ const TaskDrawerAssigneeSelector = ({ task }: TaskDrawerAssigneeSelectorProps) =
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body));
socket?.once(
SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(),
(data: ITaskAssigneesUpdateResponse) => {
dispatch(setTaskAssignee(data));
if (tab === 'tasks-list') {
dispatch(updateTasksListTaskAssignees(data));
}
if (tab === 'board') {
dispatch(updateEnhancedKanbanTaskAssignees(data));
}
}
);
SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(),
(data: ITaskAssigneesUpdateResponse) => {
dispatch(setTaskAssignee(data));
if (tab === 'tasks-list') {
dispatch(updateTasksListTaskAssignees(data));
}
if (tab === 'board') {
dispatch(updateEnhancedKanbanTaskAssignees(data));
}
}
);
} catch (error) {
console.error('Error updating assignee:', error);
}
@@ -148,16 +148,16 @@ const TaskDrawerAssigneeSelector = ({ task }: TaskDrawerAssigneeSelectorProps) =
/>
</div>
<Flex vertical>
<Typography.Text>{member.name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{member.email}&nbsp;
{member.pending_invitation && (
<Typography.Text type="danger" style={{ fontSize: 10 }}>
({t('pendingInvitation')})
</Typography.Text>
)}
</Typography.Text>
</Flex>
<Typography.Text>{member.name}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{member.email}&nbsp;
{member.pending_invitation && (
<Typography.Text type="danger" style={{ fontSize: 10 }}>
({t('pendingInvitation')})
</Typography.Text>
)}
</Typography.Text>
</Flex>
</List.Item>
))
) : (

View File

@@ -14,7 +14,10 @@ import { ITaskViewModel } from '@/types/tasks/task.types';
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { setStartDate, setTaskEndDate } from '@/features/task-drawer/task-drawer.slice';
import { updateEnhancedKanbanTaskStartDate, updateEnhancedKanbanTaskEndDate } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import {
updateEnhancedKanbanTaskStartDate,
updateEnhancedKanbanTaskEndDate,
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
import useTabSearchParam from '@/hooks/useTabSearchParam';
interface TaskDrawerDueDateProps {
task: ITaskViewModel;
@@ -61,18 +64,15 @@ const TaskDrawerDueDate = ({ task, t, form }: TaskDrawerDueDateProps) => {
: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
);
socket?.once(
SocketEvents.TASK_START_DATE_CHANGE.toString(),
(data: IProjectTask) => {
dispatch(setStartDate(data));
// Also update enhanced kanban if on board tab
if (tab === 'board') {
dispatch(updateEnhancedKanbanTaskStartDate({ task: data }));
}
}
);
} catch (error) {
socket?.once(SocketEvents.TASK_START_DATE_CHANGE.toString(), (data: IProjectTask) => {
dispatch(setStartDate(data));
// Also update enhanced kanban if on board tab
if (tab === 'board') {
dispatch(updateEnhancedKanbanTaskStartDate({ task: data }));
}
});
} catch (error) {
logger.error('Failed to update start date:', error);
}
};
@@ -90,17 +90,14 @@ const TaskDrawerDueDate = ({ task, t, form }: TaskDrawerDueDateProps) => {
: Intl.DateTimeFormat().resolvedOptions().timeZone,
})
);
socket?.once(
SocketEvents.TASK_END_DATE_CHANGE.toString(),
(data: IProjectTask) => {
dispatch(setTaskEndDate(data));
// Also update enhanced kanban if on board tab
if (tab === 'board') {
dispatch(updateEnhancedKanbanTaskEndDate({ task: data }));
}
socket?.once(SocketEvents.TASK_END_DATE_CHANGE.toString(), (data: IProjectTask) => {
dispatch(setTaskEndDate(data));
// Also update enhanced kanban if on board tab
if (tab === 'board') {
dispatch(updateEnhancedKanbanTaskEndDate({ task: data }));
}
);
});
} catch (error) {
logger.error('Failed to update due date:', error);
}
@@ -134,7 +131,9 @@ const TaskDrawerDueDate = ({ task, t, form }: TaskDrawerDueDateProps) => {
onClick={() => setIsShowStartDate(prev => !prev)}
style={{ color: isShowStartDate ? 'red' : colors.skyBlue }}
>
{isShowStartDate ? t('taskInfoTab.details.hide-start-date') : t('taskInfoTab.details.show-start-date')}
{isShowStartDate
? t('taskInfoTab.details.hide-start-date')
: t('taskInfoTab.details.show-start-date')}
</Button>
</Flex>
</Form.Item>

View File

@@ -19,11 +19,11 @@ const TaskDrawerEstimation = ({ t, task, form }: TaskDrawerEstimationProps) => {
const handleTimeEstimationBlur = (e: React.FocusEvent<HTMLInputElement>) => {
if (!connected || !task.id) return;
// Get current form values instead of using state
const currentHours = form.getFieldValue('hours') || 0;
const currentMinutes = form.getFieldValue('minutes') || 0;
socket?.emit(
SocketEvents.TASK_TIME_ESTIMATION_CHANGE.toString(),
JSON.stringify({
@@ -52,7 +52,7 @@ const TaskDrawerEstimation = ({ t, task, form }: TaskDrawerEstimationProps) => {
<InputNumber
min={0}
max={24}
placeholder={t('taskInfoTab.details.hours')}
placeholder={t('taskInfoTab.details.hours')}
onBlur={handleTimeEstimationBlur}
onChange={value => setHours(value || 0)}
/>

View File

@@ -58,18 +58,15 @@ const TaskDrawerLabels = ({ task, t }: TaskDrawerLabelsProps) => {
team_id: currentSession?.team_id,
};
socket?.emit(SocketEvents.TASK_LABELS_CHANGE.toString(), JSON.stringify(labelData));
socket?.once(
SocketEvents.TASK_LABELS_CHANGE.toString(),
(data: ILabelsChangeResponse) => {
dispatch(setTaskLabels(data));
if (tab === 'tasks-list') {
dispatch(updateTaskLabel(data));
}
if (tab === 'board') {
dispatch(updateEnhancedKanbanTaskLabels(data));
}
socket?.once(SocketEvents.TASK_LABELS_CHANGE.toString(), (data: ILabelsChangeResponse) => {
dispatch(setTaskLabels(data));
if (tab === 'tasks-list') {
dispatch(updateTaskLabel(data));
}
);
if (tab === 'board') {
dispatch(updateEnhancedKanbanTaskLabels(data));
}
});
} catch (error) {
console.error('Error changing label:', error);
}
@@ -84,18 +81,15 @@ const TaskDrawerLabels = ({ task, t }: TaskDrawerLabelsProps) => {
team_id: currentSession?.team_id,
};
socket?.emit(SocketEvents.CREATE_LABEL.toString(), JSON.stringify(labelData));
socket?.once(
SocketEvents.CREATE_LABEL.toString(),
(data: ILabelsChangeResponse) => {
dispatch(setTaskLabels(data));
if (tab === 'tasks-list') {
dispatch(updateTaskLabel(data));
}
if (tab === 'board') {
dispatch(updateEnhancedKanbanTaskLabels(data));
}
}
);
socket?.once(SocketEvents.CREATE_LABEL.toString(), (data: ILabelsChangeResponse) => {
dispatch(setTaskLabels(data));
if (tab === 'tasks-list') {
dispatch(updateTaskLabel(data));
}
if (tab === 'board') {
dispatch(updateEnhancedKanbanTaskLabels(data));
}
});
};
useEffect(() => {
@@ -119,57 +113,57 @@ const TaskDrawerLabels = ({ task, t }: TaskDrawerLabelsProps) => {
onChange={e => setSearchQuery(e.currentTarget.value)}
placeholder={t('taskInfoTab.labels.labelInputPlaceholder')}
onKeyDown={e => {
const isLabel = filteredLabelData.findIndex(
label => label.name?.toLowerCase() === searchQuery.toLowerCase()
);
if (isLabel === -1) {
if (e.key === 'Enter') {
handleCreateLabel();
setSearchQuery('');
}
}
const isLabel = filteredLabelData.findIndex(
label => label.name?.toLowerCase() === searchQuery.toLowerCase()
);
if (isLabel === -1) {
if (e.key === 'Enter') {
handleCreateLabel();
setSearchQuery('');
}
}
}}
/>
<List style={{ padding: 0, maxHeight: 300, overflow: 'scroll' }}>
{filteredLabelData.length ? (
filteredLabelData.map(label => (
<List.Item
className={themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'}
key={label.id}
style={{
display: 'flex',
justifyContent: 'flex-start',
gap: 8,
padding: '4px 8px',
border: 'none',
cursor: 'pointer',
}}
onClick={() => handleLabelChange(label)}
>
<Checkbox
id={label.id}
checked={
task?.labels
? task?.labels.some(existingLabel => existingLabel.id === label.id)
: false
}
onChange={e => e.preventDefault()}
>
<Flex gap={8}>
<Badge color={label.color_code} />
{label.name}
</Flex>
</Checkbox>
</List.Item>
))
filteredLabelData.map(label => (
<List.Item
className={themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'}
key={label.id}
style={{
display: 'flex',
justifyContent: 'flex-start',
gap: 8,
padding: '4px 8px',
border: 'none',
cursor: 'pointer',
}}
onClick={() => handleLabelChange(label)}
>
<Checkbox
id={label.id}
checked={
task?.labels
? task?.labels.some(existingLabel => existingLabel.id === label.id)
: false
}
onChange={e => e.preventDefault()}
>
<Flex gap={8}>
<Badge color={label.color_code} />
{label.name}
</Flex>
</Checkbox>
</List.Item>
))
) : (
<Typography.Text
style={{ color: colors.lightGray }}
onClick={() => handleCreateLabel()}
>
{t('taskInfoTab.labels.labelsSelectorInputTip')}
</Typography.Text>
<Typography.Text
style={{ color: colors.lightGray }}
onClick={() => handleCreateLabel()}
>
{t('taskInfoTab.labels.labelsSelectorInputTip')}
</Typography.Text>
)}
</List>
</Flex>

View File

@@ -14,7 +14,7 @@ interface TaskDrawerPhaseSelectorProps {
const TaskDrawerPhaseSelector = ({ phases, task }: TaskDrawerPhaseSelectorProps) => {
const { socket, connected } = useSocket();
const phaseMenuItems = phases?.map(phase => ({
key: phase.id,
value: phase.id,
@@ -25,7 +25,7 @@ const TaskDrawerPhaseSelector = ({ phases, task }: TaskDrawerPhaseSelectorProps)
socket?.emit(SocketEvents.TASK_PHASE_CHANGE.toString(), {
task_id: task.id,
phase_id: value,
parent_task: task.parent_task_id || null
parent_task: task.parent_task_id || null,
});
// socket?.once(SocketEvents.TASK_PHASE_CHANGE.toString(), () => {

View File

@@ -29,7 +29,7 @@ const PriorityDropdown = ({ task }: PriorityDropdownProps) => {
const currentSession = useAuthService().getCurrentSession();
const dispatch = useAppDispatch();
const { tab } = useTabSearchParam();
const handlePriorityChange = (priorityId: string) => {
if (!task.id || !priorityId) return;
@@ -93,7 +93,7 @@ const PriorityDropdown = ({ task }: PriorityDropdownProps) => {
return (
<>
{(
{
<Select
value={task?.priority_id}
onChange={handlePriorityChange}
@@ -105,10 +105,9 @@ const PriorityDropdown = ({ task }: PriorityDropdownProps) => {
? selectedPriority?.color_code_dark
: selectedPriority?.color_code + ALPHA_CHANNEL,
}}
options={options}
/>
)}
}
</>
);
};

View File

@@ -14,7 +14,10 @@ import { setTaskStatus } from '@/features/task-drawer/task-drawer.slice';
import { useAppDispatch } from '@/hooks/useAppDispatch';
import { updateBoardTaskStatus } from '@/features/board/board-slice';
import { updateTaskProgress, updateTaskStatus } from '@/features/tasks/tasks.slice';
import { updateEnhancedKanbanTaskStatus, updateEnhancedKanbanTaskProgress } from '@/features/enhanced-kanban/enhanced-kanban.slice';
import {
updateEnhancedKanbanTaskStatus,
updateEnhancedKanbanTaskProgress,
} from '@/features/enhanced-kanban/enhanced-kanban.slice';
import useTabSearchParam from '@/hooks/useTabSearchParam';
interface TaskDrawerProgressProps {
@@ -325,7 +328,12 @@ const TaskDrawerProgress = ({ task, form }: TaskDrawerProgressProps) => {
okText={t('taskProgress.confirmMarkAsDone', 'Yes, mark as done')}
cancelText={t('taskProgress.cancelMarkAsDone', 'No, keep current status')}
>
<p>{t('taskProgress.markAsDoneDescription', 'You\'ve set the progress to 100%. Would you like to update the task status to "Done"?')}</p>
<p>
{t(
'taskProgress.markAsDoneDescription',
'You\'ve set the progress to 100%. Would you like to update the task status to "Done"?'
)}
</p>
</Modal>
</>
);

View File

@@ -15,7 +15,12 @@ import {
import { SettingOutlined } from '@ant-design/icons';
import { useSocket } from '@/socket/socketContext';
import { SocketEvents } from '@/shared/socket-events';
import { IRepeatOption, ITaskRecurring, ITaskRecurringSchedule, ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule';
import {
IRepeatOption,
ITaskRecurring,
ITaskRecurringSchedule,
ITaskRecurringScheduleData,
} from '@/types/tasks/task-recurring-schedule';
import { ITaskViewModel } from '@/types/tasks/task.types';
import { useTranslation } from 'react-i18next';
import { useAppDispatch } from '@/hooks/useAppDispatch';
@@ -47,7 +52,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
{ label: t('wed'), value: 3, checked: false },
{ label: t('thu'), value: 4, checked: false },
{ label: t('fri'), value: 5, checked: false },
{ label: t('sat'), value: 6, checked: false }
{ label: t('sat'), value: 6, checked: false },
];
const weekOptions = [
@@ -55,7 +60,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
{ label: t('second'), value: 2 },
{ label: t('third'), value: 3 },
{ label: t('fourth'), value: 4 },
{ label: t('last'), value: 5 }
{ label: t('last'), value: 5 },
];
const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value }));
@@ -91,7 +96,9 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
if (selected) setRepeatOption(selected);
}
dispatch(updateRecurringChange(schedule));
dispatch(setTaskRecurringSchedule({ schedule_id: schedule.id as string, task_id: task.id }));
dispatch(
setTaskRecurringSchedule({ schedule_id: schedule.id as string, task_id: task.id })
);
setRecurring(checked);
if (!checked) setShowConfig(false);
@@ -114,16 +121,16 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
const getSelectedDays = () => {
return daysOfWeek
.filter(day => day.checked) // Get only the checked days
.map(day => day.value); // Extract their numeric values
}
.filter(day => day.checked) // Get only the checked days
.map(day => day.value); // Extract their numeric values
};
const getUpdateBody = () => {
if (!task.id || !task.schedule_id || !repeatOption.value) return;
const body: ITaskRecurringSchedule = {
id: task.id,
schedule_type: repeatOption.value
schedule_type: repeatOption.value,
};
switch (repeatOption.value) {
@@ -156,7 +163,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
break;
}
return body;
}
};
const handleSave = async () => {
if (!task.id || !task.schedule_id) return;
@@ -172,7 +179,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
configVisibleChange(false);
}
} catch (e) {
logger.error("handleSave", e);
logger.error('handleSave', e);
} finally {
setUpdatingData(false);
}
@@ -207,14 +214,13 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
updateDaysOfWeek();
}
}
};
}
} catch (e) {
logger.error("getScheduleData", e);
}
finally {
logger.error('getScheduleData', e);
} finally {
setLoadingData(false);
}
}
};
const handleResponse = (response: ITaskRecurringScheduleData) => {
if (!task || !response.task_id) return;
@@ -259,7 +265,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
<Checkbox.Group
options={daysOfWeek.map(day => ({
label: day.label,
value: day.value
value: day.value,
}))}
value={selectedDays}
onChange={handleDayCheckboxChange}

View File

@@ -51,7 +51,9 @@ const InfoTabFooter = () => {
const [members, setMembers] = useState<ITeamMember[]>([]);
const [membersLoading, setMembersLoading] = useState<boolean>(false);
const [selectedMembers, setSelectedMembers] = useState<{ team_member_id: string; name: string }[]>([]);
const [selectedMembers, setSelectedMembers] = useState<
{ team_member_id: string; name: string }[]
>([]);
const [commentValue, setCommentValue] = useState<string>('');
const [uploading, setUploading] = useState<boolean>(false);
@@ -102,20 +104,23 @@ const InfoTabFooter = () => {
key: member.id,
})) ?? [];
const memberSelectHandler = useCallback((member: IMentionMemberSelectOption) => {
if (!member?.value || !member?.label) return;
// Find the member ID from the members list using the name
const selectedMember = members.find(m => m.name === member.value);
if (!selectedMember) return;
// Add to selected members if not already present
setSelectedMembers(prev =>
prev.some(mention => mention.team_member_id === selectedMember.id)
? prev
: [...prev, { team_member_id: selectedMember.id!, name: selectedMember.name! }]
);
}, [members]);
const memberSelectHandler = useCallback(
(member: IMentionMemberSelectOption) => {
if (!member?.value || !member?.label) return;
// Find the member ID from the members list using the name
const selectedMember = members.find(m => m.name === member.value);
if (!selectedMember) return;
// Add to selected members if not already present
setSelectedMembers(prev =>
prev.some(mention => mention.team_member_id === selectedMember.id)
? prev
: [...prev, { team_member_id: selectedMember.id!, name: selectedMember.name! }]
);
},
[members]
);
const handleCommentChange = useCallback((value: string) => {
setCommentValue(value);
@@ -149,7 +154,7 @@ const InfoTabFooter = () => {
setAttachmentComment(false);
setIsCommentBoxExpand(false);
setCommentValue('');
// Dispatch event to notify that a comment was created
// This will trigger scrolling to the new comment
document.dispatchEvent(new Event('task-comment-create'));

View File

@@ -98,12 +98,9 @@ const NotifyMemberSelector = ({ task, t }: NotifyMemberSelectorProps) => {
mode: checked ? 0 : 1,
};
socket?.emit(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), body);
socket?.once(
SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(),
(data: InlineMember[]) => {
dispatch(setTaskSubscribers(data));
}
);
socket?.once(SocketEvents.TASK_SUBSCRIBERS_CHANGE.toString(), (data: InlineMember[]) => {
dispatch(setTaskSubscribers(data));
});
} catch (error) {
logger.error('Error notifying member:', error);
}

View File

@@ -16,4 +16,4 @@
.task-dependency:hover .task-dependency-actions {
opacity: 1;
}
}

View File

@@ -24,10 +24,10 @@ import {
} from '@/features/tasks/tasks.slice';
import useTabSearchParam from '@/hooks/useTabSearchParam';
import logger from '@/utils/errorLogger';
import {
setShowTaskDrawer,
setSelectedTaskId,
fetchTask
import {
setShowTaskDrawer,
setSelectedTaskId,
fetchTask,
} from '@/features/task-drawer/task-drawer.slice';
import { updateSubtask } from '@/features/board/board-slice';
@@ -53,7 +53,7 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
const createRequestBody = (taskName: string): ITaskCreateRequest | null => {
if (!projectId || !currentSession) return null;
const body: ITaskCreateRequest = {
project_id: projectId,
name: taskName,
@@ -63,7 +63,7 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
const groupBy = getCurrentGroup();
const task = taskFormViewModel?.task;
if (groupBy.value === GROUP_BY_STATUS_VALUE) {
body.status_id = task?.status_id;
} else if (groupBy.value === GROUP_BY_PRIORITY_VALUE) {
@@ -75,7 +75,7 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
if (selectedTaskId) {
body.parent_task_id = selectedTaskId;
}
return body;
};
@@ -86,13 +86,13 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
setCreatingTask(true);
const body = createRequestBody(taskName);
if (!body) return;
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
socket?.once(SocketEvents.QUICK_TASK.toString(), (task: IProjectTask) => {
if (task.parent_task_id) {
refreshSubTasks();
dispatch(updateSubtask({ sectionId: '', subtask: task, mode: 'add' }));
// Note: Enhanced kanban updates are now handled by the global socket handler
// No need to dispatch here as it will be handled by useTaskSocketHandlers
}
@@ -111,18 +111,24 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
try {
await tasksApiService.deleteTask(taskId);
dispatch(updateSubtask({ sectionId: '', subtask: { id: taskId, parent_task_id: selectedTaskId || '' }, mode: 'delete' }));
dispatch(
updateSubtask({
sectionId: '',
subtask: { id: taskId, parent_task_id: selectedTaskId || '' },
mode: 'delete',
})
);
// Note: Enhanced kanban updates are now handled by the global socket handler
// No need to dispatch here as it will be handled by useTaskSocketHandlers
refreshSubTasks();
} catch (error) {
logger.error('Error deleting subtask:', error);
}
};
const handleOnBlur = () => {
const handleOnBlur = () => {
if (newTaskName.trim() === '') {
setIsEdit(true);
return;
@@ -150,15 +156,15 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
const handleEditSubTask = (taskId: string) => {
if (!taskId || !projectId) return;
// Close the current drawer and open the drawer for the selected sub task
dispatch(setShowTaskDrawer(false));
// Small delay to ensure the current drawer is closed before opening the new one
setTimeout(() => {
dispatch(setSelectedTaskId(taskId));
dispatch(setShowTaskDrawer(true));
// Fetch task data for the subtask
dispatch(fetchTask({ taskId, projectId }));
}, 100);
@@ -167,67 +173,77 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
const getSubTasksProgress = () => {
const ratio = taskFormViewModel?.task?.complete_ratio || 0;
return ratio == Infinity ? 0 : ratio;
}
};
const columns = useMemo(() => [
{
key: 'name',
dataIndex: 'name',
},
{
key: 'priority',
render: (record: IProjectTask) => (
<Tag
color={themeMode === 'dark' ? record.priority_color_dark : record.priority_color}
style={{ textTransform: 'capitalize' }}
>
{record.priority_name}
</Tag>
),
},
{
key: 'status',
render: (record: IProjectTask) => (
<Tag
color={themeMode === 'dark' ? record.status_color_dark : record.status_color}
style={{ textTransform: 'capitalize' }}
>
{record.status_name}
</Tag>
),
},
{
key: 'assignee',
render: (record: ISubTask) => <Avatars members={record.names || []} />,
},
{
key: 'actionBtns',
width: 80,
render: (record: IProjectTask) => (
<Flex gap={8} align="center" className="action-buttons">
<Tooltip title={typeof t === 'function' ? t('taskInfoTab.subTasks.edit') : 'Edit'}>
<Button
size="small"
icon={<EditOutlined />}
onClick={() => record.id && handleEditSubTask(record.id)}
/>
</Tooltip>
<Popconfirm
title="Are you sure?"
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText="Yes"
cancelText="No"
onPopupClick={(e) => e.stopPropagation()}
onConfirm={(e) => {handleDeleteSubTask(record.id)}}
const columns = useMemo(
() => [
{
key: 'name',
dataIndex: 'name',
},
{
key: 'priority',
render: (record: IProjectTask) => (
<Tag
color={themeMode === 'dark' ? record.priority_color_dark : record.priority_color}
style={{ textTransform: 'capitalize' }}
>
<Tooltip title="Delete">
<Button shape="default" icon={<DeleteOutlined />} size="small" onClick={(e)=> e.stopPropagation()} />
{record.priority_name}
</Tag>
),
},
{
key: 'status',
render: (record: IProjectTask) => (
<Tag
color={themeMode === 'dark' ? record.status_color_dark : record.status_color}
style={{ textTransform: 'capitalize' }}
>
{record.status_name}
</Tag>
),
},
{
key: 'assignee',
render: (record: ISubTask) => <Avatars members={record.names || []} />,
},
{
key: 'actionBtns',
width: 80,
render: (record: IProjectTask) => (
<Flex gap={8} align="center" className="action-buttons">
<Tooltip title={typeof t === 'function' ? t('taskInfoTab.subTasks.edit') : 'Edit'}>
<Button
size="small"
icon={<EditOutlined />}
onClick={() => record.id && handleEditSubTask(record.id)}
/>
</Tooltip>
</Popconfirm>
</Flex>
),
},
], [themeMode, t]);
<Popconfirm
title="Are you sure?"
icon={<ExclamationCircleFilled style={{ color: colors.vibrantOrange }} />}
okText="Yes"
cancelText="No"
onPopupClick={e => e.stopPropagation()}
onConfirm={e => {
handleDeleteSubTask(record.id);
}}
>
<Tooltip title="Delete">
<Button
shape="default"
icon={<DeleteOutlined />}
size="small"
onClick={e => e.stopPropagation()}
/>
</Tooltip>
</Popconfirm>
</Flex>
),
},
],
[themeMode, t]
);
return (
<Flex vertical gap={12}>
@@ -247,12 +263,12 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
cursor: 'pointer',
height: 36,
},
onClick: () => record.id && handleEditSubTask(record.id)
onClick: () => record.id && handleEditSubTask(record.id),
})}
loading={loadingSubTasks}
/>
)}
<div>
{isEdit ? (
<Input
@@ -264,7 +280,11 @@ const SubTaskTable = ({ subTasks, loadingSubTasks, refreshSubTasks, t }: SubTask
boxShadow: 'none',
height: 38,
}}
placeholder={typeof t === 'function' ? t('taskInfoTab.subTasks.addSubTaskInputPlaceholder') : 'Type your task and hit enter'}
placeholder={
typeof t === 'function'
? t('taskInfoTab.subTasks.addSubTaskInputPlaceholder')
: 'Type your task and hit enter'
}
onBlur={handleInputBlur}
onPressEnter={handleOnBlur}
size="small"

View File

@@ -49,7 +49,7 @@ const TaskDrawerTimeLog = ({ t, refreshTrigger = 0 }: TaskDrawerTimeLogProps) =>
for (const log of logs) {
const timeSpentInSeconds = Number(log.time_spent || '0');
// Calculate hours, minutes, seconds for individual time log
const hours = Math.floor(timeSpentInSeconds / 3600);
const minutes = Math.floor((timeSpentInSeconds % 3600) / 60);

Some files were not shown because too many files have changed in this diff Show More