diff --git a/worklenz-backend/src/controllers/tasks-controller-v2.ts b/worklenz-backend/src/controllers/tasks-controller-v2.ts index 9d0e31d8..162d5dd2 100644 --- a/worklenz-backend/src/controllers/tasks-controller-v2.ts +++ b/worklenz-backend/src/controllers/tasks-controller-v2.ts @@ -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, diff --git a/worklenz-frontend/src/api/tasks/tasks.api.service.ts b/worklenz-frontend/src/api/tasks/tasks.api.service.ts index c348fdbe..1b18c0f3 100644 --- a/worklenz-frontend/src/api/tasks/tasks.api.service.ts +++ b/worklenz-frontend/src/api/tasks/tasks.api.service.ts @@ -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 { diff --git a/worklenz-frontend/src/components/AssigneeSelector.tsx b/worklenz-frontend/src/components/AssigneeSelector.tsx index 1ca0cdf8..54ea30fd 100644 --- a/worklenz-frontend/src/components/AssigneeSelector.tsx +++ b/worklenz-frontend/src/components/AssigneeSelector.tsx @@ -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'; diff --git a/worklenz-frontend/src/components/common/people-dropdown/PeopleDropdown.tsx b/worklenz-frontend/src/components/common/people-dropdown/PeopleDropdown.tsx new file mode 100644 index 00000000..c2f7ac78 --- /dev/null +++ b/worklenz-frontend/src/components/common/people-dropdown/PeopleDropdown.tsx @@ -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; +} + +const PeopleDropdown: React.FC = ({ + 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({ data: [], total: 0 }); + const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 }); + const [hasLoadedMembers, setHasLoadedMembers] = useState(false); + + const dropdownRef = useRef(null); + const buttonRef = useRef(null); + const searchInputRef = useRef(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 ( + <> + + + {isOpen && + createPortal( +
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 */} +
+ 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 + `} + /> +
+ + {/* Members List */} +
+ {filteredMembers && filteredMembers.length > 0 ? ( + filteredMembers.map(member => ( +
{ + 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', + }} + > +
+ e.stopPropagation()}> + handleMemberToggle(member.id || '', checked)} + disabled={ + member.pending_invitation || pendingChanges.has(member.id || '') + } + isDarkMode={isDarkMode} + /> + + {pendingChanges.has(member.id || '') && ( +
+
+
+ )} +
+ + + +
+
+ {member.name} +
+
+ {member.email} + {member.pending_invitation && ( + (Pending) + )} +
+
+
+ )) + ) : ( +
+
+ {isLoading ? 'Loading members...' : 'No members found'} +
+
+ )} +
+ + {/* Footer */} +
+ +
+
, + document.body + )} + + ); +}; + +export default PeopleDropdown; \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx index 59ca0d85..a23d106e 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskListV2.tsx @@ -151,17 +151,28 @@ const TaskListV2: React.FC = () => { // Add visible custom columns const visibleCustomColumns = customColumns ?.filter(column => column.pinned) - ?.map(column => ({ - id: column.key || column.id || 'unknown', - label: column.name || t('customColumns.customColumnHeader'), - width: `${(column as any).width || 120}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, - })) || []; + ?.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 || 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]); diff --git a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx index 38d20402..823b43fe 100644 --- a/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx +++ b/worklenz-frontend/src/components/task-list-v2/TaskRow.tsx @@ -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 = 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 ( +
+ +
+ ); + } return null; } }, [ @@ -634,6 +652,10 @@ const TaskRow: React.FC = memo(({ taskId, projectId, visibleColumn // Translation t, + + // Custom columns + visibleColumns, + updateTaskCustomColumnValue, ]); return ( diff --git a/worklenz-frontend/src/components/task-list-v2/components/CustomColumnComponents.tsx b/worklenz-frontend/src/components/task-list-v2/components/CustomColumnComponents.tsx index bc37ce9b..cbe895a9 100644 --- a/worklenz-frontend/src/components/task-list-v2/components/CustomColumnComponents.tsx +++ b/worklenz-frontend/src/components/task-list-v2/components/CustomColumnComponents.tsx @@ -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 ( - - ); }); @@ -55,7 +68,7 @@ export const CustomColumnHeader: React.FC<{ t('customColumns.customColumnHeader'); return ( - + {displayName} ); default: - return {t('customColumns.unsupportedField')}; + return {t('customColumns.unsupportedField')}; } }); @@ -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>(new Set()); + const [optimisticSelectedIds, setOptimisticSelectedIds] = useState([]); 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(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 = ( -
-
- 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" - /> - -
- {filteredMembers.length > 0 ? ( - filteredMembers.map(member => ( -
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" - > - member.id && handleMemberSelection(member.id)} - className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500" - /> -
- {member.avatar_url ? ( - {member.name} - ) : ( - member.name?.charAt(0).toUpperCase() - )} -
-
-
{member.name}
-
{member.email}
-
-
- )) - ) : ( -
- {t('noMembersFound')} -
- )} -
- -
- -
-
-
- ); + 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 ( -
+
{selectedMembers.length > 0 && ( -
- {selectedMembers.slice(0, 3).map((member) => ( -
- {member.avatar_url ? ( - {member.name} - ) : ( - member.name?.charAt(0).toUpperCase() - )} -
- ))} - {selectedMembers.length > 3 && ( -
- +{selectedMembers.length - 3} -
- )} -
+ ({ + 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} + /> )} - dropdownContent} - trigger={['click']} - placement="bottomLeft" - > - - +
); }); @@ -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'); + + const handleDateChange = (date: dayjs.Dayjs | null) => { + if (task.id) { + updateTaskCustomColumnValue(task.id, columnKey, date ? date.toISOString() : ''); + } + setIsOpen(false); + }; return ( - { - if (task.id) { - updateTaskCustomColumnValue(task.id, columnKey, date ? date.toISOString() : ''); - } - }} - placeholder="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" - inputReadOnly - /> +
+
+ trigger.parentElement || document.body} + style={{ + backgroundColor: 'transparent', + border: 'none', + boxShadow: 'none', + width: '100%', + }} + /> +
+
); }); @@ -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) => { 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 ( -
- {numberType === 'withLabel' && labelPosition === 'left' && ( - {label} - )} - + 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' && ( - {label} - )}
); }); @@ -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 handleOptionSelect = async (option: any) => { + setIsLoading(true); + try { + if (task.id) { + updateTaskCustomColumnValue(task.id, columnKey, option.selection_name); + } + setIsDropdownOpen(false); + } finally { + // Small delay to show loading state + setTimeout(() => setIsLoading(false), 200); + } + }; + const dropdownContent = ( -
- {selectionsList.map((option: any) => ( -
{ - 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" - > +
+ {/* Header */} +
+ Select an option +
+ + {/* Options */} +
+ {selectionsList.map((option: any) => (
- {option.selection_name} -
- ))} - {selectionsList.length === 0 && ( -
- No options available -
- )} + 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' + } + `} + > +
+ {option.selection_name} + {selectedOption?.selection_id === option.selection_id && ( +
+ + + +
+ )} +
+ ))} + + {selectionsList.length === 0 && ( +
+
📋
+
No options available
+
+ )} +
); return ( - dropdownContent} - trigger={['click']} - placement="bottomLeft" - > -
- {selectedOption ? ( - <> -
- {selectedOption.selection_name} - - ) : ( - Select option - )} -
- +
+ dropdownContent} + trigger={['click']} + placement="bottomLeft" + overlayClassName="custom-selection-dropdown" + getPopupContainer={(trigger) => trigger.parentElement || document.body} + > +
+ {isLoading ? ( +
+
+ + Updating... + +
+ ) : selectedOption ? ( + <> +
+ + {selectedOption.selection_name} + + + + + + ) : ( + <> +
+ + Select option + + + + + + )} +
+ +
); }); diff --git a/worklenz-frontend/src/features/task-management/task-management.slice.ts b/worklenz-frontend/src/features/task-management/task-management.slice.ts index fd661a54..48e3901e 100644 --- a/worklenz-frontend/src/features/task-management/task-management.slice.ts +++ b/worklenz-frontend/src/features/task-management/task-management.slice.ts @@ -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, diff --git a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts index 7542076e..63ad3ae4 100644 --- a/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts +++ b/worklenz-frontend/src/hooks/useTaskSocketHandlers.ts @@ -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, ]); }; diff --git a/worklenz-frontend/src/index.css b/worklenz-frontend/src/index.css index 78351f5e..ae4a2152 100644 --- a/worklenz-frontend/src/index.css +++ b/worklenz-frontend/src/index.css @@ -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; +} diff --git a/worklenz-frontend/src/types/task-management.types.ts b/worklenz-frontend/src/types/task-management.types.ts index 6b3e559d..791fd212 100644 --- a/worklenz-frontend/src/types/task-management.types.ts +++ b/worklenz-frontend/src/types/task-management.types.ts @@ -43,6 +43,8 @@ export interface Task { logged?: number; estimated?: number; }; + custom_column_values?: Record; // Custom column values + isTemporary?: boolean; // Temporary task indicator // Add any other task properties as needed }