feat(custom-columns): enhance task management with custom column support
- Added custom column values to task responses in the API for better task management flexibility. - Implemented custom column components in the frontend, including dropdowns and date pickers, to improve user interaction. - Updated TaskListV2 and TaskRow components to handle custom columns, ensuring proper rendering and functionality. - Introduced a new PeopleDropdown component for selecting team members in custom columns, enhancing usability. - Enhanced styling for custom column components to support both light and dark modes, improving visual consistency.
This commit is contained in:
@@ -1085,6 +1085,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
logged: convertTimeValue(task.time_spent),
|
||||
},
|
||||
customFields: {},
|
||||
custom_column_values: task.custom_column_values || {}, // Include custom column values
|
||||
createdAt: task.created_at || new Date().toISOString(),
|
||||
updatedAt: task.updated_at || new Date().toISOString(),
|
||||
order: typeof task.sort_order === "number" ? task.sort_order : 0,
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface ITaskListConfigV2 {
|
||||
group?: string;
|
||||
isSubtasksInclude: boolean;
|
||||
include_empty?: string; // Include empty groups in response
|
||||
customColumns?: boolean; // Include custom column values in response
|
||||
}
|
||||
|
||||
export interface ITaskListV3Response {
|
||||
|
||||
@@ -9,12 +9,10 @@ import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { Avatar, Button, Checkbox } from '@/components';
|
||||
import { Avatar, Checkbox } from '@/components';
|
||||
import { sortTeamMembers } from '@/utils/sort-team-members';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
||||
import { updateTask } from '@/features/task-management/task-management.slice';
|
||||
import { updateEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
||||
import { updateTaskAssignees } from '@/features/task-management/task-management.slice';
|
||||
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
||||
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { PlusOutlined, UserAddOutlined } from '@ant-design/icons';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
||||
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
||||
import { sortTeamMembers } from '@/utils/sort-team-members';
|
||||
import { Avatar, Checkbox } from '@/components';
|
||||
|
||||
interface PeopleDropdownProps {
|
||||
selectedMemberIds: string[];
|
||||
onMemberToggle: (memberId: string, checked: boolean) => void;
|
||||
onInviteClick?: () => void;
|
||||
isDarkMode?: boolean;
|
||||
className?: string;
|
||||
buttonClassName?: string;
|
||||
isLoading?: boolean;
|
||||
loadMembers?: () => void;
|
||||
pendingChanges?: Set<string>;
|
||||
}
|
||||
|
||||
const PeopleDropdown: React.FC<PeopleDropdownProps> = ({
|
||||
selectedMemberIds,
|
||||
onMemberToggle,
|
||||
onInviteClick,
|
||||
isDarkMode = false,
|
||||
className = '',
|
||||
buttonClassName = '',
|
||||
isLoading = false,
|
||||
loadMembers,
|
||||
pendingChanges = new Set(),
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [teamMembers, setTeamMembers] = useState<ITeamMembersViewModel>({ data: [], total: 0 });
|
||||
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
||||
const [hasLoadedMembers, setHasLoadedMembers] = useState(false);
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const dispatch = useAppDispatch();
|
||||
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
|
||||
|
||||
// Load members on demand when dropdown opens
|
||||
useEffect(() => {
|
||||
if (!hasLoadedMembers && loadMembers && isOpen) {
|
||||
loadMembers();
|
||||
setHasLoadedMembers(true);
|
||||
}
|
||||
}, [hasLoadedMembers, loadMembers, isOpen]);
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
return teamMembers?.data?.filter(member =>
|
||||
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [teamMembers, searchQuery]);
|
||||
|
||||
// Update dropdown position
|
||||
const updateDropdownPosition = useCallback(() => {
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
const viewportHeight = window.innerHeight;
|
||||
const dropdownHeight = 280; // More accurate height: header(40) + max-height(192) + footer(40) + padding
|
||||
|
||||
// Check if dropdown would go below viewport
|
||||
const spaceBelow = viewportHeight - rect.bottom;
|
||||
const shouldShowAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
|
||||
|
||||
setDropdownPosition({
|
||||
top: shouldShowAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
|
||||
left: rect.left,
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Close dropdown when clicking outside and handle scroll
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (
|
||||
dropdownRef.current &&
|
||||
!dropdownRef.current.contains(event.target as Node) &&
|
||||
buttonRef.current &&
|
||||
!buttonRef.current.contains(event.target as Node)
|
||||
) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleScroll = (event: Event) => {
|
||||
if (isOpen) {
|
||||
// Only close dropdown if scrolling happens outside the dropdown
|
||||
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleResize = () => {
|
||||
if (isOpen) {
|
||||
updateDropdownPosition();
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
window.addEventListener('scroll', handleScroll, true);
|
||||
window.addEventListener('resize', handleResize);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
window.removeEventListener('scroll', handleScroll, true);
|
||||
window.removeEventListener('resize', handleResize);
|
||||
};
|
||||
} else {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isOpen, updateDropdownPosition]);
|
||||
|
||||
const handleDropdownToggle = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!isOpen) {
|
||||
updateDropdownPosition();
|
||||
|
||||
// Prepare team members data when opening
|
||||
const membersData = (members?.data || []).map(member => ({
|
||||
...member,
|
||||
selected: selectedMemberIds.includes(member.id || ''),
|
||||
}));
|
||||
const sortedMembers = sortTeamMembers(membersData);
|
||||
setTeamMembers({ data: sortedMembers });
|
||||
|
||||
setIsOpen(true);
|
||||
// Focus search input after opening
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 0);
|
||||
} else {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMemberToggle = (memberId: string, checked: boolean) => {
|
||||
if (!memberId) return;
|
||||
onMemberToggle(memberId, checked);
|
||||
|
||||
// Update local team members state for dropdown UI
|
||||
setTeamMembers(prev => ({
|
||||
...prev,
|
||||
data: (prev.data || []).map(member =>
|
||||
member.id === memberId ? { ...member, selected: checked } : member
|
||||
),
|
||||
}));
|
||||
};
|
||||
|
||||
const checkMemberSelected = (memberId: string) => {
|
||||
if (!memberId) return false;
|
||||
return selectedMemberIds.includes(memberId);
|
||||
};
|
||||
|
||||
const handleInviteProjectMemberDrawer = () => {
|
||||
setIsOpen(false); // Close the dropdown first
|
||||
if (onInviteClick) {
|
||||
onInviteClick();
|
||||
} else {
|
||||
dispatch(toggleProjectMemberDrawer()); // Then open the invite drawer
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onClick={handleDropdownToggle}
|
||||
className={`
|
||||
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
|
||||
transition-colors duration-200
|
||||
${buttonClassName}
|
||||
${
|
||||
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={`
|
||||
fixed w-72 rounded-md shadow-lg border people-dropdown-portal ${className}
|
||||
${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={`
|
||||
w-full px-2 py-1 text-xs rounded border
|
||||
${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500'
|
||||
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500'
|
||||
}
|
||||
focus:outline-none focus:ring-1 focus:ring-blue-500
|
||||
`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Members List */}
|
||||
<div className="max-h-48 overflow-y-auto">
|
||||
{filteredMembers && filteredMembers.length > 0 ? (
|
||||
filteredMembers.map(member => (
|
||||
<div
|
||||
key={member.id}
|
||||
className={`
|
||||
flex items-center gap-2 p-2 cursor-pointer transition-colors
|
||||
${
|
||||
member.pending_invitation
|
||||
? 'opacity-50 cursor-not-allowed'
|
||||
: isDarkMode
|
||||
? 'hover:bg-gray-700'
|
||||
: 'hover:bg-gray-50'
|
||||
}
|
||||
`}
|
||||
onClick={() => {
|
||||
if (!member.pending_invitation) {
|
||||
const isSelected = checkMemberSelected(member.id || '');
|
||||
handleMemberToggle(member.id || '', !isSelected);
|
||||
}
|
||||
}}
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div
|
||||
className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||
>
|
||||
<div className="text-xs">
|
||||
{isLoading ? 'Loading members...' : 'No members found'}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
|
||||
<button
|
||||
className={`
|
||||
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
|
||||
transition-colors
|
||||
${isDarkMode ? 'text-blue-400 hover:bg-gray-700' : 'text-blue-600 hover:bg-blue-50'}
|
||||
`}
|
||||
onClick={handleInviteProjectMemberDrawer}
|
||||
>
|
||||
<UserAddOutlined />
|
||||
Invite member
|
||||
</button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PeopleDropdown;
|
||||
@@ -151,17 +151,28 @@ const TaskListV2: React.FC = () => {
|
||||
// Add visible custom columns
|
||||
const visibleCustomColumns = customColumns
|
||||
?.filter(column => column.pinned)
|
||||
?.map(column => ({
|
||||
?.map(column => {
|
||||
// Give selection columns more width for dropdown content
|
||||
const fieldType = column.custom_column_obj?.fieldType;
|
||||
let defaultWidth = 160;
|
||||
if (fieldType === 'selection') {
|
||||
defaultWidth = 180; // Extra width for selection dropdowns
|
||||
} else if (fieldType === 'people') {
|
||||
defaultWidth = 170; // Extra width for people with avatars
|
||||
}
|
||||
|
||||
return {
|
||||
id: column.key || column.id || 'unknown',
|
||||
label: column.name || t('customColumns.customColumnHeader'),
|
||||
width: `${(column as any).width || 120}px`,
|
||||
width: `${(column as any).width || defaultWidth}px`,
|
||||
key: column.key || column.id || 'unknown',
|
||||
custom_column: true,
|
||||
custom_column_obj: column.custom_column_obj || (column as any).configuration,
|
||||
isCustom: true,
|
||||
name: column.name,
|
||||
uuid: column.id,
|
||||
})) || [];
|
||||
};
|
||||
}) || [];
|
||||
|
||||
return [...baseVisibleColumns, ...visibleCustomColumns];
|
||||
}, [fields, columns, customColumns, t]);
|
||||
|
||||
@@ -25,6 +25,7 @@ import TaskTimeTracking from './TaskTimeTracking';
|
||||
import { CustomNumberLabel, CustomColordLabel } from '@/components';
|
||||
import LabelsSelector from '@/components/LabelsSelector';
|
||||
import TaskPhaseDropdown from '@/components/task-management/task-phase-dropdown';
|
||||
import { CustomColumnCell } from './components/CustomColumnComponents';
|
||||
|
||||
interface TaskRowProps {
|
||||
taskId: string;
|
||||
@@ -33,6 +34,10 @@ interface TaskRowProps {
|
||||
id: string;
|
||||
width: string;
|
||||
isSticky?: boolean;
|
||||
key?: string;
|
||||
custom_column?: boolean;
|
||||
custom_column_obj?: any;
|
||||
isCustom?: boolean;
|
||||
}>;
|
||||
isSubtask?: boolean;
|
||||
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
|
||||
@@ -606,6 +611,19 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
||||
);
|
||||
|
||||
default:
|
||||
// Handle custom columns
|
||||
const column = visibleColumns.find(col => col.id === columnId);
|
||||
if (column && (column.custom_column || column.isCustom) && updateTaskCustomColumnValue) {
|
||||
return (
|
||||
<div style={baseStyle}>
|
||||
<CustomColumnCell
|
||||
column={column}
|
||||
task={task}
|
||||
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}, [
|
||||
@@ -634,6 +652,10 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
||||
|
||||
// Translation
|
||||
t,
|
||||
|
||||
// Custom columns
|
||||
visibleColumns,
|
||||
updateTaskCustomColumnValue,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useCallback, useMemo, memo } from 'react';
|
||||
import { Button, Tooltip, Flex, Dropdown, DatePicker } from 'antd';
|
||||
import React, { useState, useCallback, useMemo, memo, useEffect } from 'react';
|
||||
import { Button, Tooltip, Flex, Dropdown, DatePicker, Input } from 'antd';
|
||||
import { PlusOutlined, SettingOutlined, UsergroupAddOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
@@ -9,11 +9,14 @@ import {
|
||||
toggleCustomColumnModalOpen,
|
||||
} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
||||
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
||||
import PeopleDropdown from '@/components/common/people-dropdown/PeopleDropdown';
|
||||
import AvatarGroup from '@/components/AvatarGroup';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
// Add Custom Column Button Component
|
||||
export const AddCustomColumnButton: React.FC = memo(() => {
|
||||
const dispatch = useAppDispatch();
|
||||
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
const handleModalOpen = useCallback(() => {
|
||||
@@ -22,19 +25,29 @@ export const AddCustomColumnButton: React.FC = memo(() => {
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<Tooltip title={t('customColumns.addCustomColumn')}>
|
||||
<Button
|
||||
icon={<PlusOutlined />}
|
||||
type="text"
|
||||
size="small"
|
||||
<Tooltip title={t('customColumns.addCustomColumn')} placement="top">
|
||||
<button
|
||||
onClick={handleModalOpen}
|
||||
className="hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
style={{
|
||||
background: 'transparent',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
}}
|
||||
/>
|
||||
className={`
|
||||
group relative w-8 h-8 rounded-lg border-2 border-dashed transition-all duration-200
|
||||
flex items-center justify-center
|
||||
${isDarkMode
|
||||
? 'border-gray-600 hover:border-blue-500 hover:bg-blue-500/10 text-gray-500 hover:text-blue-400'
|
||||
: 'border-gray-300 hover:border-blue-500 hover:bg-blue-50 text-gray-400 hover:text-blue-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<PlusOutlined className="text-xs transition-transform duration-200 group-hover:scale-110" />
|
||||
|
||||
{/* Subtle glow effect on hover */}
|
||||
<div className={`
|
||||
absolute inset-0 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200
|
||||
${isDarkMode
|
||||
? 'bg-blue-500/5 shadow-lg shadow-blue-500/20'
|
||||
: 'bg-blue-500/5 shadow-lg shadow-blue-500/10'
|
||||
}
|
||||
`} />
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
});
|
||||
@@ -55,7 +68,7 @@ export const CustomColumnHeader: React.FC<{
|
||||
t('customColumns.customColumnHeader');
|
||||
|
||||
return (
|
||||
<Flex align="center" justify="space-between" className="w-full">
|
||||
<Flex align="center" justify="space-between" className="w-full px-2">
|
||||
<span title={displayName}>{displayName}</span>
|
||||
<Tooltip title={t('customColumns.customColumnSettings')}>
|
||||
<SettingOutlined
|
||||
@@ -126,7 +139,7 @@ export const CustomColumnCell: React.FC<{
|
||||
/>
|
||||
);
|
||||
default:
|
||||
return <span className="text-sm text-gray-400">{t('customColumns.unsupportedField')}</span>;
|
||||
return <span className="text-sm text-gray-400 px-2">{t('customColumns.unsupportedField')}</span>;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -139,13 +152,15 @@ export const PeopleCustomColumnCell: React.FC<{
|
||||
customValue: any;
|
||||
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||
}> = memo(({ task, columnKey, customValue, updateTaskCustomColumnValue }) => {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('task-list-table');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [pendingChanges, setPendingChanges] = useState<Set<string>>(new Set());
|
||||
const [optimisticSelectedIds, setOptimisticSelectedIds] = useState<string[]>([]);
|
||||
|
||||
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const isDarkMode = themeMode === 'dark';
|
||||
|
||||
// Parse selected member IDs from custom value
|
||||
const selectedMemberIds = useMemo(() => {
|
||||
try {
|
||||
return customValue ? JSON.parse(customValue) : [];
|
||||
@@ -154,125 +169,90 @@ export const PeopleCustomColumnCell: React.FC<{
|
||||
}
|
||||
}, [customValue]);
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
return members?.data?.filter(member =>
|
||||
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
) || [];
|
||||
}, [members, searchQuery]);
|
||||
// Use optimistic updates when there are pending changes, otherwise use actual value
|
||||
const displayedMemberIds = useMemo(() => {
|
||||
// If we have pending changes, use optimistic state
|
||||
if (pendingChanges.size > 0) {
|
||||
return optimisticSelectedIds;
|
||||
}
|
||||
// Otherwise use the actual value from the server
|
||||
return selectedMemberIds;
|
||||
}, [pendingChanges.size, optimisticSelectedIds, selectedMemberIds]);
|
||||
|
||||
// Initialize optimistic state and update when actual value changes (from socket updates)
|
||||
useEffect(() => {
|
||||
// Only update optimistic state if there are no pending changes
|
||||
// This prevents the socket update from overriding our optimistic state
|
||||
if (pendingChanges.size === 0) {
|
||||
setOptimisticSelectedIds(selectedMemberIds);
|
||||
}
|
||||
}, [selectedMemberIds, pendingChanges.size]);
|
||||
|
||||
const selectedMembers = useMemo(() => {
|
||||
if (!members?.data || !selectedMemberIds.length) return [];
|
||||
return members.data.filter(member => selectedMemberIds.includes(member.id));
|
||||
}, [members, selectedMemberIds]);
|
||||
if (!members?.data || !displayedMemberIds.length) return [];
|
||||
return members.data.filter(member => displayedMemberIds.includes(member.id));
|
||||
}, [members, displayedMemberIds]);
|
||||
|
||||
const handleMemberSelection = (memberId: string) => {
|
||||
const newSelectedIds = selectedMemberIds.includes(memberId)
|
||||
? selectedMemberIds.filter((id: string) => id !== memberId)
|
||||
: [...selectedMemberIds, memberId];
|
||||
const handleMemberToggle = useCallback((memberId: string, checked: boolean) => {
|
||||
// Add to pending changes for visual feedback
|
||||
setPendingChanges(prev => new Set(prev).add(memberId));
|
||||
|
||||
const newSelectedIds = checked
|
||||
? [...selectedMemberIds, memberId]
|
||||
: selectedMemberIds.filter((id: string) => id !== memberId);
|
||||
|
||||
// Update optimistic state immediately for instant UI feedback
|
||||
setOptimisticSelectedIds(newSelectedIds);
|
||||
|
||||
if (task.id) {
|
||||
updateTaskCustomColumnValue(task.id, columnKey, JSON.stringify(newSelectedIds));
|
||||
}
|
||||
};
|
||||
|
||||
const handleInviteProjectMember = () => {
|
||||
dispatch(toggleProjectMemberDrawer());
|
||||
};
|
||||
// Remove from pending changes after socket update is processed
|
||||
// Use a longer timeout to ensure the socket update has been received and processed
|
||||
setTimeout(() => {
|
||||
setPendingChanges(prev => {
|
||||
const newSet = new Set<string>(Array.from(prev));
|
||||
newSet.delete(memberId);
|
||||
return newSet;
|
||||
});
|
||||
}, 1500); // Even longer delay to ensure socket update is fully processed
|
||||
}, [selectedMemberIds, task.id, columnKey, updateTaskCustomColumnValue]);
|
||||
|
||||
const dropdownContent = (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-2 w-80">
|
||||
<div className="flex flex-col gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={e => setSearchQuery(e.target.value)}
|
||||
placeholder={t('searchInputPlaceholder')}
|
||||
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
|
||||
<div className="max-h-60 overflow-y-auto">
|
||||
{filteredMembers.length > 0 ? (
|
||||
filteredMembers.map(member => (
|
||||
<div
|
||||
key={member.id}
|
||||
onClick={() => member.id && handleMemberSelection(member.id)}
|
||||
className="flex items-center gap-2 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={member.id ? selectedMemberIds.includes(member.id) : false}
|
||||
onChange={() => member.id && handleMemberSelection(member.id)}
|
||||
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
|
||||
/>
|
||||
<div className="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{member.avatar_url ? (
|
||||
<img src={member.avatar_url} alt={member.name} className="w-8 h-8 rounded-full" />
|
||||
) : (
|
||||
member.name?.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{member.name}</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">{member.email}</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="text-center py-4 text-gray-500 dark:text-gray-400 text-sm">
|
||||
{t('noMembersFound')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-2">
|
||||
<button
|
||||
onClick={handleInviteProjectMember}
|
||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md"
|
||||
>
|
||||
<UsergroupAddOutlined className="w-4 h-4" />
|
||||
{t('assigneeSelectorInviteButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
const loadMembers = useCallback(async () => {
|
||||
if (members?.data?.length === 0) {
|
||||
setIsLoading(true);
|
||||
// The members are loaded through Redux, so we just need to wait
|
||||
setTimeout(() => setIsLoading(false), 500);
|
||||
}
|
||||
}, [members]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<div className="flex items-center gap-1 px-2 relative custom-column-cell">
|
||||
{selectedMembers.length > 0 && (
|
||||
<div className="flex -space-x-1">
|
||||
{selectedMembers.slice(0, 3).map((member) => (
|
||||
<div
|
||||
key={member.id}
|
||||
className="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xs font-medium text-gray-700 dark:text-gray-300 border-2 border-white dark:border-gray-800"
|
||||
title={member.name}
|
||||
>
|
||||
{member.avatar_url ? (
|
||||
<img src={member.avatar_url} alt={member.name} className="w-6 h-6 rounded-full" />
|
||||
) : (
|
||||
member.name?.charAt(0).toUpperCase()
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{selectedMembers.length > 3 && (
|
||||
<div className="w-6 h-6 rounded-full bg-gray-400 dark:bg-gray-500 flex items-center justify-center text-xs font-medium text-white border-2 border-white dark:border-gray-800">
|
||||
+{selectedMembers.length - 3}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<AvatarGroup
|
||||
members={selectedMembers.map(member => ({
|
||||
id: member.id,
|
||||
team_member_id: member.id,
|
||||
name: member.name,
|
||||
avatar_url: member.avatar_url,
|
||||
color_code: member.color_code,
|
||||
}))}
|
||||
maxCount={3}
|
||||
size={24}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dropdown
|
||||
open={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
dropdownRender={() => dropdownContent}
|
||||
trigger={['click']}
|
||||
placement="bottomLeft"
|
||||
>
|
||||
<button className="w-6 h-6 rounded-full border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center hover:border-blue-500 dark:hover:border-blue-400 transition-colors">
|
||||
<PlusOutlined className="w-3 h-3 text-gray-400 dark:text-gray-500" />
|
||||
</button>
|
||||
</Dropdown>
|
||||
<PeopleDropdown
|
||||
selectedMemberIds={displayedMemberIds}
|
||||
onMemberToggle={handleMemberToggle}
|
||||
isDarkMode={isDarkMode}
|
||||
isLoading={isLoading}
|
||||
loadMembers={loadMembers}
|
||||
pendingChanges={pendingChanges}
|
||||
buttonClassName="w-6 h-6"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -286,22 +266,46 @@ export const DateCustomColumnCell: React.FC<{
|
||||
customValue: any;
|
||||
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||
}> = memo(({ task, columnKey, customValue, updateTaskCustomColumnValue }) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dateValue = customValue ? dayjs(customValue) : null;
|
||||
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
|
||||
|
||||
return (
|
||||
<DatePicker
|
||||
value={dateValue}
|
||||
onChange={date => {
|
||||
const handleDateChange = (date: dayjs.Dayjs | null) => {
|
||||
if (task.id) {
|
||||
updateTaskCustomColumnValue(task.id, columnKey, date ? date.toISOString() : '');
|
||||
}
|
||||
}}
|
||||
placeholder="Set Date"
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`px-2 relative custom-column-cell ${isOpen ? 'focused' : ''}`}>
|
||||
<div className="relative">
|
||||
<DatePicker
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
value={dateValue}
|
||||
onChange={handleDateChange}
|
||||
placeholder={dateValue ? "" : "Click to set date"}
|
||||
format="MMM DD, YYYY"
|
||||
suffixIcon={null}
|
||||
className="w-full border-none bg-transparent hover:bg-gray-50 dark:hover:bg-gray-700 text-sm"
|
||||
size="small"
|
||||
variant="borderless"
|
||||
className={`
|
||||
w-full text-sm transition-colors duration-200 custom-column-date-picker
|
||||
${isDarkMode ? 'dark-mode' : 'light-mode'}
|
||||
`}
|
||||
popupClassName={isDarkMode ? 'dark-date-picker' : 'light-date-picker'}
|
||||
inputReadOnly
|
||||
getPopupContainer={(trigger) => trigger.parentElement || document.body}
|
||||
style={{
|
||||
backgroundColor: 'transparent',
|
||||
border: 'none',
|
||||
boxShadow: 'none',
|
||||
width: '100%',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -315,14 +319,20 @@ export const NumberCustomColumnCell: React.FC<{
|
||||
columnObj: any;
|
||||
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||
}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => {
|
||||
const [inputValue, setInputValue] = useState(customValue || '');
|
||||
const [inputValue, setInputValue] = useState(String(customValue || ''));
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
|
||||
|
||||
const numberType = columnObj?.numberType || 'formatted';
|
||||
const decimals = columnObj?.decimals || 0;
|
||||
const label = columnObj?.label || '';
|
||||
const labelPosition = columnObj?.labelPosition || 'left';
|
||||
|
||||
// Sync inputValue with customValue to prevent NaN issues
|
||||
useEffect(() => {
|
||||
setInputValue(String(customValue || ''));
|
||||
}, [customValue]);
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = e.target.value;
|
||||
// Allow only numbers, decimal point, and minus sign
|
||||
@@ -331,10 +341,22 @@ export const NumberCustomColumnCell: React.FC<{
|
||||
}
|
||||
};
|
||||
|
||||
const handleFocus = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleBlur = () => {
|
||||
setIsEditing(false);
|
||||
// Only update if there's a valid value and it's different from the current value
|
||||
if (task.id && inputValue !== customValue) {
|
||||
updateTaskCustomColumnValue(task.id, columnKey, inputValue);
|
||||
// Safely convert inputValue to string to avoid .trim() errors
|
||||
const stringValue = String(inputValue || '');
|
||||
// Don't save empty values or invalid numbers
|
||||
if (stringValue.trim() === '' || isNaN(parseFloat(stringValue))) {
|
||||
setInputValue(customValue || ''); // Reset to original value
|
||||
} else {
|
||||
updateTaskCustomColumnValue(task.id, columnKey, stringValue);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -351,10 +373,12 @@ export const NumberCustomColumnCell: React.FC<{
|
||||
const getDisplayValue = () => {
|
||||
if (isEditing) return inputValue;
|
||||
|
||||
if (!inputValue) return '';
|
||||
// Safely convert inputValue to string to avoid .trim() errors
|
||||
const stringValue = String(inputValue || '');
|
||||
if (!stringValue || stringValue.trim() === '') return '';
|
||||
|
||||
const numValue = parseFloat(inputValue);
|
||||
if (isNaN(numValue)) return inputValue;
|
||||
const numValue = parseFloat(stringValue);
|
||||
if (isNaN(numValue)) return ''; // Return empty string instead of showing NaN
|
||||
|
||||
switch (numberType) {
|
||||
case 'formatted':
|
||||
@@ -364,28 +388,36 @@ export const NumberCustomColumnCell: React.FC<{
|
||||
case 'withLabel':
|
||||
return labelPosition === 'left' ? `${label} ${numValue.toFixed(decimals)}` : `${numValue.toFixed(decimals)} ${label}`;
|
||||
default:
|
||||
return inputValue;
|
||||
return numValue.toString();
|
||||
}
|
||||
};
|
||||
|
||||
const addonBefore = numberType === 'withLabel' && labelPosition === 'left' ? label : undefined;
|
||||
const addonAfter = numberType === 'withLabel' && labelPosition === 'right' ? label : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
{numberType === 'withLabel' && labelPosition === 'left' && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{label}</span>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
<div className="px-2">
|
||||
<Input
|
||||
value={getDisplayValue()}
|
||||
onChange={handleInputChange}
|
||||
onFocus={() => setIsEditing(true)}
|
||||
onFocus={handleFocus}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="w-full bg-transparent border-none text-sm text-right focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-700 px-1 py-0.5 rounded"
|
||||
placeholder="0"
|
||||
placeholder={numberType === 'percentage' ? '0%' : '0'}
|
||||
size="small"
|
||||
variant="borderless"
|
||||
addonBefore={addonBefore}
|
||||
addonAfter={addonAfter}
|
||||
style={{
|
||||
textAlign: 'right',
|
||||
width: '100%',
|
||||
minWidth: 0,
|
||||
}}
|
||||
className={`
|
||||
custom-column-number-input
|
||||
${isDarkMode ? 'dark-mode' : 'light-mode'}
|
||||
`}
|
||||
/>
|
||||
{numberType === 'withLabel' && labelPosition === 'right' && (
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{label}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -401,60 +433,152 @@ export const SelectionCustomColumnCell: React.FC<{
|
||||
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||
}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => {
|
||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
|
||||
const selectionsList = columnObj?.selectionsList || [];
|
||||
|
||||
const selectedOption = selectionsList.find((option: any) => option.selection_name === customValue);
|
||||
|
||||
const dropdownContent = (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-1 min-w-[150px]">
|
||||
{selectionsList.map((option: any) => (
|
||||
<div
|
||||
key={option.selection_id}
|
||||
onClick={() => {
|
||||
const handleOptionSelect = async (option: any) => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
if (task.id) {
|
||||
updateTaskCustomColumnValue(task.id, columnKey, option.selection_name);
|
||||
}
|
||||
setIsDropdownOpen(false);
|
||||
}}
|
||||
className="flex items-center gap-2 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md cursor-pointer"
|
||||
} finally {
|
||||
// Small delay to show loading state
|
||||
setTimeout(() => setIsLoading(false), 200);
|
||||
}
|
||||
};
|
||||
|
||||
const dropdownContent = (
|
||||
<div className={`
|
||||
rounded-lg shadow-xl border min-w-[180px] max-h-64 overflow-y-auto custom-column-dropdown
|
||||
${isDarkMode
|
||||
? 'bg-gray-800 border-gray-600'
|
||||
: 'bg-white border-gray-200'
|
||||
}
|
||||
`}>
|
||||
{/* Header */}
|
||||
<div className={`
|
||||
px-3 py-2 border-b text-xs font-medium
|
||||
${isDarkMode
|
||||
? 'border-gray-600 text-gray-300 bg-gray-750'
|
||||
: 'border-gray-200 text-gray-600 bg-gray-50'
|
||||
}
|
||||
`}>
|
||||
Select an option
|
||||
</div>
|
||||
|
||||
{/* Options */}
|
||||
<div className="p-1">
|
||||
{selectionsList.map((option: any) => (
|
||||
<div
|
||||
key={option.selection_id}
|
||||
onClick={() => handleOptionSelect(option)}
|
||||
className={`
|
||||
flex items-center gap-3 p-2 rounded-md cursor-pointer transition-all duration-200
|
||||
${selectedOption?.selection_id === option.selection_id
|
||||
? isDarkMode
|
||||
? 'bg-blue-900/50 text-blue-200'
|
||||
: 'bg-blue-50 text-blue-700'
|
||||
: isDarkMode
|
||||
? 'hover:bg-gray-700 text-gray-200'
|
||||
: 'hover:bg-gray-100 text-gray-900'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
className="w-3 h-3 rounded-full border border-white/20 shadow-sm"
|
||||
style={{ backgroundColor: option.selection_color || '#6b7280' }}
|
||||
/>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">{option.selection_name}</span>
|
||||
</div>
|
||||
))}
|
||||
{selectionsList.length === 0 && (
|
||||
<div className="text-center py-2 text-gray-500 dark:text-gray-400 text-sm">
|
||||
No options available
|
||||
<span className="text-sm font-medium flex-1">{option.selection_name}</span>
|
||||
{selectedOption?.selection_id === option.selection_id && (
|
||||
<div className={`
|
||||
w-4 h-4 rounded-full flex items-center justify-center
|
||||
${isDarkMode ? 'bg-blue-600' : 'bg-blue-500'}
|
||||
`}>
|
||||
<svg className="w-2.5 h-2.5 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{selectionsList.length === 0 && (
|
||||
<div className={`
|
||||
text-center py-8 text-sm
|
||||
${isDarkMode ? 'text-gray-500' : 'text-gray-400'}
|
||||
`}>
|
||||
<div className="mb-2">📋</div>
|
||||
<div>No options available</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`px-2 relative custom-column-cell ${isDropdownOpen ? 'focused' : ''}`}>
|
||||
<Dropdown
|
||||
open={isDropdownOpen}
|
||||
onOpenChange={setIsDropdownOpen}
|
||||
dropdownRender={() => dropdownContent}
|
||||
trigger={['click']}
|
||||
placement="bottomLeft"
|
||||
overlayClassName="custom-selection-dropdown"
|
||||
getPopupContainer={(trigger) => trigger.parentElement || document.body}
|
||||
>
|
||||
<div className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 p-1 rounded min-h-[24px]">
|
||||
{selectedOption ? (
|
||||
<div className={`
|
||||
flex items-center gap-2 cursor-pointer rounded-md px-2 py-1 min-h-[28px] transition-all duration-200 relative
|
||||
${isDropdownOpen
|
||||
? isDarkMode
|
||||
? 'bg-gray-700 ring-1 ring-blue-500/50'
|
||||
: 'bg-gray-100 ring-1 ring-blue-500/50'
|
||||
: isDarkMode
|
||||
? 'hover:bg-gray-700/50'
|
||||
: 'hover:bg-gray-100/50'
|
||||
}
|
||||
`}>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className={`
|
||||
w-3 h-3 rounded-full animate-spin border-2 border-transparent
|
||||
${isDarkMode ? 'border-t-gray-400' : 'border-t-gray-600'}
|
||||
`} />
|
||||
<span className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
Updating...
|
||||
</span>
|
||||
</div>
|
||||
) : selectedOption ? (
|
||||
<>
|
||||
<div
|
||||
className="w-3 h-3 rounded-full"
|
||||
className="w-3 h-3 rounded-full border border-white/20 shadow-sm"
|
||||
style={{ backgroundColor: selectedOption.selection_color || '#6b7280' }}
|
||||
/>
|
||||
<span className="text-sm text-gray-900 dark:text-gray-100">{selectedOption.selection_name}</span>
|
||||
<span className={`text-sm font-medium ${isDarkMode ? 'text-gray-200' : 'text-gray-900'}`}>
|
||||
{selectedOption.selection_name}
|
||||
</span>
|
||||
<svg className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-gray-400 dark:text-gray-500">Select option</span>
|
||||
<>
|
||||
<div className={`w-3 h-3 rounded-full border-2 border-dashed ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`} />
|
||||
<span className={`text-sm ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`}>
|
||||
Select option
|
||||
</span>
|
||||
<svg className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Dropdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -240,6 +240,7 @@ export const fetchTasksV3 = createAsyncThunk(
|
||||
isSubtasksInclude: false,
|
||||
labels: selectedLabels,
|
||||
priorities: selectedPriorities,
|
||||
customColumns: true, // Include custom columns in the response
|
||||
};
|
||||
|
||||
const response = await tasksApiService.getTaskListV3(config);
|
||||
@@ -264,7 +265,7 @@ export const fetchTasksV3 = createAsyncThunk(
|
||||
labels: task.labels?.map((l: { id: string; label_id: string; name: string; color_code: string; end: boolean; names: string[] }) => ({
|
||||
id: l.id || l.label_id,
|
||||
name: l.name,
|
||||
color: l.color || '#1890ff',
|
||||
color: l.color_code || '#1890ff',
|
||||
end: l.end,
|
||||
names: l.names,
|
||||
})) || [],
|
||||
@@ -275,6 +276,7 @@ export const fetchTasksV3 = createAsyncThunk(
|
||||
logged: convertTimeValue(task.time_spent),
|
||||
},
|
||||
customFields: {},
|
||||
custom_column_values: task.custom_column_values || {}, // Include custom column values
|
||||
createdAt: task.created_at || now,
|
||||
updatedAt: task.updated_at || now,
|
||||
created_at: task.created_at || now,
|
||||
|
||||
@@ -737,6 +737,30 @@ export const useTaskSocketHandlers = () => {
|
||||
[dispatch, taskGroups]
|
||||
);
|
||||
|
||||
const handleCustomColumnUpdate = useCallback(
|
||||
(data: { task_id: string; column_key: string; value: string }) => {
|
||||
if (!data || !data.task_id || !data.column_key) return;
|
||||
|
||||
// Update the task-management slice for task-list-v2 components
|
||||
const currentTask = store.getState().taskManagement.entities[data.task_id];
|
||||
if (currentTask) {
|
||||
const updatedCustomColumnValues = {
|
||||
...currentTask.custom_column_values,
|
||||
[data.column_key]: data.value,
|
||||
};
|
||||
|
||||
const updatedTask: Task = {
|
||||
...currentTask,
|
||||
custom_column_values: updatedCustomColumnValues,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
|
||||
dispatch(updateTask(updatedTask));
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
// Handler for TASK_ASSIGNEES_CHANGE (fallback event with limited data)
|
||||
const handleTaskAssigneesChange = useCallback((data: { assigneeIds: string[] }) => {
|
||||
if (!data || !data.assigneeIds) return;
|
||||
@@ -776,6 +800,7 @@ export const useTaskSocketHandlers = () => {
|
||||
},
|
||||
{ event: SocketEvents.QUICK_TASK.toString(), handler: handleNewTaskReceived },
|
||||
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdated },
|
||||
{ event: SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), handler: handleCustomColumnUpdate },
|
||||
];
|
||||
|
||||
// Register all event listeners
|
||||
@@ -806,5 +831,6 @@ export const useTaskSocketHandlers = () => {
|
||||
handleTaskDescriptionChange,
|
||||
handleNewTaskReceived,
|
||||
handleTaskProgressUpdated,
|
||||
handleCustomColumnUpdate,
|
||||
]);
|
||||
};
|
||||
|
||||
@@ -146,3 +146,332 @@ Not supports in Firefox and IE */
|
||||
tr:hover .action-buttons {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Custom column components z-index hierarchy */
|
||||
.custom-column-cell {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.custom-column-cell.focused {
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.custom-column-dropdown {
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.custom-selection-dropdown .ant-dropdown {
|
||||
z-index: 1050 !important;
|
||||
}
|
||||
|
||||
/* Ensure people dropdown has higher z-index */
|
||||
.people-dropdown-portal {
|
||||
z-index: 9999 !important;
|
||||
}
|
||||
|
||||
/* Number input focused state */
|
||||
.number-input-container.focused {
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.number-input-container.focused input {
|
||||
z-index: 21;
|
||||
}
|
||||
|
||||
/* Custom column number input styles */
|
||||
.custom-column-number-input {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.custom-column-number-input .ant-input-group {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
display: flex !important;
|
||||
overflow: hidden !important;
|
||||
}
|
||||
|
||||
.custom-column-number-input .ant-input {
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
min-width: 0 !important;
|
||||
padding: 2px 6px !important;
|
||||
}
|
||||
|
||||
.custom-column-number-input.light-mode .ant-input {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
color: #1f2937 !important;
|
||||
}
|
||||
|
||||
.custom-column-number-input.light-mode .ant-input::placeholder {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
.custom-column-number-input.light-mode .ant-input:hover {
|
||||
background-color: rgba(243, 244, 246, 0.5) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.custom-column-number-input.light-mode .ant-input:focus {
|
||||
background-color: rgba(243, 244, 246, 0.8) !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.5) !important;
|
||||
}
|
||||
|
||||
.custom-column-number-input.dark-mode .ant-input {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
.custom-column-number-input.dark-mode .ant-input::placeholder {
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
|
||||
.custom-column-number-input.dark-mode .ant-input:hover {
|
||||
background-color: rgba(55, 65, 81, 0.3) !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
.custom-column-number-input.dark-mode .ant-input:focus {
|
||||
background-color: rgba(55, 65, 81, 0.5) !important;
|
||||
border: none !important;
|
||||
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Addon styles for light mode */
|
||||
.custom-column-number-input.light-mode .ant-input-group-addon {
|
||||
background-color: #f3f4f6 !important;
|
||||
border: 1px solid #e5e7eb !important;
|
||||
color: #6b7280 !important;
|
||||
padding: 2px 6px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
/* Addon styles for dark mode */
|
||||
.custom-column-number-input.dark-mode .ant-input-group-addon {
|
||||
background-color: #374151 !important;
|
||||
border: 1px solid #4b5563 !important;
|
||||
color: #9ca3af !important;
|
||||
padding: 2px 6px !important;
|
||||
font-size: 12px !important;
|
||||
}
|
||||
|
||||
/* Dark mode styles for Ant Design components in custom columns */
|
||||
[data-theme="dark"] .ant-picker,
|
||||
[data-theme="dark"] .ant-picker-input > input,
|
||||
.theme-dark .ant-picker,
|
||||
.theme-dark .ant-picker-input > input {
|
||||
background-color: transparent !important;
|
||||
border-color: transparent !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ant-picker-input > input::placeholder,
|
||||
.theme-dark .ant-picker-input > input::placeholder {
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ant-picker:hover,
|
||||
.theme-dark .ant-picker:hover {
|
||||
border-color: transparent !important;
|
||||
background-color: rgba(55, 65, 81, 0.3) !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ant-picker-focused,
|
||||
[data-theme="dark"] .ant-picker:focus,
|
||||
.theme-dark .ant-picker-focused,
|
||||
.theme-dark .ant-picker:focus {
|
||||
border-color: rgba(59, 130, 246, 0.5) !important;
|
||||
background-color: rgba(55, 65, 81, 0.5) !important;
|
||||
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Dark mode dropdown styles */
|
||||
[data-theme="dark"] .ant-dropdown,
|
||||
.theme-dark .ant-dropdown {
|
||||
background-color: #1f1f1f !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ant-dropdown-menu,
|
||||
.theme-dark .ant-dropdown-menu {
|
||||
background-color: #1f1f1f !important;
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ant-dropdown-menu-item,
|
||||
.theme-dark .ant-dropdown-menu-item {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ant-dropdown-menu-item:hover,
|
||||
.theme-dark .ant-dropdown-menu-item:hover {
|
||||
background-color: #374151 !important;
|
||||
}
|
||||
|
||||
/* Dark mode date picker popup */
|
||||
.dark-date-picker .ant-picker-panel,
|
||||
.dark-date-picker .ant-picker-panel-container {
|
||||
background-color: #1f1f1f !important;
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
|
||||
.dark-date-picker .ant-picker-header {
|
||||
background-color: #1f1f1f !important;
|
||||
border-bottom-color: #374151 !important;
|
||||
}
|
||||
|
||||
.dark-date-picker .ant-picker-header button {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
.dark-date-picker .ant-picker-header button:hover {
|
||||
color: #60a5fa !important;
|
||||
}
|
||||
|
||||
.dark-date-picker .ant-picker-content {
|
||||
background-color: #1f1f1f !important;
|
||||
}
|
||||
|
||||
.dark-date-picker .ant-picker-cell {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
.dark-date-picker .ant-picker-cell:hover .ant-picker-cell-inner {
|
||||
background-color: #374151 !important;
|
||||
}
|
||||
|
||||
.dark-date-picker .ant-picker-cell-selected .ant-picker-cell-inner {
|
||||
background-color: #3b82f6 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
.dark-date-picker .ant-picker-cell-today .ant-picker-cell-inner {
|
||||
border-color: #60a5fa !important;
|
||||
}
|
||||
|
||||
.dark-date-picker .ant-picker-footer {
|
||||
background-color: #1f1f1f !important;
|
||||
border-top-color: #374151 !important;
|
||||
}
|
||||
|
||||
.dark-date-picker .ant-picker-footer .ant-btn {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
.dark-date-picker .ant-picker-footer .ant-btn:hover {
|
||||
color: #60a5fa !important;
|
||||
}
|
||||
|
||||
/* Global dark mode styles for date picker popups */
|
||||
[data-theme="dark"] .ant-picker-dropdown .ant-picker-panel-container,
|
||||
.theme-dark .ant-picker-dropdown .ant-picker-panel-container {
|
||||
background-color: #1f1f1f !important;
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ant-picker-dropdown .ant-picker-header,
|
||||
.theme-dark .ant-picker-dropdown .ant-picker-header {
|
||||
background-color: #1f1f1f !important;
|
||||
border-bottom-color: #374151 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ant-picker-dropdown .ant-picker-header button,
|
||||
.theme-dark .ant-picker-dropdown .ant-picker-header button {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ant-picker-dropdown .ant-picker-header button:hover,
|
||||
.theme-dark .ant-picker-dropdown .ant-picker-header button:hover {
|
||||
color: #60a5fa !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ant-picker-dropdown .ant-picker-content,
|
||||
.theme-dark .ant-picker-dropdown .ant-picker-content {
|
||||
background-color: #1f1f1f !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ant-picker-dropdown .ant-picker-cell,
|
||||
.theme-dark .ant-picker-dropdown .ant-picker-cell {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ant-picker-dropdown .ant-picker-cell:hover .ant-picker-cell-inner,
|
||||
.theme-dark .ant-picker-dropdown .ant-picker-cell:hover .ant-picker-cell-inner {
|
||||
background-color: #374151 !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ant-picker-dropdown .ant-picker-cell-selected .ant-picker-cell-inner,
|
||||
.theme-dark .ant-picker-dropdown .ant-picker-cell-selected .ant-picker-cell-inner {
|
||||
background-color: #3b82f6 !important;
|
||||
color: #ffffff !important;
|
||||
}
|
||||
|
||||
[data-theme="dark"] .ant-picker-dropdown .ant-picker-cell-today .ant-picker-cell-inner,
|
||||
.theme-dark .ant-picker-dropdown .ant-picker-cell-today .ant-picker-cell-inner {
|
||||
border-color: #60a5fa !important;
|
||||
}
|
||||
|
||||
/* Custom column date picker styles */
|
||||
.custom-column-date-picker.light-mode .ant-picker-input > input {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
color: #1f2937 !important;
|
||||
}
|
||||
|
||||
.custom-column-date-picker.light-mode .ant-picker-input > input::placeholder {
|
||||
color: #9ca3af !important;
|
||||
}
|
||||
|
||||
.custom-column-date-picker.light-mode:hover {
|
||||
background-color: rgba(243, 244, 246, 0.5) !important;
|
||||
}
|
||||
|
||||
.custom-column-date-picker.light-mode:focus,
|
||||
.custom-column-date-picker.light-mode.ant-picker-focused {
|
||||
background-color: rgba(243, 244, 246, 0.8) !important;
|
||||
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.5) !important;
|
||||
}
|
||||
|
||||
.custom-column-date-picker.dark-mode .ant-picker-input > input {
|
||||
background-color: transparent !important;
|
||||
border: none !important;
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
.custom-column-date-picker.dark-mode .ant-picker-input > input::placeholder {
|
||||
color: #6b7280 !important;
|
||||
}
|
||||
|
||||
.custom-column-date-picker.dark-mode:hover {
|
||||
background-color: rgba(55, 65, 81, 0.3) !important;
|
||||
}
|
||||
|
||||
.custom-column-date-picker.dark-mode:focus,
|
||||
.custom-column-date-picker.dark-mode.ant-picker-focused {
|
||||
background-color: rgba(55, 65, 81, 0.5) !important;
|
||||
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.5) !important;
|
||||
}
|
||||
|
||||
/* Custom column selection dropdown styles */
|
||||
.custom-selection-dropdown [data-theme="dark"] .ant-dropdown-menu,
|
||||
.custom-selection-dropdown .theme-dark .ant-dropdown-menu {
|
||||
background-color: #1f1f1f !important;
|
||||
border-color: #374151 !important;
|
||||
}
|
||||
|
||||
.custom-selection-dropdown [data-theme="dark"] .ant-dropdown-menu-item,
|
||||
.custom-selection-dropdown .theme-dark .ant-dropdown-menu-item {
|
||||
color: #e5e7eb !important;
|
||||
}
|
||||
|
||||
.custom-selection-dropdown [data-theme="dark"] .ant-dropdown-menu-item:hover,
|
||||
.custom-selection-dropdown .theme-dark .ant-dropdown-menu-item:hover {
|
||||
background-color: #374151 !important;
|
||||
}
|
||||
|
||||
@@ -43,6 +43,8 @@ export interface Task {
|
||||
logged?: number;
|
||||
estimated?: number;
|
||||
};
|
||||
custom_column_values?: Record<string, any>; // Custom column values
|
||||
isTemporary?: boolean; // Temporary task indicator
|
||||
// Add any other task properties as needed
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user