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),
|
logged: convertTimeValue(task.time_spent),
|
||||||
},
|
},
|
||||||
customFields: {},
|
customFields: {},
|
||||||
|
custom_column_values: task.custom_column_values || {}, // Include custom column values
|
||||||
createdAt: task.created_at || new Date().toISOString(),
|
createdAt: task.created_at || new Date().toISOString(),
|
||||||
updatedAt: task.updated_at || new Date().toISOString(),
|
updatedAt: task.updated_at || new Date().toISOString(),
|
||||||
order: typeof task.sort_order === "number" ? task.sort_order : 0,
|
order: typeof task.sort_order === "number" ? task.sort_order : 0,
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface ITaskListConfigV2 {
|
|||||||
group?: string;
|
group?: string;
|
||||||
isSubtasksInclude: boolean;
|
isSubtasksInclude: boolean;
|
||||||
include_empty?: string; // Include empty groups in response
|
include_empty?: string; // Include empty groups in response
|
||||||
|
customColumns?: boolean; // Include custom column values in response
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITaskListV3Response {
|
export interface ITaskListV3Response {
|
||||||
|
|||||||
@@ -9,12 +9,10 @@ import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
|||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
import { SocketEvents } from '@/shared/socket-events';
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
import { Avatar, Button, Checkbox } from '@/components';
|
import { Avatar, Checkbox } from '@/components';
|
||||||
import { sortTeamMembers } from '@/utils/sort-team-members';
|
import { sortTeamMembers } from '@/utils/sort-team-members';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
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 { updateTaskAssignees } from '@/features/task-management/task-management.slice';
|
||||||
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
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
|
// Add visible custom columns
|
||||||
const visibleCustomColumns = customColumns
|
const visibleCustomColumns = customColumns
|
||||||
?.filter(column => column.pinned)
|
?.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',
|
id: column.key || column.id || 'unknown',
|
||||||
label: column.name || t('customColumns.customColumnHeader'),
|
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',
|
key: column.key || column.id || 'unknown',
|
||||||
custom_column: true,
|
custom_column: true,
|
||||||
custom_column_obj: column.custom_column_obj || (column as any).configuration,
|
custom_column_obj: column.custom_column_obj || (column as any).configuration,
|
||||||
isCustom: true,
|
isCustom: true,
|
||||||
name: column.name,
|
name: column.name,
|
||||||
uuid: column.id,
|
uuid: column.id,
|
||||||
})) || [];
|
};
|
||||||
|
}) || [];
|
||||||
|
|
||||||
return [...baseVisibleColumns, ...visibleCustomColumns];
|
return [...baseVisibleColumns, ...visibleCustomColumns];
|
||||||
}, [fields, columns, customColumns, t]);
|
}, [fields, columns, customColumns, t]);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ import TaskTimeTracking from './TaskTimeTracking';
|
|||||||
import { CustomNumberLabel, CustomColordLabel } from '@/components';
|
import { CustomNumberLabel, CustomColordLabel } from '@/components';
|
||||||
import LabelsSelector from '@/components/LabelsSelector';
|
import LabelsSelector from '@/components/LabelsSelector';
|
||||||
import TaskPhaseDropdown from '@/components/task-management/task-phase-dropdown';
|
import TaskPhaseDropdown from '@/components/task-management/task-phase-dropdown';
|
||||||
|
import { CustomColumnCell } from './components/CustomColumnComponents';
|
||||||
|
|
||||||
interface TaskRowProps {
|
interface TaskRowProps {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -33,6 +34,10 @@ interface TaskRowProps {
|
|||||||
id: string;
|
id: string;
|
||||||
width: string;
|
width: string;
|
||||||
isSticky?: boolean;
|
isSticky?: boolean;
|
||||||
|
key?: string;
|
||||||
|
custom_column?: boolean;
|
||||||
|
custom_column_obj?: any;
|
||||||
|
isCustom?: boolean;
|
||||||
}>;
|
}>;
|
||||||
isSubtask?: boolean;
|
isSubtask?: boolean;
|
||||||
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
|
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
|
||||||
@@ -606,6 +611,19 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
);
|
);
|
||||||
|
|
||||||
default:
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -634,6 +652,10 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
|
|
||||||
// Translation
|
// Translation
|
||||||
t,
|
t,
|
||||||
|
|
||||||
|
// Custom columns
|
||||||
|
visibleColumns,
|
||||||
|
updateTaskCustomColumnValue,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useCallback, useMemo, memo } from 'react';
|
import React, { useState, useCallback, useMemo, memo, useEffect } from 'react';
|
||||||
import { Button, Tooltip, Flex, Dropdown, DatePicker } from 'antd';
|
import { Button, Tooltip, Flex, Dropdown, DatePicker, Input } from 'antd';
|
||||||
import { PlusOutlined, SettingOutlined, UsergroupAddOutlined } from '@ant-design/icons';
|
import { PlusOutlined, SettingOutlined, UsergroupAddOutlined } from '@ant-design/icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
@@ -9,11 +9,14 @@ import {
|
|||||||
toggleCustomColumnModalOpen,
|
toggleCustomColumnModalOpen,
|
||||||
} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
||||||
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
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';
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
// Add Custom Column Button Component
|
// Add Custom Column Button Component
|
||||||
export const AddCustomColumnButton: React.FC = memo(() => {
|
export const AddCustomColumnButton: React.FC = memo(() => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
|
||||||
const { t } = useTranslation('task-list-table');
|
const { t } = useTranslation('task-list-table');
|
||||||
|
|
||||||
const handleModalOpen = useCallback(() => {
|
const handleModalOpen = useCallback(() => {
|
||||||
@@ -22,19 +25,29 @@ export const AddCustomColumnButton: React.FC = memo(() => {
|
|||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip title={t('customColumns.addCustomColumn')}>
|
<Tooltip title={t('customColumns.addCustomColumn')} placement="top">
|
||||||
<Button
|
<button
|
||||||
icon={<PlusOutlined />}
|
|
||||||
type="text"
|
|
||||||
size="small"
|
|
||||||
onClick={handleModalOpen}
|
onClick={handleModalOpen}
|
||||||
className="hover:bg-gray-100 dark:hover:bg-gray-700"
|
className={`
|
||||||
style={{
|
group relative w-8 h-8 rounded-lg border-2 border-dashed transition-all duration-200
|
||||||
background: 'transparent',
|
flex items-center justify-center
|
||||||
border: 'none',
|
${isDarkMode
|
||||||
boxShadow: 'none',
|
? '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>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -55,7 +68,7 @@ export const CustomColumnHeader: React.FC<{
|
|||||||
t('customColumns.customColumnHeader');
|
t('customColumns.customColumnHeader');
|
||||||
|
|
||||||
return (
|
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>
|
<span title={displayName}>{displayName}</span>
|
||||||
<Tooltip title={t('customColumns.customColumnSettings')}>
|
<Tooltip title={t('customColumns.customColumnSettings')}>
|
||||||
<SettingOutlined
|
<SettingOutlined
|
||||||
@@ -126,7 +139,7 @@ export const CustomColumnCell: React.FC<{
|
|||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
default:
|
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;
|
customValue: any;
|
||||||
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||||
}> = memo(({ task, columnKey, customValue, updateTaskCustomColumnValue }) => {
|
}> = memo(({ task, columnKey, customValue, updateTaskCustomColumnValue }) => {
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [pendingChanges, setPendingChanges] = useState<Set<string>>(new Set());
|
||||||
const dispatch = useAppDispatch();
|
const [optimisticSelectedIds, setOptimisticSelectedIds] = useState<string[]>([]);
|
||||||
const { t } = useTranslation('task-list-table');
|
|
||||||
|
|
||||||
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
|
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(() => {
|
const selectedMemberIds = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return customValue ? JSON.parse(customValue) : [];
|
return customValue ? JSON.parse(customValue) : [];
|
||||||
@@ -154,125 +169,90 @@ export const PeopleCustomColumnCell: React.FC<{
|
|||||||
}
|
}
|
||||||
}, [customValue]);
|
}, [customValue]);
|
||||||
|
|
||||||
const filteredMembers = useMemo(() => {
|
// Use optimistic updates when there are pending changes, otherwise use actual value
|
||||||
return members?.data?.filter(member =>
|
const displayedMemberIds = useMemo(() => {
|
||||||
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
// If we have pending changes, use optimistic state
|
||||||
) || [];
|
if (pendingChanges.size > 0) {
|
||||||
}, [members, searchQuery]);
|
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(() => {
|
const selectedMembers = useMemo(() => {
|
||||||
if (!members?.data || !selectedMemberIds.length) return [];
|
if (!members?.data || !displayedMemberIds.length) return [];
|
||||||
return members.data.filter(member => selectedMemberIds.includes(member.id));
|
return members.data.filter(member => displayedMemberIds.includes(member.id));
|
||||||
}, [members, selectedMemberIds]);
|
}, [members, displayedMemberIds]);
|
||||||
|
|
||||||
const handleMemberSelection = (memberId: string) => {
|
const handleMemberToggle = useCallback((memberId: string, checked: boolean) => {
|
||||||
const newSelectedIds = selectedMemberIds.includes(memberId)
|
// Add to pending changes for visual feedback
|
||||||
? selectedMemberIds.filter((id: string) => id !== memberId)
|
setPendingChanges(prev => new Set(prev).add(memberId));
|
||||||
: [...selectedMemberIds, 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) {
|
if (task.id) {
|
||||||
updateTaskCustomColumnValue(task.id, columnKey, JSON.stringify(newSelectedIds));
|
updateTaskCustomColumnValue(task.id, columnKey, JSON.stringify(newSelectedIds));
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleInviteProjectMember = () => {
|
// Remove from pending changes after socket update is processed
|
||||||
dispatch(toggleProjectMemberDrawer());
|
// 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 = (
|
const loadMembers = useCallback(async () => {
|
||||||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-2 w-80">
|
if (members?.data?.length === 0) {
|
||||||
<div className="flex flex-col gap-2">
|
setIsLoading(true);
|
||||||
<input
|
// The members are loaded through Redux, so we just need to wait
|
||||||
type="text"
|
setTimeout(() => setIsLoading(false), 500);
|
||||||
value={searchQuery}
|
}
|
||||||
onChange={e => setSearchQuery(e.target.value)}
|
}, [members]);
|
||||||
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>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1 px-2 relative custom-column-cell">
|
||||||
{selectedMembers.length > 0 && (
|
{selectedMembers.length > 0 && (
|
||||||
<div className="flex -space-x-1">
|
<AvatarGroup
|
||||||
{selectedMembers.slice(0, 3).map((member) => (
|
members={selectedMembers.map(member => ({
|
||||||
<div
|
id: member.id,
|
||||||
key={member.id}
|
team_member_id: 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"
|
name: member.name,
|
||||||
title={member.name}
|
avatar_url: member.avatar_url,
|
||||||
>
|
color_code: member.color_code,
|
||||||
{member.avatar_url ? (
|
}))}
|
||||||
<img src={member.avatar_url} alt={member.name} className="w-6 h-6 rounded-full" />
|
maxCount={3}
|
||||||
) : (
|
size={24}
|
||||||
member.name?.charAt(0).toUpperCase()
|
isDarkMode={isDarkMode}
|
||||||
)}
|
/>
|
||||||
</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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Dropdown
|
<PeopleDropdown
|
||||||
open={isDropdownOpen}
|
selectedMemberIds={displayedMemberIds}
|
||||||
onOpenChange={setIsDropdownOpen}
|
onMemberToggle={handleMemberToggle}
|
||||||
dropdownRender={() => dropdownContent}
|
isDarkMode={isDarkMode}
|
||||||
trigger={['click']}
|
isLoading={isLoading}
|
||||||
placement="bottomLeft"
|
loadMembers={loadMembers}
|
||||||
>
|
pendingChanges={pendingChanges}
|
||||||
<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">
|
buttonClassName="w-6 h-6"
|
||||||
<PlusOutlined className="w-3 h-3 text-gray-400 dark:text-gray-500" />
|
/>
|
||||||
</button>
|
|
||||||
</Dropdown>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -286,22 +266,46 @@ export const DateCustomColumnCell: React.FC<{
|
|||||||
customValue: any;
|
customValue: any;
|
||||||
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||||
}> = memo(({ task, columnKey, customValue, updateTaskCustomColumnValue }) => {
|
}> = memo(({ task, columnKey, customValue, updateTaskCustomColumnValue }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const dateValue = customValue ? dayjs(customValue) : null;
|
const dateValue = customValue ? dayjs(customValue) : null;
|
||||||
|
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
|
||||||
|
|
||||||
return (
|
const handleDateChange = (date: dayjs.Dayjs | null) => {
|
||||||
<DatePicker
|
|
||||||
value={dateValue}
|
|
||||||
onChange={date => {
|
|
||||||
if (task.id) {
|
if (task.id) {
|
||||||
updateTaskCustomColumnValue(task.id, columnKey, date ? date.toISOString() : '');
|
updateTaskCustomColumnValue(task.id, columnKey, date ? date.toISOString() : '');
|
||||||
}
|
}
|
||||||
}}
|
setIsOpen(false);
|
||||||
placeholder="Set Date"
|
};
|
||||||
|
|
||||||
|
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"
|
format="MMM DD, YYYY"
|
||||||
suffixIcon={null}
|
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
|
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;
|
columnObj: any;
|
||||||
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||||
}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => {
|
}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => {
|
||||||
const [inputValue, setInputValue] = useState(customValue || '');
|
const [inputValue, setInputValue] = useState(String(customValue || ''));
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
|
||||||
|
|
||||||
const numberType = columnObj?.numberType || 'formatted';
|
const numberType = columnObj?.numberType || 'formatted';
|
||||||
const decimals = columnObj?.decimals || 0;
|
const decimals = columnObj?.decimals || 0;
|
||||||
const label = columnObj?.label || '';
|
const label = columnObj?.label || '';
|
||||||
const labelPosition = columnObj?.labelPosition || 'left';
|
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 handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = e.target.value;
|
const value = e.target.value;
|
||||||
// Allow only numbers, decimal point, and minus sign
|
// Allow only numbers, decimal point, and minus sign
|
||||||
@@ -331,10 +341,22 @@ export const NumberCustomColumnCell: React.FC<{
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
const handleBlur = () => {
|
const handleBlur = () => {
|
||||||
setIsEditing(false);
|
setIsEditing(false);
|
||||||
|
// Only update if there's a valid value and it's different from the current value
|
||||||
if (task.id && inputValue !== customValue) {
|
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 = () => {
|
const getDisplayValue = () => {
|
||||||
if (isEditing) return inputValue;
|
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);
|
const numValue = parseFloat(stringValue);
|
||||||
if (isNaN(numValue)) return inputValue;
|
if (isNaN(numValue)) return ''; // Return empty string instead of showing NaN
|
||||||
|
|
||||||
switch (numberType) {
|
switch (numberType) {
|
||||||
case 'formatted':
|
case 'formatted':
|
||||||
@@ -364,28 +388,36 @@ export const NumberCustomColumnCell: React.FC<{
|
|||||||
case 'withLabel':
|
case 'withLabel':
|
||||||
return labelPosition === 'left' ? `${label} ${numValue.toFixed(decimals)}` : `${numValue.toFixed(decimals)} ${label}`;
|
return labelPosition === 'left' ? `${label} ${numValue.toFixed(decimals)}` : `${numValue.toFixed(decimals)} ${label}`;
|
||||||
default:
|
default:
|
||||||
return inputValue;
|
return numValue.toString();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const addonBefore = numberType === 'withLabel' && labelPosition === 'left' ? label : undefined;
|
||||||
|
const addonAfter = numberType === 'withLabel' && labelPosition === 'right' ? label : undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="px-2">
|
||||||
{numberType === 'withLabel' && labelPosition === 'left' && (
|
<Input
|
||||||
<span className="text-xs text-gray-500 dark:text-gray-400">{label}</span>
|
|
||||||
)}
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={getDisplayValue()}
|
value={getDisplayValue()}
|
||||||
onChange={handleInputChange}
|
onChange={handleInputChange}
|
||||||
onFocus={() => setIsEditing(true)}
|
onFocus={handleFocus}
|
||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
onKeyDown={handleKeyDown}
|
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={numberType === 'percentage' ? '0%' : '0'}
|
||||||
placeholder="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>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -401,60 +433,152 @@ export const SelectionCustomColumnCell: React.FC<{
|
|||||||
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||||
}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => {
|
}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => {
|
||||||
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
|
||||||
const selectionsList = columnObj?.selectionsList || [];
|
const selectionsList = columnObj?.selectionsList || [];
|
||||||
|
|
||||||
const selectedOption = selectionsList.find((option: any) => option.selection_name === customValue);
|
const selectedOption = selectionsList.find((option: any) => option.selection_name === customValue);
|
||||||
|
|
||||||
const dropdownContent = (
|
const handleOptionSelect = async (option: any) => {
|
||||||
<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]">
|
setIsLoading(true);
|
||||||
{selectionsList.map((option: any) => (
|
try {
|
||||||
<div
|
|
||||||
key={option.selection_id}
|
|
||||||
onClick={() => {
|
|
||||||
if (task.id) {
|
if (task.id) {
|
||||||
updateTaskCustomColumnValue(task.id, columnKey, option.selection_name);
|
updateTaskCustomColumnValue(task.id, columnKey, option.selection_name);
|
||||||
}
|
}
|
||||||
setIsDropdownOpen(false);
|
setIsDropdownOpen(false);
|
||||||
}}
|
} finally {
|
||||||
className="flex items-center gap-2 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md cursor-pointer"
|
// 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
|
<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' }}
|
style={{ backgroundColor: option.selection_color || '#6b7280' }}
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-gray-900 dark:text-gray-100">{option.selection_name}</span>
|
<span className="text-sm font-medium flex-1">{option.selection_name}</span>
|
||||||
</div>
|
{selectedOption?.selection_id === option.selection_id && (
|
||||||
))}
|
<div className={`
|
||||||
{selectionsList.length === 0 && (
|
w-4 h-4 rounded-full flex items-center justify-center
|
||||||
<div className="text-center py-2 text-gray-500 dark:text-gray-400 text-sm">
|
${isDarkMode ? 'bg-blue-600' : 'bg-blue-500'}
|
||||||
No options available
|
`}>
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
</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 (
|
return (
|
||||||
|
<div className={`px-2 relative custom-column-cell ${isDropdownOpen ? 'focused' : ''}`}>
|
||||||
<Dropdown
|
<Dropdown
|
||||||
open={isDropdownOpen}
|
open={isDropdownOpen}
|
||||||
onOpenChange={setIsDropdownOpen}
|
onOpenChange={setIsDropdownOpen}
|
||||||
dropdownRender={() => dropdownContent}
|
dropdownRender={() => dropdownContent}
|
||||||
trigger={['click']}
|
trigger={['click']}
|
||||||
placement="bottomLeft"
|
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]">
|
<div className={`
|
||||||
{selectedOption ? (
|
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
|
<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' }}
|
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>
|
</div>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -240,6 +240,7 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
isSubtasksInclude: false,
|
isSubtasksInclude: false,
|
||||||
labels: selectedLabels,
|
labels: selectedLabels,
|
||||||
priorities: selectedPriorities,
|
priorities: selectedPriorities,
|
||||||
|
customColumns: true, // Include custom columns in the response
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await tasksApiService.getTaskListV3(config);
|
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[] }) => ({
|
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,
|
id: l.id || l.label_id,
|
||||||
name: l.name,
|
name: l.name,
|
||||||
color: l.color || '#1890ff',
|
color: l.color_code || '#1890ff',
|
||||||
end: l.end,
|
end: l.end,
|
||||||
names: l.names,
|
names: l.names,
|
||||||
})) || [],
|
})) || [],
|
||||||
@@ -275,6 +276,7 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
logged: convertTimeValue(task.time_spent),
|
logged: convertTimeValue(task.time_spent),
|
||||||
},
|
},
|
||||||
customFields: {},
|
customFields: {},
|
||||||
|
custom_column_values: task.custom_column_values || {}, // Include custom column values
|
||||||
createdAt: task.created_at || now,
|
createdAt: task.created_at || now,
|
||||||
updatedAt: task.updated_at || now,
|
updatedAt: task.updated_at || now,
|
||||||
created_at: task.created_at || now,
|
created_at: task.created_at || now,
|
||||||
|
|||||||
@@ -737,6 +737,30 @@ export const useTaskSocketHandlers = () => {
|
|||||||
[dispatch, taskGroups]
|
[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)
|
// Handler for TASK_ASSIGNEES_CHANGE (fallback event with limited data)
|
||||||
const handleTaskAssigneesChange = useCallback((data: { assigneeIds: string[] }) => {
|
const handleTaskAssigneesChange = useCallback((data: { assigneeIds: string[] }) => {
|
||||||
if (!data || !data.assigneeIds) return;
|
if (!data || !data.assigneeIds) return;
|
||||||
@@ -776,6 +800,7 @@ export const useTaskSocketHandlers = () => {
|
|||||||
},
|
},
|
||||||
{ event: SocketEvents.QUICK_TASK.toString(), handler: handleNewTaskReceived },
|
{ event: SocketEvents.QUICK_TASK.toString(), handler: handleNewTaskReceived },
|
||||||
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdated },
|
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdated },
|
||||||
|
{ event: SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), handler: handleCustomColumnUpdate },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Register all event listeners
|
// Register all event listeners
|
||||||
@@ -806,5 +831,6 @@ export const useTaskSocketHandlers = () => {
|
|||||||
handleTaskDescriptionChange,
|
handleTaskDescriptionChange,
|
||||||
handleNewTaskReceived,
|
handleNewTaskReceived,
|
||||||
handleTaskProgressUpdated,
|
handleTaskProgressUpdated,
|
||||||
|
handleCustomColumnUpdate,
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -146,3 +146,332 @@ Not supports in Firefox and IE */
|
|||||||
tr:hover .action-buttons {
|
tr:hover .action-buttons {
|
||||||
opacity: 1;
|
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;
|
logged?: number;
|
||||||
estimated?: number;
|
estimated?: number;
|
||||||
};
|
};
|
||||||
|
custom_column_values?: Record<string, any>; // Custom column values
|
||||||
|
isTemporary?: boolean; // Temporary task indicator
|
||||||
// Add any other task properties as needed
|
// Add any other task properties as needed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user