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:
chamiakJ
2025-07-07 02:04:05 +05:30
parent c70f8e7b6d
commit 174c6bcedf
11 changed files with 1072 additions and 215 deletions

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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';

View File

@@ -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;

View File

@@ -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]);

View File

@@ -25,6 +25,7 @@ import TaskTimeTracking from './TaskTimeTracking';
import { CustomNumberLabel, CustomColordLabel } from '@/components';
import LabelsSelector from '@/components/LabelsSelector';
import TaskPhaseDropdown from '@/components/task-management/task-phase-dropdown';
import { CustomColumnCell } from './components/CustomColumnComponents';
interface TaskRowProps {
taskId: string;
@@ -33,6 +34,10 @@ interface TaskRowProps {
id: string;
width: string;
isSticky?: boolean;
key?: string;
custom_column?: boolean;
custom_column_obj?: any;
isCustom?: boolean;
}>;
isSubtask?: boolean;
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
@@ -606,6 +611,19 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
);
default:
// Handle custom columns
const column = visibleColumns.find(col => col.id === columnId);
if (column && (column.custom_column || column.isCustom) && updateTaskCustomColumnValue) {
return (
<div style={baseStyle}>
<CustomColumnCell
column={column}
task={task}
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
/>
</div>
);
}
return null;
}
}, [
@@ -634,6 +652,10 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
// Translation
t,
// Custom columns
visibleColumns,
updateTaskCustomColumnValue,
]);
return (

View File

@@ -1,5 +1,5 @@
import React, { useState, useCallback, useMemo, memo } from 'react';
import { Button, Tooltip, Flex, Dropdown, DatePicker } from 'antd';
import React, { useState, useCallback, useMemo, memo, useEffect } from 'react';
import { Button, Tooltip, Flex, Dropdown, DatePicker, Input } from 'antd';
import { PlusOutlined, SettingOutlined, UsergroupAddOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import { useAppSelector } from '@/hooks/useAppSelector';
@@ -9,11 +9,14 @@ import {
toggleCustomColumnModalOpen,
} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
import PeopleDropdown from '@/components/common/people-dropdown/PeopleDropdown';
import AvatarGroup from '@/components/AvatarGroup';
import dayjs from 'dayjs';
// Add Custom Column Button Component
export const AddCustomColumnButton: React.FC = memo(() => {
const dispatch = useAppDispatch();
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
const { t } = useTranslation('task-list-table');
const handleModalOpen = useCallback(() => {
@@ -22,19 +25,29 @@ export const AddCustomColumnButton: React.FC = memo(() => {
}, [dispatch]);
return (
<Tooltip title={t('customColumns.addCustomColumn')}>
<Button
icon={<PlusOutlined />}
type="text"
size="small"
<Tooltip title={t('customColumns.addCustomColumn')} placement="top">
<button
onClick={handleModalOpen}
className="hover:bg-gray-100 dark:hover:bg-gray-700"
style={{
background: 'transparent',
border: 'none',
boxShadow: 'none',
}}
/>
className={`
group relative w-8 h-8 rounded-lg border-2 border-dashed transition-all duration-200
flex items-center justify-center
${isDarkMode
? 'border-gray-600 hover:border-blue-500 hover:bg-blue-500/10 text-gray-500 hover:text-blue-400'
: 'border-gray-300 hover:border-blue-500 hover:bg-blue-50 text-gray-400 hover:text-blue-600'
}
`}
>
<PlusOutlined className="text-xs transition-transform duration-200 group-hover:scale-110" />
{/* Subtle glow effect on hover */}
<div className={`
absolute inset-0 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200
${isDarkMode
? 'bg-blue-500/5 shadow-lg shadow-blue-500/20'
: 'bg-blue-500/5 shadow-lg shadow-blue-500/10'
}
`} />
</button>
</Tooltip>
);
});
@@ -55,7 +68,7 @@ export const CustomColumnHeader: React.FC<{
t('customColumns.customColumnHeader');
return (
<Flex align="center" justify="space-between" className="w-full">
<Flex align="center" justify="space-between" className="w-full px-2">
<span title={displayName}>{displayName}</span>
<Tooltip title={t('customColumns.customColumnSettings')}>
<SettingOutlined
@@ -126,7 +139,7 @@ export const CustomColumnCell: React.FC<{
/>
);
default:
return <span className="text-sm text-gray-400">{t('customColumns.unsupportedField')}</span>;
return <span className="text-sm text-gray-400 px-2">{t('customColumns.unsupportedField')}</span>;
}
});
@@ -139,13 +152,15 @@ export const PeopleCustomColumnCell: React.FC<{
customValue: any;
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
}> = memo(({ task, columnKey, customValue, updateTaskCustomColumnValue }) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const dispatch = useAppDispatch();
const { t } = useTranslation('task-list-table');
const [isLoading, setIsLoading] = useState(false);
const [pendingChanges, setPendingChanges] = useState<Set<string>>(new Set());
const [optimisticSelectedIds, setOptimisticSelectedIds] = useState<string[]>([]);
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
const themeMode = useAppSelector(state => state.themeReducer.mode);
const isDarkMode = themeMode === 'dark';
// Parse selected member IDs from custom value
const selectedMemberIds = useMemo(() => {
try {
return customValue ? JSON.parse(customValue) : [];
@@ -154,125 +169,90 @@ export const PeopleCustomColumnCell: React.FC<{
}
}, [customValue]);
const filteredMembers = useMemo(() => {
return members?.data?.filter(member =>
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
) || [];
}, [members, searchQuery]);
// Use optimistic updates when there are pending changes, otherwise use actual value
const displayedMemberIds = useMemo(() => {
// If we have pending changes, use optimistic state
if (pendingChanges.size > 0) {
return optimisticSelectedIds;
}
// Otherwise use the actual value from the server
return selectedMemberIds;
}, [pendingChanges.size, optimisticSelectedIds, selectedMemberIds]);
// Initialize optimistic state and update when actual value changes (from socket updates)
useEffect(() => {
// Only update optimistic state if there are no pending changes
// This prevents the socket update from overriding our optimistic state
if (pendingChanges.size === 0) {
setOptimisticSelectedIds(selectedMemberIds);
}
}, [selectedMemberIds, pendingChanges.size]);
const selectedMembers = useMemo(() => {
if (!members?.data || !selectedMemberIds.length) return [];
return members.data.filter(member => selectedMemberIds.includes(member.id));
}, [members, selectedMemberIds]);
if (!members?.data || !displayedMemberIds.length) return [];
return members.data.filter(member => displayedMemberIds.includes(member.id));
}, [members, displayedMemberIds]);
const handleMemberSelection = (memberId: string) => {
const newSelectedIds = selectedMemberIds.includes(memberId)
? selectedMemberIds.filter((id: string) => id !== memberId)
: [...selectedMemberIds, memberId];
const handleMemberToggle = useCallback((memberId: string, checked: boolean) => {
// Add to pending changes for visual feedback
setPendingChanges(prev => new Set(prev).add(memberId));
const newSelectedIds = checked
? [...selectedMemberIds, memberId]
: selectedMemberIds.filter((id: string) => id !== memberId);
// Update optimistic state immediately for instant UI feedback
setOptimisticSelectedIds(newSelectedIds);
if (task.id) {
updateTaskCustomColumnValue(task.id, columnKey, JSON.stringify(newSelectedIds));
}
};
const handleInviteProjectMember = () => {
dispatch(toggleProjectMemberDrawer());
};
// Remove from pending changes after socket update is processed
// Use a longer timeout to ensure the socket update has been received and processed
setTimeout(() => {
setPendingChanges(prev => {
const newSet = new Set<string>(Array.from(prev));
newSet.delete(memberId);
return newSet;
});
}, 1500); // Even longer delay to ensure socket update is fully processed
}, [selectedMemberIds, task.id, columnKey, updateTaskCustomColumnValue]);
const dropdownContent = (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-2 w-80">
<div className="flex flex-col gap-2">
<input
type="text"
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
placeholder={t('searchInputPlaceholder')}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
<div className="max-h-60 overflow-y-auto">
{filteredMembers.length > 0 ? (
filteredMembers.map(member => (
<div
key={member.id}
onClick={() => member.id && handleMemberSelection(member.id)}
className="flex items-center gap-2 p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md cursor-pointer"
>
<input
type="checkbox"
checked={member.id ? selectedMemberIds.includes(member.id) : false}
onChange={() => member.id && handleMemberSelection(member.id)}
className="w-4 h-4 text-blue-600 bg-gray-100 border-gray-300 rounded focus:ring-blue-500"
/>
<div className="w-8 h-8 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-sm font-medium text-gray-700 dark:text-gray-300">
{member.avatar_url ? (
<img src={member.avatar_url} alt={member.name} className="w-8 h-8 rounded-full" />
) : (
member.name?.charAt(0).toUpperCase()
)}
</div>
<div className="flex-1">
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">{member.name}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{member.email}</div>
</div>
</div>
))
) : (
<div className="text-center py-4 text-gray-500 dark:text-gray-400 text-sm">
{t('noMembersFound')}
</div>
)}
</div>
<div className="border-t border-gray-200 dark:border-gray-700 pt-2">
<button
onClick={handleInviteProjectMember}
className="w-full flex items-center justify-center gap-2 px-3 py-2 text-sm text-blue-600 hover:bg-blue-50 dark:hover:bg-blue-900/20 rounded-md"
>
<UsergroupAddOutlined className="w-4 h-4" />
{t('assigneeSelectorInviteButton')}
</button>
</div>
</div>
</div>
);
const loadMembers = useCallback(async () => {
if (members?.data?.length === 0) {
setIsLoading(true);
// The members are loaded through Redux, so we just need to wait
setTimeout(() => setIsLoading(false), 500);
}
}, [members]);
return (
<div className="flex items-center gap-1">
<div className="flex items-center gap-1 px-2 relative custom-column-cell">
{selectedMembers.length > 0 && (
<div className="flex -space-x-1">
{selectedMembers.slice(0, 3).map((member) => (
<div
key={member.id}
className="w-6 h-6 rounded-full bg-gray-300 dark:bg-gray-600 flex items-center justify-center text-xs font-medium text-gray-700 dark:text-gray-300 border-2 border-white dark:border-gray-800"
title={member.name}
>
{member.avatar_url ? (
<img src={member.avatar_url} alt={member.name} className="w-6 h-6 rounded-full" />
) : (
member.name?.charAt(0).toUpperCase()
)}
</div>
))}
{selectedMembers.length > 3 && (
<div className="w-6 h-6 rounded-full bg-gray-400 dark:bg-gray-500 flex items-center justify-center text-xs font-medium text-white border-2 border-white dark:border-gray-800">
+{selectedMembers.length - 3}
</div>
)}
</div>
<AvatarGroup
members={selectedMembers.map(member => ({
id: member.id,
team_member_id: member.id,
name: member.name,
avatar_url: member.avatar_url,
color_code: member.color_code,
}))}
maxCount={3}
size={24}
isDarkMode={isDarkMode}
/>
)}
<Dropdown
open={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
dropdownRender={() => dropdownContent}
trigger={['click']}
placement="bottomLeft"
>
<button className="w-6 h-6 rounded-full border-2 border-dashed border-gray-300 dark:border-gray-600 flex items-center justify-center hover:border-blue-500 dark:hover:border-blue-400 transition-colors">
<PlusOutlined className="w-3 h-3 text-gray-400 dark:text-gray-500" />
</button>
</Dropdown>
<PeopleDropdown
selectedMemberIds={displayedMemberIds}
onMemberToggle={handleMemberToggle}
isDarkMode={isDarkMode}
isLoading={isLoading}
loadMembers={loadMembers}
pendingChanges={pendingChanges}
buttonClassName="w-6 h-6"
/>
</div>
);
});
@@ -286,22 +266,46 @@ export const DateCustomColumnCell: React.FC<{
customValue: any;
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
}> = memo(({ task, columnKey, customValue, updateTaskCustomColumnValue }) => {
const [isOpen, setIsOpen] = useState(false);
const dateValue = customValue ? dayjs(customValue) : null;
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
const handleDateChange = (date: dayjs.Dayjs | null) => {
if (task.id) {
updateTaskCustomColumnValue(task.id, columnKey, date ? date.toISOString() : '');
}
setIsOpen(false);
};
return (
<DatePicker
value={dateValue}
onChange={date => {
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
/>
<div className={`px-2 relative custom-column-cell ${isOpen ? 'focused' : ''}`}>
<div className="relative">
<DatePicker
open={isOpen}
onOpenChange={setIsOpen}
value={dateValue}
onChange={handleDateChange}
placeholder={dateValue ? "" : "Click to set date"}
format="MMM DD, YYYY"
suffixIcon={null}
size="small"
variant="borderless"
className={`
w-full text-sm transition-colors duration-200 custom-column-date-picker
${isDarkMode ? 'dark-mode' : 'light-mode'}
`}
popupClassName={isDarkMode ? 'dark-date-picker' : 'light-date-picker'}
inputReadOnly
getPopupContainer={(trigger) => trigger.parentElement || document.body}
style={{
backgroundColor: 'transparent',
border: 'none',
boxShadow: 'none',
width: '100%',
}}
/>
</div>
</div>
);
});
@@ -315,14 +319,20 @@ export const NumberCustomColumnCell: React.FC<{
columnObj: any;
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => {
const [inputValue, setInputValue] = useState(customValue || '');
const [inputValue, setInputValue] = useState(String(customValue || ''));
const [isEditing, setIsEditing] = useState(false);
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
const numberType = columnObj?.numberType || 'formatted';
const decimals = columnObj?.decimals || 0;
const label = columnObj?.label || '';
const labelPosition = columnObj?.labelPosition || 'left';
// Sync inputValue with customValue to prevent NaN issues
useEffect(() => {
setInputValue(String(customValue || ''));
}, [customValue]);
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
// Allow only numbers, decimal point, and minus sign
@@ -331,10 +341,22 @@ export const NumberCustomColumnCell: React.FC<{
}
};
const handleFocus = () => {
setIsEditing(true);
};
const handleBlur = () => {
setIsEditing(false);
// Only update if there's a valid value and it's different from the current value
if (task.id && inputValue !== customValue) {
updateTaskCustomColumnValue(task.id, columnKey, inputValue);
// Safely convert inputValue to string to avoid .trim() errors
const stringValue = String(inputValue || '');
// Don't save empty values or invalid numbers
if (stringValue.trim() === '' || isNaN(parseFloat(stringValue))) {
setInputValue(customValue || ''); // Reset to original value
} else {
updateTaskCustomColumnValue(task.id, columnKey, stringValue);
}
}
};
@@ -351,10 +373,12 @@ export const NumberCustomColumnCell: React.FC<{
const getDisplayValue = () => {
if (isEditing) return inputValue;
if (!inputValue) return '';
// Safely convert inputValue to string to avoid .trim() errors
const stringValue = String(inputValue || '');
if (!stringValue || stringValue.trim() === '') return '';
const numValue = parseFloat(inputValue);
if (isNaN(numValue)) return inputValue;
const numValue = parseFloat(stringValue);
if (isNaN(numValue)) return ''; // Return empty string instead of showing NaN
switch (numberType) {
case 'formatted':
@@ -364,28 +388,36 @@ export const NumberCustomColumnCell: React.FC<{
case 'withLabel':
return labelPosition === 'left' ? `${label} ${numValue.toFixed(decimals)}` : `${numValue.toFixed(decimals)} ${label}`;
default:
return inputValue;
return numValue.toString();
}
};
const addonBefore = numberType === 'withLabel' && labelPosition === 'left' ? label : undefined;
const addonAfter = numberType === 'withLabel' && labelPosition === 'right' ? label : undefined;
return (
<div className="flex items-center gap-1">
{numberType === 'withLabel' && labelPosition === 'left' && (
<span className="text-xs text-gray-500 dark:text-gray-400">{label}</span>
)}
<input
type="text"
<div className="px-2">
<Input
value={getDisplayValue()}
onChange={handleInputChange}
onFocus={() => setIsEditing(true)}
onFocus={handleFocus}
onBlur={handleBlur}
onKeyDown={handleKeyDown}
className="w-full bg-transparent border-none text-sm text-right focus:outline-none focus:bg-gray-50 dark:focus:bg-gray-700 px-1 py-0.5 rounded"
placeholder="0"
placeholder={numberType === 'percentage' ? '0%' : '0'}
size="small"
variant="borderless"
addonBefore={addonBefore}
addonAfter={addonAfter}
style={{
textAlign: 'right',
width: '100%',
minWidth: 0,
}}
className={`
custom-column-number-input
${isDarkMode ? 'dark-mode' : 'light-mode'}
`}
/>
{numberType === 'withLabel' && labelPosition === 'right' && (
<span className="text-xs text-gray-500 dark:text-gray-400">{label}</span>
)}
</div>
);
});
@@ -401,60 +433,152 @@ export const SelectionCustomColumnCell: React.FC<{
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => {
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
const [isLoading, setIsLoading] = useState(false);
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
const selectionsList = columnObj?.selectionsList || [];
const selectedOption = selectionsList.find((option: any) => option.selection_name === customValue);
const 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 = (
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 p-1 min-w-[150px]">
{selectionsList.map((option: any) => (
<div
key={option.selection_id}
onClick={() => {
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"
>
<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
className="w-3 h-3 rounded-full"
style={{ backgroundColor: option.selection_color || '#6b7280' }}
/>
<span className="text-sm text-gray-900 dark:text-gray-100">{option.selection_name}</span>
</div>
))}
{selectionsList.length === 0 && (
<div className="text-center py-2 text-gray-500 dark:text-gray-400 text-sm">
No options available
</div>
)}
key={option.selection_id}
onClick={() => handleOptionSelect(option)}
className={`
flex items-center gap-3 p-2 rounded-md cursor-pointer transition-all duration-200
${selectedOption?.selection_id === option.selection_id
? isDarkMode
? 'bg-blue-900/50 text-blue-200'
: 'bg-blue-50 text-blue-700'
: isDarkMode
? 'hover:bg-gray-700 text-gray-200'
: 'hover:bg-gray-100 text-gray-900'
}
`}
>
<div
className="w-3 h-3 rounded-full border border-white/20 shadow-sm"
style={{ backgroundColor: option.selection_color || '#6b7280' }}
/>
<span className="text-sm font-medium flex-1">{option.selection_name}</span>
{selectedOption?.selection_id === option.selection_id && (
<div className={`
w-4 h-4 rounded-full flex items-center justify-center
${isDarkMode ? 'bg-blue-600' : 'bg-blue-500'}
`}>
<svg className="w-2.5 h-2.5 text-white" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
</svg>
</div>
)}
</div>
))}
{selectionsList.length === 0 && (
<div className={`
text-center py-8 text-sm
${isDarkMode ? 'text-gray-500' : 'text-gray-400'}
`}>
<div className="mb-2">📋</div>
<div>No options available</div>
</div>
)}
</div>
</div>
);
return (
<Dropdown
open={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
dropdownRender={() => dropdownContent}
trigger={['click']}
placement="bottomLeft"
>
<div className="flex items-center gap-2 cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700 p-1 rounded min-h-[24px]">
{selectedOption ? (
<>
<div
className="w-3 h-3 rounded-full"
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 text-gray-400 dark:text-gray-500">Select option</span>
)}
</div>
</Dropdown>
<div className={`px-2 relative custom-column-cell ${isDropdownOpen ? 'focused' : ''}`}>
<Dropdown
open={isDropdownOpen}
onOpenChange={setIsDropdownOpen}
dropdownRender={() => dropdownContent}
trigger={['click']}
placement="bottomLeft"
overlayClassName="custom-selection-dropdown"
getPopupContainer={(trigger) => trigger.parentElement || document.body}
>
<div className={`
flex items-center gap-2 cursor-pointer rounded-md px-2 py-1 min-h-[28px] transition-all duration-200 relative
${isDropdownOpen
? isDarkMode
? 'bg-gray-700 ring-1 ring-blue-500/50'
: 'bg-gray-100 ring-1 ring-blue-500/50'
: isDarkMode
? 'hover:bg-gray-700/50'
: 'hover:bg-gray-100/50'
}
`}>
{isLoading ? (
<div className="flex items-center gap-2">
<div className={`
w-3 h-3 rounded-full animate-spin border-2 border-transparent
${isDarkMode ? 'border-t-gray-400' : 'border-t-gray-600'}
`} />
<span className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
Updating...
</span>
</div>
) : selectedOption ? (
<>
<div
className="w-3 h-3 rounded-full border border-white/20 shadow-sm"
style={{ backgroundColor: selectedOption.selection_color || '#6b7280' }}
/>
<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>
</>
) : (
<>
<div className={`w-3 h-3 rounded-full border-2 border-dashed ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`} />
<span className={`text-sm ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`}>
Select option
</span>
<svg className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</>
)}
</div>
</Dropdown>
</div>
);
});

View File

@@ -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,

View File

@@ -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,
]);
};

View File

@@ -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;
}

View File

@@ -43,6 +43,8 @@ export interface Task {
logged?: number;
estimated?: number;
};
custom_column_values?: Record<string, any>; // Custom column values
isTemporary?: boolean; // Temporary task indicator
// Add any other task properties as needed
}