feat(task-management): enhance task details and subtask handling
- Added subtask-related properties to the Task interface for better management of subtasks. - Implemented functionality to show and add subtasks directly within the task list, improving user interaction. - Updated task rendering logic to accommodate new subtask features, enhancing overall task management experience. - Removed unused components and optimized imports across various task management files for cleaner code.
This commit is contained in:
@@ -997,11 +997,9 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
const shouldRefreshProgress = req.query.refresh_progress === "true";
|
||||
|
||||
if (shouldRefreshProgress && req.params.id) {
|
||||
console.log(`[PERFORMANCE] Starting progress refresh for project ${req.params.id}`);
|
||||
const progressStartTime = performance.now();
|
||||
await this.refreshProjectTaskProgressValues(req.params.id);
|
||||
const progressEndTime = performance.now();
|
||||
console.log(`[PERFORMANCE] Progress refresh completed in ${(progressEndTime - progressStartTime).toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
const queryStartTime = performance.now();
|
||||
@@ -1011,13 +1009,11 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
const result = await db.query(q, params);
|
||||
const tasks = [...result.rows];
|
||||
const queryEndTime = performance.now();
|
||||
console.log(`[PERFORMANCE] Database query completed in ${(queryEndTime - queryStartTime).toFixed(2)}ms for ${tasks.length} tasks`);
|
||||
|
||||
// Get groups metadata dynamically from database
|
||||
const groupsStartTime = performance.now();
|
||||
const groups = await this.getGroups(groupBy, req.params.id);
|
||||
const groupsEndTime = performance.now();
|
||||
console.log(`[PERFORMANCE] Groups fetched in ${(groupsEndTime - groupsStartTime).toFixed(2)}ms`);
|
||||
|
||||
// Create priority value to name mapping
|
||||
const priorityMap: Record<string, string> = {
|
||||
@@ -1094,10 +1090,17 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
originalPriorityId: task.priority,
|
||||
statusColor: task.status_color,
|
||||
priorityColor: task.priority_color,
|
||||
// Add subtask count
|
||||
sub_tasks_count: task.sub_tasks_count || 0,
|
||||
// Add indicator fields for frontend icons
|
||||
comments_count: task.comments_count || 0,
|
||||
has_subscribers: !!task.has_subscribers,
|
||||
attachments_count: task.attachments_count || 0,
|
||||
has_dependencies: !!task.has_dependencies,
|
||||
schedule_id: task.schedule_id || null,
|
||||
};
|
||||
});
|
||||
const transformEndTime = performance.now();
|
||||
console.log(`[PERFORMANCE] Task transformation completed in ${(transformEndTime - transformStartTime).toFixed(2)}ms`);
|
||||
|
||||
// Create groups based on dynamic data from database
|
||||
const groupingStartTime = performance.now();
|
||||
@@ -1164,11 +1167,9 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
.filter(group => group && (group.tasks.length > 0 || req.query.include_empty === "true"));
|
||||
|
||||
const groupingEndTime = performance.now();
|
||||
console.log(`[PERFORMANCE] Task grouping completed in ${(groupingEndTime - groupingStartTime).toFixed(2)}ms`);
|
||||
|
||||
const endTime = performance.now();
|
||||
const totalTime = endTime - startTime;
|
||||
console.log(`[PERFORMANCE] Total getTasksV3 request completed in ${totalTime.toFixed(2)}ms for project ${req.params.id}`);
|
||||
|
||||
// Log warning if request is taking too long
|
||||
if (totalTime > 1000) {
|
||||
@@ -1235,9 +1236,8 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
||||
projectId: req.params.id
|
||||
}
|
||||
}));
|
||||
} else {
|
||||
return res.status(400).send(new ServerResponse(false, null, "Project ID is required"));
|
||||
}
|
||||
return res.status(400).send(new ServerResponse(false, null, "Project ID is required"));
|
||||
} catch (error) {
|
||||
console.error("Error refreshing task progress:", error);
|
||||
return res.status(500).send(new ServerResponse(false, null, "Failed to refresh task progress"));
|
||||
|
||||
@@ -64,11 +64,9 @@ apiClient.interceptors.request.use(
|
||||
|
||||
// Ensure we have a CSRF token before making requests
|
||||
if (!csrfToken) {
|
||||
console.log('[API CLIENT] No CSRF token, fetching...');
|
||||
const tokenStart = performance.now();
|
||||
await refreshCsrfToken();
|
||||
const tokenEnd = performance.now();
|
||||
console.log(`[API CLIENT] CSRF token fetch took ${(tokenEnd - tokenStart).toFixed(2)}ms`);
|
||||
}
|
||||
|
||||
if (csrfToken) {
|
||||
@@ -78,7 +76,6 @@ apiClient.interceptors.request.use(
|
||||
}
|
||||
|
||||
const requestEnd = performance.now();
|
||||
console.log(`[API CLIENT] Request interceptor took ${(requestEnd - requestStart).toFixed(2)}ms`);
|
||||
|
||||
return config;
|
||||
},
|
||||
|
||||
@@ -1,265 +0,0 @@
|
||||
import React, { useState, useCallback, Suspense } from 'react';
|
||||
import { Card, Typography, Space, Button, Divider } from 'antd';
|
||||
import {
|
||||
UserAddOutlined,
|
||||
CalendarOutlined,
|
||||
FlagOutlined,
|
||||
TagOutlined,
|
||||
LoadingOutlined
|
||||
} from '@ant-design/icons';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
// Simulate heavy components that would normally load immediately
|
||||
const HeavyAssigneeSelector = React.lazy(() =>
|
||||
new Promise<{ default: React.ComponentType }>((resolve) =>
|
||||
setTimeout(() => resolve({
|
||||
default: () => (
|
||||
<div className="p-4 border rounded-sm bg-blue-50">
|
||||
<Text strong>🚀 Heavy Assignee Selector Loaded!</Text>
|
||||
<br />
|
||||
<Text type="secondary">This component contains:</Text>
|
||||
<ul className="mt-2 text-sm">
|
||||
<li>Team member search logic</li>
|
||||
<li>Avatar rendering</li>
|
||||
<li>Permission checking</li>
|
||||
<li>Socket connections</li>
|
||||
<li>Optimistic updates</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}), 1000) // Simulate 1s load time
|
||||
)
|
||||
);
|
||||
|
||||
const HeavyDatePicker = React.lazy(() =>
|
||||
new Promise<{ default: React.ComponentType }>((resolve) =>
|
||||
setTimeout(() => resolve({
|
||||
default: () => (
|
||||
<div className="p-4 border rounded-sm bg-green-50">
|
||||
<Text strong>📅 Heavy Date Picker Loaded!</Text>
|
||||
<br />
|
||||
<Text type="secondary">This component contains:</Text>
|
||||
<ul className="mt-2 text-sm">
|
||||
<li>Calendar rendering logic</li>
|
||||
<li>Date validation</li>
|
||||
<li>Timezone handling</li>
|
||||
<li>Locale support</li>
|
||||
<li>Accessibility features</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}), 800) // Simulate 0.8s load time
|
||||
)
|
||||
);
|
||||
|
||||
const HeavyPrioritySelector = React.lazy(() =>
|
||||
new Promise<{ default: React.ComponentType }>((resolve) =>
|
||||
setTimeout(() => resolve({
|
||||
default: () => (
|
||||
<div className="p-4 border rounded-sm bg-orange-50">
|
||||
<Text strong>🔥 Heavy Priority Selector Loaded!</Text>
|
||||
<br />
|
||||
<Text type="secondary">This component contains:</Text>
|
||||
<ul className="mt-2 text-sm">
|
||||
<li>Priority level logic</li>
|
||||
<li>Color calculations</li>
|
||||
<li>Business rules</li>
|
||||
<li>Validation</li>
|
||||
<li>State management</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}), 600) // Simulate 0.6s load time
|
||||
)
|
||||
);
|
||||
|
||||
const HeavyLabelsSelector = React.lazy(() =>
|
||||
new Promise<{ default: React.ComponentType }>((resolve) =>
|
||||
setTimeout(() => resolve({
|
||||
default: () => (
|
||||
<div className="p-4 border rounded-sm bg-purple-50">
|
||||
<Text strong>🏷️ Heavy Labels Selector Loaded!</Text>
|
||||
<br />
|
||||
<Text type="secondary">This component contains:</Text>
|
||||
<ul className="mt-2 text-sm">
|
||||
<li>Label management</li>
|
||||
<li>Color picker</li>
|
||||
<li>Search functionality</li>
|
||||
<li>CRUD operations</li>
|
||||
<li>Drag & drop</li>
|
||||
</ul>
|
||||
</div>
|
||||
)
|
||||
}), 700) // Simulate 0.7s load time
|
||||
)
|
||||
);
|
||||
|
||||
// Lightweight placeholder buttons (what loads immediately)
|
||||
const PlaceholderButton: React.FC<{
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
loaded?: boolean;
|
||||
}> = ({ icon, label, onClick, loaded = false }) => (
|
||||
<Button
|
||||
size="small"
|
||||
icon={loaded ? <LoadingOutlined spin /> : icon}
|
||||
onClick={onClick}
|
||||
className={`${loaded ? 'border-blue-500 bg-blue-50' : ''}`}
|
||||
>
|
||||
{loaded ? 'Loading...' : label}
|
||||
</Button>
|
||||
);
|
||||
|
||||
const AsanaStyleLazyDemo: React.FC = () => {
|
||||
const [loadedComponents, setLoadedComponents] = useState<{
|
||||
assignee: boolean;
|
||||
date: boolean;
|
||||
priority: boolean;
|
||||
labels: boolean;
|
||||
}>({
|
||||
assignee: false,
|
||||
date: false,
|
||||
priority: false,
|
||||
labels: false,
|
||||
});
|
||||
|
||||
const [showComponents, setShowComponents] = useState<{
|
||||
assignee: boolean;
|
||||
date: boolean;
|
||||
priority: boolean;
|
||||
labels: boolean;
|
||||
}>({
|
||||
assignee: false,
|
||||
date: false,
|
||||
priority: false,
|
||||
labels: false,
|
||||
});
|
||||
|
||||
const handleLoad = useCallback((component: keyof typeof loadedComponents) => {
|
||||
setLoadedComponents(prev => ({ ...prev, [component]: true }));
|
||||
setTimeout(() => {
|
||||
setShowComponents(prev => ({ ...prev, [component]: true }));
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
const resetDemo = useCallback(() => {
|
||||
setLoadedComponents({
|
||||
assignee: false,
|
||||
date: false,
|
||||
priority: false,
|
||||
labels: false,
|
||||
});
|
||||
setShowComponents({
|
||||
assignee: false,
|
||||
date: false,
|
||||
priority: false,
|
||||
labels: false,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Card className="max-w-4xl mx-auto">
|
||||
<Title level={3}>🎯 Asana-Style Lazy Loading Demo</Title>
|
||||
|
||||
<div className="mb-4 p-4 bg-gray-50 rounded-sm">
|
||||
<Text strong>Performance Benefits:</Text>
|
||||
<ul className="mt-2 text-sm">
|
||||
<li>✅ <strong>Faster Initial Load:</strong> Only lightweight placeholders load initially</li>
|
||||
<li>✅ <strong>Reduced Bundle Size:</strong> Heavy components split into separate chunks</li>
|
||||
<li>✅ <strong>Better UX:</strong> Instant visual feedback, components load on demand</li>
|
||||
<li>✅ <strong>Memory Efficient:</strong> Components only consume memory when needed</li>
|
||||
<li>✅ <strong>Network Optimized:</strong> Parallel loading of components as user interacts</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Text strong>Task Management Components (Click to Load):</Text>
|
||||
<div className="mt-2 flex gap-2 flex-wrap">
|
||||
<PlaceholderButton
|
||||
icon={<UserAddOutlined />}
|
||||
label="Add Assignee"
|
||||
onClick={() => handleLoad('assignee')}
|
||||
loaded={loadedComponents.assignee && !showComponents.assignee}
|
||||
/>
|
||||
<PlaceholderButton
|
||||
icon={<CalendarOutlined />}
|
||||
label="Set Date"
|
||||
onClick={() => handleLoad('date')}
|
||||
loaded={loadedComponents.date && !showComponents.date}
|
||||
/>
|
||||
<PlaceholderButton
|
||||
icon={<FlagOutlined />}
|
||||
label="Set Priority"
|
||||
onClick={() => handleLoad('priority')}
|
||||
loaded={loadedComponents.priority && !showComponents.priority}
|
||||
/>
|
||||
<PlaceholderButton
|
||||
icon={<TagOutlined />}
|
||||
label="Add Labels"
|
||||
onClick={() => handleLoad('labels')}
|
||||
loaded={loadedComponents.labels && !showComponents.labels}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button onClick={resetDemo} size="small">
|
||||
Reset Demo
|
||||
</Button>
|
||||
<Text type="secondary" className="self-center">
|
||||
Components loaded: {Object.values(showComponents).filter(Boolean).length}/4
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="space-y-4">
|
||||
{showComponents.assignee && (
|
||||
<Suspense fallback={<div className="p-4 border rounded-sm bg-gray-100">Loading assignee selector...</div>}>
|
||||
<HeavyAssigneeSelector />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{showComponents.date && (
|
||||
<Suspense fallback={<div className="p-4 border rounded-sm bg-gray-100">Loading date picker...</div>}>
|
||||
<HeavyDatePicker />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{showComponents.priority && (
|
||||
<Suspense fallback={<div className="p-4 border rounded-sm bg-gray-100">Loading priority selector...</div>}>
|
||||
<HeavyPrioritySelector />
|
||||
</Suspense>
|
||||
)}
|
||||
|
||||
{showComponents.labels && (
|
||||
<Suspense fallback={<div className="p-4 border rounded-sm bg-gray-100">Loading labels selector...</div>}>
|
||||
<HeavyLabelsSelector />
|
||||
</Suspense>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Divider />
|
||||
|
||||
<div className="text-sm text-gray-600">
|
||||
<Text strong>How it works:</Text>
|
||||
<ol className="mt-2 space-y-1">
|
||||
<li>1. Page loads instantly with lightweight placeholder buttons</li>
|
||||
<li>2. User clicks a button to interact with a feature</li>
|
||||
<li>3. Heavy component starts loading in the background</li>
|
||||
<li>4. Loading state shows immediate feedback</li>
|
||||
<li>5. Full component renders when ready</li>
|
||||
<li>6. Subsequent interactions are instant (component cached)</li>
|
||||
</ol>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default AsanaStyleLazyDemo;
|
||||
@@ -1,270 +0,0 @@
|
||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { UserAddOutlined } from '@ant-design/icons';
|
||||
import { RootState } from '@/app/store';
|
||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { Avatar, Button, Checkbox } from '@/components';
|
||||
import { sortTeamMembers } from '@/utils/sort-team-members';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
||||
import { ILocalSession } from '@/types/auth/session.types';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import { DefaultEventsMap } from '@socket.io/component-emitter';
|
||||
import { ThunkDispatch } from '@reduxjs/toolkit';
|
||||
import { Dispatch } from 'redux';
|
||||
|
||||
interface AssigneeDropdownContentProps {
|
||||
task: IProjectTask;
|
||||
groupId?: string | null;
|
||||
isDarkMode?: boolean;
|
||||
projectId: string | null;
|
||||
currentSession: ILocalSession | null;
|
||||
socket: Socket<DefaultEventsMap, DefaultEventsMap> | null;
|
||||
dispatch: ThunkDispatch<any, any, any> & Dispatch<any>;
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
position: { top: number; left: number };
|
||||
}
|
||||
|
||||
const AssigneeDropdownContent: React.FC<AssigneeDropdownContentProps> = ({
|
||||
task,
|
||||
groupId = null,
|
||||
isDarkMode = false,
|
||||
projectId,
|
||||
currentSession,
|
||||
socket,
|
||||
dispatch,
|
||||
isOpen,
|
||||
onClose,
|
||||
position,
|
||||
}) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [teamMembers, setTeamMembers] = useState<ITeamMembersViewModel>({ data: [], total: 0 });
|
||||
const [optimisticAssignees, setOptimisticAssignees] = useState<string[]>([]);
|
||||
const [pendingChanges, setPendingChanges] = useState<Set<string>>(new Set());
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const members = useSelector((state: RootState) => state.teamMembersReducer.teamMembers);
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
return teamMembers?.data?.filter(member =>
|
||||
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}, [teamMembers, searchQuery]);
|
||||
|
||||
// Initialize team members data when component mounts
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
|
||||
const membersData = (members?.data || []).map(member => ({
|
||||
...member,
|
||||
selected: assignees?.includes(member.id),
|
||||
}));
|
||||
const sortedMembers = sortTeamMembers(membersData);
|
||||
setTeamMembers({ data: sortedMembers });
|
||||
|
||||
// Focus search input after opening
|
||||
setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
}, [isOpen, members, task]);
|
||||
|
||||
const handleMemberToggle = useCallback((memberId: string, checked: boolean) => {
|
||||
if (!memberId || !projectId || !task?.id || !currentSession?.id) return;
|
||||
|
||||
// Add to pending changes for visual feedback
|
||||
setPendingChanges(prev => new Set(prev).add(memberId));
|
||||
|
||||
// OPTIMISTIC UPDATE: Update local state immediately for instant UI feedback
|
||||
const currentAssignees = task?.assignees?.map(a => a.team_member_id) || [];
|
||||
let newAssigneeIds: string[];
|
||||
|
||||
if (checked) {
|
||||
// Adding assignee
|
||||
newAssigneeIds = [...currentAssignees, memberId];
|
||||
} else {
|
||||
// Removing assignee
|
||||
newAssigneeIds = currentAssignees.filter(id => id !== memberId);
|
||||
}
|
||||
|
||||
// Update optimistic state for immediate UI feedback in dropdown
|
||||
setOptimisticAssignees(newAssigneeIds);
|
||||
|
||||
// Update local team members state for dropdown UI
|
||||
setTeamMembers(prev => ({
|
||||
...prev,
|
||||
data: (prev.data || []).map(member =>
|
||||
member.id === memberId
|
||||
? { ...member, selected: checked }
|
||||
: member
|
||||
)
|
||||
}));
|
||||
|
||||
const body = {
|
||||
team_member_id: memberId,
|
||||
project_id: projectId,
|
||||
task_id: task.id,
|
||||
reporter_id: currentSession.id,
|
||||
mode: checked ? 0 : 1,
|
||||
parent_task: task.parent_task_id,
|
||||
};
|
||||
|
||||
// Emit socket event - the socket handler will update Redux with proper types
|
||||
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body));
|
||||
|
||||
// Remove from pending changes after a short delay (optimistic)
|
||||
setTimeout(() => {
|
||||
setPendingChanges(prev => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(memberId);
|
||||
return newSet;
|
||||
});
|
||||
}, 500); // Remove pending state after 500ms
|
||||
}, [task, projectId, currentSession, socket]);
|
||||
|
||||
const checkMemberSelected = useCallback((memberId: string) => {
|
||||
if (!memberId) return false;
|
||||
// Use optimistic assignees if available, otherwise fall back to task assignees
|
||||
const assignees = optimisticAssignees.length > 0
|
||||
? optimisticAssignees
|
||||
: task?.assignees?.map(assignee => assignee.team_member_id) || [];
|
||||
return assignees.includes(memberId);
|
||||
}, [optimisticAssignees, task]);
|
||||
|
||||
const handleInviteProjectMemberDrawer = useCallback(() => {
|
||||
onClose(); // Close the assignee dropdown first
|
||||
dispatch(toggleProjectMemberDrawer()); // Then open the invite drawer
|
||||
}, [onClose, dispatch]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={dropdownRef}
|
||||
className={`
|
||||
fixed z-9999 w-72 rounded-md shadow-lg border
|
||||
${isDarkMode
|
||||
? 'bg-gray-800 border-gray-600'
|
||||
: 'bg-white border-gray-200'
|
||||
}
|
||||
`}
|
||||
style={{
|
||||
top: position.top,
|
||||
left: position.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-64 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 relative
|
||||
${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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="relative">
|
||||
<Checkbox
|
||||
checked={checkMemberSelected(member.id || '')}
|
||||
onChange={(checked) => handleMemberToggle(member.id || '', checked)}
|
||||
disabled={member.pending_invitation || pendingChanges.has(member.id || '')}
|
||||
isDarkMode={isDarkMode}
|
||||
/>
|
||||
{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">
|
||||
<div className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||
No members found
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer - Invite button */}
|
||||
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
||||
<Button
|
||||
icon={<UserAddOutlined />}
|
||||
type="text"
|
||||
onClick={handleInviteProjectMemberDrawer}
|
||||
className={`
|
||||
w-full text-left justify-start
|
||||
${isDarkMode
|
||||
? 'text-blue-400 hover:bg-gray-700'
|
||||
: 'text-blue-600 hover:bg-blue-50'
|
||||
}
|
||||
`}
|
||||
style={{ fontSize: '12px' }}
|
||||
>
|
||||
Invite team member
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AssigneeDropdownContent;
|
||||
@@ -1,592 +0,0 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button,
|
||||
Typography,
|
||||
Dropdown,
|
||||
Menu,
|
||||
Popconfirm,
|
||||
Tooltip,
|
||||
Badge,
|
||||
DeleteOutlined,
|
||||
CloseOutlined,
|
||||
MoreOutlined,
|
||||
RetweetOutlined,
|
||||
UserAddOutlined,
|
||||
InboxOutlined,
|
||||
TagsOutlined,
|
||||
UsergroupAddOutlined,
|
||||
type CheckboxChangeEvent,
|
||||
type InputRef
|
||||
} from './antd-imports';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IGroupBy, fetchTaskGroups } from '@/features/tasks/tasks.slice';
|
||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
|
||||
import {
|
||||
evt_project_task_list_bulk_archive,
|
||||
evt_project_task_list_bulk_assign_me,
|
||||
evt_project_task_list_bulk_assign_members,
|
||||
evt_project_task_list_bulk_change_phase,
|
||||
evt_project_task_list_bulk_change_priority,
|
||||
evt_project_task_list_bulk_change_status,
|
||||
evt_project_task_list_bulk_delete,
|
||||
evt_project_task_list_bulk_update_labels,
|
||||
} from '@/shared/worklenz-analytics-events';
|
||||
import {
|
||||
IBulkTasksLabelsRequest,
|
||||
IBulkTasksPhaseChangeRequest,
|
||||
IBulkTasksPriorityChangeRequest,
|
||||
IBulkTasksStatusChangeRequest,
|
||||
} from '@/types/tasks/bulk-action-bar.types';
|
||||
import { ITaskStatus } from '@/types/tasks/taskStatus.types';
|
||||
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
||||
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
||||
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
||||
import { ITaskAssignee } from '@/types/tasks/task.types';
|
||||
import { createPortal } from 'react-dom';
|
||||
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||
import AssigneesDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/AssigneesDropdown';
|
||||
import LabelsDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown';
|
||||
import { sortTeamMembers } from '@/utils/sort-team-members';
|
||||
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
||||
import { useAuthService } from '@/hooks/useAuth';
|
||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||
import alertService from '@/services/alerts/alertService';
|
||||
import logger from '@/utils/errorLogger';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
interface BulkActionBarProps {
|
||||
selectedTaskIds: string[];
|
||||
totalSelected: number;
|
||||
currentGrouping: IGroupBy;
|
||||
projectId: string;
|
||||
onClearSelection?: () => void;
|
||||
}
|
||||
|
||||
const BulkActionBarContent: React.FC<BulkActionBarProps> = ({
|
||||
selectedTaskIds,
|
||||
totalSelected,
|
||||
currentGrouping,
|
||||
projectId,
|
||||
onClearSelection,
|
||||
}) => {
|
||||
const dispatch = useAppDispatch();
|
||||
const { t } = useTranslation('tasks/task-table-bulk-actions');
|
||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||
|
||||
// Add permission hooks
|
||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||
|
||||
// loading state
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [updatingLabels, setUpdatingLabels] = useState(false);
|
||||
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
|
||||
const [updatingAssignees, setUpdatingAssignees] = useState(false);
|
||||
const [updatingArchive, setUpdatingArchive] = useState(false);
|
||||
const [updatingDelete, setUpdatingDelete] = useState(false);
|
||||
|
||||
// Selectors
|
||||
const { selectedTaskIdsList } = useAppSelector(state => state.bulkActionReducer);
|
||||
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
||||
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
|
||||
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
|
||||
const labelsList = useAppSelector(state => state.taskLabelsReducer.labels);
|
||||
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
|
||||
const archived = useAppSelector(state => state.taskReducer.archived);
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
|
||||
const labelsInputRef = useRef<InputRef>(null);
|
||||
const [createLabelText, setCreateLabelText] = useState<string>('');
|
||||
const [teamMembersSorted, setTeamMembersSorted] = useState<ITeamMembersViewModel>({
|
||||
data: [],
|
||||
total: 0,
|
||||
});
|
||||
const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false);
|
||||
const [showDrawer, setShowDrawer] = useState(false);
|
||||
const [selectedLabels, setSelectedLabels] = useState<ITaskLabel[]>([]);
|
||||
|
||||
// Handlers
|
||||
const handleChangeStatus = async (status: ITaskStatus) => {
|
||||
if (!status.id || !projectId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const body: IBulkTasksStatusChangeRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
status_id: status.id,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
onClearSelection?.();
|
||||
}
|
||||
for (const it of selectedTaskIds) {
|
||||
const canContinue = await checkTaskDependencyStatus(it, status.id);
|
||||
if (!canContinue) {
|
||||
if (selectedTaskIds.length > 1) {
|
||||
alertService.warning(
|
||||
'Incomplete Dependencies!',
|
||||
'Some tasks were not updated. Please ensure all dependent tasks are completed before proceeding.'
|
||||
);
|
||||
} else {
|
||||
alertService.error(
|
||||
'Task is not completed',
|
||||
'Please complete the task dependencies before proceeding'
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing status:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePriority = async (priority: ITaskPriority) => {
|
||||
if (!priority.id || !projectId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const body: IBulkTasksPriorityChangeRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
priority_id: priority.id,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
onClearSelection?.();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing priority:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangePhase = async (phase: ITaskPhase) => {
|
||||
if (!phase.id || !projectId) return;
|
||||
try {
|
||||
setLoading(true);
|
||||
const body: IBulkTasksPhaseChangeRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
phase_id: phase.id,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
onClearSelection?.();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error changing phase:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAssignToMe = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setUpdatingAssignToMe(true);
|
||||
const body = {
|
||||
tasks: selectedTaskIds,
|
||||
project_id: projectId,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.assignToMe(body);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_assign_me);
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
onClearSelection?.();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error assigning to me:', error);
|
||||
} finally {
|
||||
setUpdatingAssignToMe(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setUpdatingArchive(true);
|
||||
const body = {
|
||||
tasks: selectedTaskIds,
|
||||
project_id: projectId,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.archiveTasks(body, archived);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_archive);
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
onClearSelection?.();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error archiving tasks:', error);
|
||||
} finally {
|
||||
setUpdatingArchive(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleChangeAssignees = async (selectedAssignees: ITeamMemberViewModel[]) => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setUpdatingAssignees(true);
|
||||
const body = {
|
||||
tasks: selectedTaskIds,
|
||||
project_id: projectId,
|
||||
members: selectedAssignees.map(member => ({
|
||||
id: member.id,
|
||||
name: member.name || member.email || 'Unknown', // Fix: Ensure name is always a string
|
||||
email: member.email || '',
|
||||
avatar_url: member.avatar_url,
|
||||
team_member_id: member.id,
|
||||
project_member_id: member.id,
|
||||
})) as ITaskAssignee[],
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.assignTasks(body);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
onClearSelection?.();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error assigning tasks:', error);
|
||||
} finally {
|
||||
setUpdatingAssignees(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setUpdatingDelete(true);
|
||||
const body = {
|
||||
tasks: selectedTaskIds,
|
||||
project_id: projectId,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.deleteTasks(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_delete);
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
onClearSelection?.();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error deleting tasks:', error);
|
||||
} finally {
|
||||
setUpdatingDelete(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Menu Generators
|
||||
const getChangeOptionsMenu = () => [
|
||||
{
|
||||
key: '1',
|
||||
label: t('status'),
|
||||
children: statusList.map(status => ({
|
||||
key: status.id,
|
||||
onClick: () => handleChangeStatus(status),
|
||||
label: <Badge color={status.color_code} text={status.name} />,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: '2',
|
||||
label: t('priority'),
|
||||
children: priorityList.map(priority => ({
|
||||
key: priority.id,
|
||||
onClick: () => handleChangePriority(priority),
|
||||
label: <Badge color={priority.color_code} text={priority.name} />,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: '3',
|
||||
label: t('phase'),
|
||||
children: phaseList.map(phase => ({
|
||||
key: phase.id,
|
||||
onClick: () => handleChangePhase(phase),
|
||||
label: <Badge color={phase.color_code} text={phase.name} />,
|
||||
})),
|
||||
},
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
if (members?.data && assigneeDropdownOpen) {
|
||||
let sortedMembers = sortTeamMembers(members.data);
|
||||
setTeamMembersSorted({ data: sortedMembers, total: members.total });
|
||||
}
|
||||
}, [assigneeDropdownOpen, members?.data]);
|
||||
|
||||
const getAssigneesMenu = () => {
|
||||
return (
|
||||
<AssigneesDropdown
|
||||
members={teamMembersSorted?.data || []}
|
||||
themeMode={themeMode}
|
||||
onApply={handleChangeAssignees}
|
||||
onClose={() => setAssigneeDropdownOpen(false)}
|
||||
t={t}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const handleLabelChange = (e: CheckboxChangeEvent, label: ITaskLabel) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedLabels(prev => [...prev, label]);
|
||||
} else {
|
||||
setSelectedLabels(prev => prev.filter(l => l.id !== label.id));
|
||||
}
|
||||
};
|
||||
|
||||
const applyLabels = async () => {
|
||||
if (!projectId) return;
|
||||
try {
|
||||
setUpdatingLabels(true);
|
||||
const body: IBulkTasksLabelsRequest = {
|
||||
tasks: selectedTaskIds,
|
||||
labels: selectedLabels,
|
||||
text:
|
||||
selectedLabels.length > 0
|
||||
? null
|
||||
: createLabelText.trim() !== ''
|
||||
? createLabelText.trim()
|
||||
: null,
|
||||
};
|
||||
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
|
||||
if (res.done) {
|
||||
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
|
||||
dispatch(deselectAll());
|
||||
dispatch(fetchTaskGroups(projectId));
|
||||
dispatch(fetchLabels()); // Fallback: refetch all labels
|
||||
setCreateLabelText('');
|
||||
setSelectedLabels([]);
|
||||
onClearSelection?.();
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error updating labels:', error);
|
||||
} finally {
|
||||
setUpdatingLabels(false);
|
||||
}
|
||||
};
|
||||
|
||||
const labelsDropdownContent = (
|
||||
<LabelsDropdown
|
||||
labelsList={labelsList}
|
||||
themeMode={themeMode}
|
||||
createLabelText={createLabelText}
|
||||
selectedLabels={selectedLabels}
|
||||
labelsInputRef={labelsInputRef as React.RefObject<InputRef>}
|
||||
onLabelChange={handleLabelChange}
|
||||
onCreateLabelTextChange={value => setCreateLabelText(value)}
|
||||
onApply={applyLabels}
|
||||
t={t}
|
||||
loading={updatingLabels}
|
||||
/>
|
||||
);
|
||||
|
||||
const onAssigneeDropdownOpenChange = (open: boolean) => {
|
||||
setAssigneeDropdownOpen(open);
|
||||
};
|
||||
|
||||
const buttonStyle = {
|
||||
background: 'transparent',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '4px 8px',
|
||||
height: '32px',
|
||||
fontSize: '16px',
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '30px',
|
||||
left: '50%',
|
||||
transform: 'translateX(-50%)',
|
||||
zIndex: 1000,
|
||||
background: '#252628',
|
||||
borderRadius: '25px',
|
||||
padding: '8px 16px',
|
||||
boxShadow: '0 0 0 1px #434343, 0 4px 12px 0 rgba(0, 0, 0, 0.15)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
minWidth: 'fit-content',
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontSize: '14px', fontWeight: 500, marginRight: '8px' }}>
|
||||
{totalSelected} task{totalSelected > 1 ? 's' : ''} selected
|
||||
</Text>
|
||||
|
||||
{/* Status/Priority/Phase Change */}
|
||||
<Tooltip title="Change Status/Priority/Phase">
|
||||
<Dropdown menu={{ items: getChangeOptionsMenu() }} trigger={['click']}>
|
||||
<Button
|
||||
icon={<RetweetOutlined />}
|
||||
style={buttonStyle}
|
||||
size="small"
|
||||
loading={loading}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Tooltip>
|
||||
|
||||
{/* Labels */}
|
||||
<Tooltip title="Add Labels">
|
||||
<Dropdown
|
||||
dropdownRender={() => labelsDropdownContent}
|
||||
placement="top"
|
||||
arrow
|
||||
trigger={['click']}
|
||||
onOpenChange={value => {
|
||||
if (!value) {
|
||||
setSelectedLabels([]);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<TagsOutlined />}
|
||||
style={buttonStyle}
|
||||
size="small"
|
||||
loading={updatingLabels}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Tooltip>
|
||||
|
||||
{/* Assign to Me */}
|
||||
<Tooltip title="Assign to Me">
|
||||
<Button
|
||||
icon={<UserAddOutlined />}
|
||||
style={buttonStyle}
|
||||
size="small"
|
||||
onClick={handleAssignToMe}
|
||||
loading={updatingAssignToMe}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Assign Members */}
|
||||
<Tooltip title="Assign Members">
|
||||
<Dropdown
|
||||
dropdownRender={getAssigneesMenu}
|
||||
open={assigneeDropdownOpen}
|
||||
onOpenChange={onAssigneeDropdownOpenChange}
|
||||
placement="top"
|
||||
arrow
|
||||
trigger={['click']}
|
||||
>
|
||||
<Button
|
||||
icon={<UsergroupAddOutlined />}
|
||||
style={buttonStyle}
|
||||
size="small"
|
||||
loading={updatingAssignees}
|
||||
/>
|
||||
</Dropdown>
|
||||
</Tooltip>
|
||||
|
||||
{/* Archive */}
|
||||
<Tooltip title={archived ? 'Unarchive' : 'Archive'}>
|
||||
<Button
|
||||
icon={<InboxOutlined />}
|
||||
style={buttonStyle}
|
||||
size="small"
|
||||
onClick={handleArchive}
|
||||
loading={updatingArchive}
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Delete */}
|
||||
<Tooltip title="Delete">
|
||||
<Popconfirm
|
||||
title={`Delete ${totalSelected} task${totalSelected > 1 ? 's' : ''}?`}
|
||||
description="This action cannot be undone."
|
||||
onConfirm={handleDelete}
|
||||
okText="Delete"
|
||||
cancelText="Cancel"
|
||||
okType="danger"
|
||||
>
|
||||
<Button
|
||||
icon={<DeleteOutlined />}
|
||||
style={buttonStyle}
|
||||
size="small"
|
||||
loading={updatingDelete}
|
||||
/>
|
||||
</Popconfirm>
|
||||
</Tooltip>
|
||||
|
||||
{/* More Actions - Only for Owner/Admin */}
|
||||
{isOwnerOrAdmin && (
|
||||
<Tooltip title="More Actions">
|
||||
<Dropdown
|
||||
trigger={['click']}
|
||||
menu={{
|
||||
items: [
|
||||
{
|
||||
key: 'createTemplate',
|
||||
label: 'Create task template',
|
||||
onClick: () => setShowDrawer(true),
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
icon={<MoreOutlined />}
|
||||
style={buttonStyle}
|
||||
size="small"
|
||||
/>
|
||||
</Dropdown>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Clear Selection */}
|
||||
<Tooltip title="Clear Selection">
|
||||
<Button
|
||||
icon={<CloseOutlined />}
|
||||
onClick={onClearSelection}
|
||||
style={buttonStyle}
|
||||
size="small"
|
||||
/>
|
||||
</Tooltip>
|
||||
|
||||
{/* Task Template Drawer */}
|
||||
{createPortal(
|
||||
<TaskTemplateDrawer
|
||||
showDrawer={showDrawer}
|
||||
selectedTemplateId={null}
|
||||
onClose={() => {
|
||||
setShowDrawer(false);
|
||||
dispatch(deselectAll());
|
||||
onClearSelection?.();
|
||||
}}
|
||||
/>,
|
||||
document.body,
|
||||
'create-task-template'
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const BulkActionBar: React.FC<BulkActionBarProps> = (props) => {
|
||||
// Render the bulk action bar through a portal to avoid suspense issues
|
||||
return createPortal(
|
||||
<BulkActionBarContent {...props} />,
|
||||
document.body,
|
||||
'bulk-action-bar'
|
||||
);
|
||||
};
|
||||
|
||||
export default BulkActionBar;
|
||||
@@ -1,43 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Select, Typography } from 'antd';
|
||||
import { IGroupBy } from '@/features/tasks/tasks.slice';
|
||||
import { IGroupByOption } from '@/types/tasks/taskList.types';
|
||||
|
||||
const { Text } = Typography;
|
||||
const { Option } = Select;
|
||||
|
||||
interface GroupingSelectorProps {
|
||||
currentGrouping: IGroupBy;
|
||||
onChange: (groupBy: IGroupBy) => void;
|
||||
options: IGroupByOption[];
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const GroupingSelector: React.FC<GroupingSelectorProps> = ({
|
||||
currentGrouping,
|
||||
onChange,
|
||||
options,
|
||||
disabled = false,
|
||||
}) => {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Text className="text-sm text-gray-600">Group by:</Text>
|
||||
<Select
|
||||
value={currentGrouping}
|
||||
onChange={onChange}
|
||||
disabled={disabled}
|
||||
size="small"
|
||||
style={{ minWidth: 100 }}
|
||||
className="capitalize"
|
||||
>
|
||||
{options.map((option) => (
|
||||
<Option key={option.value} value={option.value} className="capitalize">
|
||||
{option.label}
|
||||
</Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroupingSelector;
|
||||
@@ -1,101 +0,0 @@
|
||||
import React, { useState, useCallback, Suspense } from 'react';
|
||||
import { CalendarOutlined } from '@ant-design/icons';
|
||||
import { formatDate } from '@/utils/date-time';
|
||||
|
||||
// Lazy load the DatePicker component only when needed
|
||||
const LazyDatePicker = React.lazy(() =>
|
||||
import('antd/es/date-picker').then(module => ({ default: module.default }))
|
||||
);
|
||||
|
||||
interface LazyDatePickerProps {
|
||||
value?: string | null;
|
||||
onChange?: (date: string | null) => void;
|
||||
placeholder?: string;
|
||||
isDarkMode?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// Lightweight loading placeholder
|
||||
const DateLoadingPlaceholder: React.FC<{ isDarkMode: boolean; value?: string | null; placeholder?: string }> = ({
|
||||
isDarkMode,
|
||||
value,
|
||||
placeholder
|
||||
}) => (
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-1 px-2 py-1 text-xs rounded border cursor-pointer
|
||||
transition-colors duration-200 animate-pulse min-w-[80px]
|
||||
${isDarkMode
|
||||
? 'border-gray-600 bg-gray-800 text-gray-400'
|
||||
: 'border-gray-300 bg-gray-100 text-gray-600'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<CalendarOutlined className="text-xs" />
|
||||
<span>{value ? formatDate(value) : (placeholder || 'Select date')}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const LazyDatePickerWrapper: React.FC<LazyDatePickerProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Select date',
|
||||
isDarkMode = false,
|
||||
className = ''
|
||||
}) => {
|
||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
||||
|
||||
const handleInteraction = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
if (!hasLoadedOnce) {
|
||||
setHasLoadedOnce(true);
|
||||
}
|
||||
}, [hasLoadedOnce]);
|
||||
|
||||
// If not loaded yet, show a simple placeholder
|
||||
if (!hasLoadedOnce) {
|
||||
return (
|
||||
<div
|
||||
onClick={handleInteraction}
|
||||
onMouseEnter={handleInteraction} // Preload on hover
|
||||
className={`
|
||||
flex items-center gap-1 px-2 py-1 text-xs rounded border cursor-pointer
|
||||
transition-colors duration-200 min-w-[80px] ${className}
|
||||
${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'
|
||||
}
|
||||
`}
|
||||
title="Select date"
|
||||
>
|
||||
<CalendarOutlined className="text-xs" />
|
||||
<span>{value ? formatDate(value) : placeholder}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Once loaded, show the full DatePicker
|
||||
return (
|
||||
<Suspense
|
||||
fallback={
|
||||
<DateLoadingPlaceholder
|
||||
isDarkMode={isDarkMode}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<LazyDatePicker
|
||||
value={value ? new Date(value) : null}
|
||||
onChange={(date) => onChange?.(date ? date.toISOString() : null)}
|
||||
placeholder={placeholder}
|
||||
className={className}
|
||||
size="small"
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
};
|
||||
|
||||
export default LazyDatePickerWrapper;
|
||||
@@ -540,7 +540,6 @@ OptimizedBulkActionBarContent.displayName = 'OptimizedBulkActionBarContent';
|
||||
|
||||
// Portal wrapper for performance isolation
|
||||
const OptimizedBulkActionBar: React.FC<OptimizedBulkActionBarProps> = React.memo((props) => {
|
||||
console.log('BulkActionBar totalSelected:', props.totalSelected, typeof props.totalSelected);
|
||||
if (!props.totalSelected || Number(props.totalSelected) < 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -409,7 +409,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
||||
|
||||
.task-group-header-text {
|
||||
color: white !important;
|
||||
font-size: 13px !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 600 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
@@ -221,7 +221,6 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
// Memoized calculations - optimized
|
||||
const totalTasks = useMemo(() => {
|
||||
const total = taskGroups.reduce((sum, g) => sum + g.taskIds.length, 0);
|
||||
console.log(`[TASK-LIST-BOARD] Total tasks in groups: ${total}, Total tasks in store: ${tasks.length}, Groups: ${taskGroups.length}`);
|
||||
return total;
|
||||
}, [taskGroups, tasks.length]);
|
||||
|
||||
@@ -815,7 +814,6 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
min-height: 400px;
|
||||
max-height: calc(100vh - 120px);
|
||||
position: relative;
|
||||
border: 1px solid var(--task-border-primary, #e8e8e8);
|
||||
border-radius: 8px;
|
||||
background: var(--task-bg-primary, white);
|
||||
overflow: hidden;
|
||||
@@ -939,7 +937,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
||||
|
||||
.task-group-header-text {
|
||||
color: white !important;
|
||||
font-size: 13px !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 600 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
@@ -1,283 +0,0 @@
|
||||
/**
|
||||
* Example: Task Row Component using Centralized Ant Design Imports
|
||||
*
|
||||
* This file demonstrates how to migrate from direct antd imports to the centralized import system.
|
||||
*
|
||||
* BEFORE (Direct imports):
|
||||
* import { Input, Typography, DatePicker } from 'antd';
|
||||
* import type { InputRef } from 'antd';
|
||||
*
|
||||
* AFTER (Centralized imports):
|
||||
* import { Input, Typography, DatePicker, type InputRef, dayjs, taskManagementAntdConfig } from './antd-imports';
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Input,
|
||||
Typography,
|
||||
DatePicker,
|
||||
Button,
|
||||
Select,
|
||||
Tooltip,
|
||||
Badge,
|
||||
Space,
|
||||
Checkbox,
|
||||
UserOutlined,
|
||||
CalendarOutlined,
|
||||
ClockCircleOutlined,
|
||||
EditOutlined,
|
||||
MoreOutlined,
|
||||
dayjs,
|
||||
taskManagementAntdConfig,
|
||||
taskMessage,
|
||||
type InputRef,
|
||||
type DatePickerProps,
|
||||
type Dayjs
|
||||
} from './antd-imports';
|
||||
|
||||
// Your existing task type import
|
||||
import { Task } from '@/types/task-management.types';
|
||||
|
||||
interface TaskRowExampleProps {
|
||||
task: Task;
|
||||
projectId: string;
|
||||
isDarkMode?: boolean;
|
||||
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => void;
|
||||
}
|
||||
|
||||
const TaskRowExample: React.FC<TaskRowExampleProps> = ({
|
||||
task,
|
||||
projectId,
|
||||
isDarkMode = false,
|
||||
onTaskUpdate
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [editingField, setEditingField] = useState<string | null>(null);
|
||||
|
||||
// Use centralized config for consistent DatePicker props
|
||||
const datePickerProps = useMemo(() => ({
|
||||
...taskManagementAntdConfig.datePickerDefaults,
|
||||
className: "w-full bg-transparent border-none shadow-none"
|
||||
}), []);
|
||||
|
||||
// Use centralized config for consistent Button props
|
||||
const buttonProps = useMemo(() => ({
|
||||
...taskManagementAntdConfig.taskButtonDefaults,
|
||||
icon: <EditOutlined />
|
||||
}), []);
|
||||
|
||||
// Handle date changes with centralized message system
|
||||
const handleDateChange = useCallback((date: Dayjs | null, field: 'startDate' | 'dueDate') => {
|
||||
if (onTaskUpdate) {
|
||||
onTaskUpdate(task.id, {
|
||||
[field]: date?.toISOString() || null
|
||||
});
|
||||
taskMessage.success(`${field === 'startDate' ? 'Start' : 'Due'} date updated`);
|
||||
}
|
||||
}, [task.id, onTaskUpdate]);
|
||||
|
||||
// Handle task title edit
|
||||
const handleTitleEdit = useCallback((newTitle: string) => {
|
||||
if (onTaskUpdate && newTitle.trim() !== task.title) {
|
||||
onTaskUpdate(task.id, { title: newTitle.trim() });
|
||||
taskMessage.success('Task title updated');
|
||||
}
|
||||
setIsEditing(false);
|
||||
}, [task.id, task.title, onTaskUpdate]);
|
||||
|
||||
// Memoized date values for performance
|
||||
const startDateValue = useMemo(() =>
|
||||
task.startDate ? dayjs(task.startDate) : undefined,
|
||||
[task.startDate]
|
||||
);
|
||||
|
||||
const dueDateValue = useMemo(() =>
|
||||
task.dueDate ? dayjs(task.dueDate) : undefined,
|
||||
[task.dueDate]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={`task-row-example ${isDarkMode ? 'dark' : 'light'}`}>
|
||||
<div className="task-row-content">
|
||||
|
||||
{/* Task Selection Checkbox */}
|
||||
<div className="task-cell">
|
||||
<Checkbox
|
||||
onChange={(e) => {
|
||||
// Handle selection logic here
|
||||
console.log('Task selected:', e.target.checked);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Task Title */}
|
||||
<div className="task-cell task-title">
|
||||
{isEditing ? (
|
||||
<Input
|
||||
{...taskManagementAntdConfig.taskInputDefaults}
|
||||
defaultValue={task.title}
|
||||
autoFocus
|
||||
onPressEnter={(e) => handleTitleEdit(e.currentTarget.value)}
|
||||
onBlur={(e) => handleTitleEdit(e.currentTarget.value)}
|
||||
/>
|
||||
) : (
|
||||
<Space>
|
||||
<Typography.Text
|
||||
className="task-title-text"
|
||||
onClick={() => setIsEditing(true)}
|
||||
>
|
||||
{task.title}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
{...buttonProps}
|
||||
onClick={() => setIsEditing(true)}
|
||||
/>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Task Progress */}
|
||||
<div className="task-cell">
|
||||
<Badge
|
||||
count={`${task.progress || 0}%`}
|
||||
color={task.progress === 100 ? 'green' : 'blue'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Task Assignees */}
|
||||
<div className="task-cell">
|
||||
<Space>
|
||||
<UserOutlined />
|
||||
<Typography.Text>
|
||||
{task.assignee_names?.join(', ') || 'Unassigned'}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Start Date */}
|
||||
<div className="task-cell">
|
||||
<Tooltip
|
||||
{...taskManagementAntdConfig.tooltipDefaults}
|
||||
title="Start Date"
|
||||
>
|
||||
<DatePicker
|
||||
{...datePickerProps}
|
||||
value={startDateValue}
|
||||
onChange={(date) => handleDateChange(date, 'startDate')}
|
||||
placeholder="Start Date"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Due Date */}
|
||||
<div className="task-cell">
|
||||
<Tooltip
|
||||
{...taskManagementAntdConfig.tooltipDefaults}
|
||||
title="Due Date"
|
||||
>
|
||||
<DatePicker
|
||||
{...datePickerProps}
|
||||
value={dueDateValue}
|
||||
onChange={(date) => handleDateChange(date, 'dueDate')}
|
||||
placeholder="Due Date"
|
||||
disabledDate={(current) =>
|
||||
startDateValue ? current.isBefore(startDateValue, 'day') : false
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
{/* Task Status */}
|
||||
<div className="task-cell">
|
||||
<Select
|
||||
{...taskManagementAntdConfig.taskSelectDefaults}
|
||||
value={task.status}
|
||||
placeholder="Status"
|
||||
onChange={(value) => {
|
||||
if (onTaskUpdate) {
|
||||
onTaskUpdate(task.id, { status: value });
|
||||
taskMessage.success('Status updated');
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ label: 'To Do', value: 'todo' },
|
||||
{ label: 'In Progress', value: 'in_progress' },
|
||||
{ label: 'Done', value: 'done' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Task Priority */}
|
||||
<div className="task-cell">
|
||||
<Select
|
||||
{...taskManagementAntdConfig.taskSelectDefaults}
|
||||
value={task.priority}
|
||||
placeholder="Priority"
|
||||
onChange={(value) => {
|
||||
if (onTaskUpdate) {
|
||||
onTaskUpdate(task.id, { priority: value });
|
||||
taskMessage.success('Priority updated');
|
||||
}
|
||||
}}
|
||||
options={[
|
||||
{ label: 'Low', value: 'low' },
|
||||
{ label: 'Medium', value: 'medium' },
|
||||
{ label: 'High', value: 'high' },
|
||||
{ label: 'Critical', value: 'critical' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Time Tracking */}
|
||||
<div className="task-cell">
|
||||
<Space>
|
||||
<ClockCircleOutlined />
|
||||
<Typography.Text>
|
||||
{task.timeTracking?.logged ? `${task.timeTracking.logged}h` : '0h'}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="task-cell">
|
||||
<Button
|
||||
{...taskManagementAntdConfig.taskButtonDefaults}
|
||||
icon={<MoreOutlined />}
|
||||
onClick={() => {
|
||||
// Handle more actions
|
||||
console.log('More actions clicked');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskRowExample;
|
||||
|
||||
/**
|
||||
* Migration Guide:
|
||||
*
|
||||
* 1. Replace direct antd imports with centralized imports:
|
||||
* - Change: import { DatePicker } from 'antd';
|
||||
* - To: import { DatePicker } from './antd-imports';
|
||||
*
|
||||
* 2. Use centralized configurations:
|
||||
* - Apply taskManagementAntdConfig.datePickerDefaults to all DatePickers
|
||||
* - Use taskMessage instead of direct message calls
|
||||
* - Apply consistent styling with taskManagementTheme
|
||||
*
|
||||
* 3. Benefits:
|
||||
* - Better tree-shaking (smaller bundle size)
|
||||
* - Consistent component props across all task management components
|
||||
* - Centralized theme management
|
||||
* - Type safety with proper TypeScript types
|
||||
* - Easy maintenance and updates
|
||||
*
|
||||
* 4. Performance optimizations included:
|
||||
* - Memoized date values to prevent unnecessary dayjs parsing
|
||||
* - Centralized configurations to prevent prop recreation
|
||||
* - Optimized message utilities
|
||||
*/
|
||||
@@ -9,6 +9,87 @@
|
||||
border-color: var(--task-border-primary, #e8e8e8);
|
||||
}
|
||||
|
||||
/* Horizontal Scrolling Optimizations */
|
||||
.task-table-fixed-columns {
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
background: var(--task-bg-primary, #fff);
|
||||
border-right: 2px solid var(--task-border-primary, #e8e8e8);
|
||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.task-table-scrollable-container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
min-width: 0; /* Allow flex item to shrink below content size */
|
||||
}
|
||||
|
||||
.task-table-scrollable-columns {
|
||||
display: flex;
|
||||
width: max-content; /* Allow content to determine width */
|
||||
min-width: 100%; /* Ensure it takes at least full width */
|
||||
}
|
||||
|
||||
/* Dark mode support for horizontal scrolling */
|
||||
.dark .task-table-fixed-columns {
|
||||
background: var(--task-bg-primary, #1f1f1f);
|
||||
border-right-color: var(--task-border-primary, #303030);
|
||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/*
|
||||
* HORIZONTAL SCROLLING SETUP
|
||||
*
|
||||
* For proper horizontal scrolling, the parent container should have:
|
||||
* - overflow-x: auto
|
||||
* - width: 100% (or specific width)
|
||||
* - min-width: fit-content (optional, for very wide content)
|
||||
*
|
||||
* Example parent container CSS:
|
||||
* .task-list-container {
|
||||
* overflow-x: auto;
|
||||
* width: 100%;
|
||||
* min-width: fit-content;
|
||||
* }
|
||||
*/
|
||||
|
||||
/* Ensure task row works with horizontal scrolling containers */
|
||||
.task-row-optimized {
|
||||
min-width: fit-content;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Container styles for horizontal scrolling */
|
||||
.task-row-container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-width: fit-content;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
/* All columns container - no fixed positioning */
|
||||
.task-table-all-columns {
|
||||
display: flex;
|
||||
min-width: fit-content;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* Ensure columns maintain their widths */
|
||||
.task-table-all-columns > div {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Responsive adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.task-row-container {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
}
|
||||
|
||||
/* SIMPLIFIED HOVER STATE: Remove complex containment and transforms */
|
||||
.task-row-optimized:hover {
|
||||
/* Remove transform that was causing GPU conflicts */
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
type InputRef,
|
||||
Tooltip
|
||||
} from './antd-imports';
|
||||
import { DownOutlined, RightOutlined, ExpandAltOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
||||
import { DownOutlined, RightOutlined, ExpandAltOutlined, CheckCircleOutlined, MinusCircleOutlined, EyeOutlined, RetweetOutlined } from '@ant-design/icons';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Task } from '@/types/task-management.types';
|
||||
import { RootState } from '@/app/store';
|
||||
@@ -50,6 +50,7 @@ interface TaskRowProps {
|
||||
columns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
|
||||
fixedColumns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
|
||||
scrollableColumns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
|
||||
onExpandSubtaskInput?: (taskId: string) => void;
|
||||
}
|
||||
|
||||
// Priority and status colors - moved outside component to avoid recreation
|
||||
@@ -352,6 +353,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
columns,
|
||||
fixedColumns,
|
||||
scrollableColumns,
|
||||
onExpandSubtaskInput,
|
||||
}) => {
|
||||
// PERFORMANCE OPTIMIZATION: Frame-rate aware loading
|
||||
const canRenderComplex = useFrameRateOptimizedLoading(index);
|
||||
@@ -494,23 +496,6 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
};
|
||||
}, [editTaskName, shouldRenderFull, handleTaskNameSave]);
|
||||
|
||||
// Handle adding new subtask
|
||||
const handleAddSubtask = useCallback(() => {
|
||||
const subtaskName = newSubtaskName?.trim();
|
||||
if (subtaskName && connected) {
|
||||
socket?.emit(
|
||||
SocketEvents.TASK_NAME_CHANGE.toString(), // Using existing event for now
|
||||
JSON.stringify({
|
||||
name: subtaskName,
|
||||
parent_task_id: task.id,
|
||||
project_id: projectId,
|
||||
})
|
||||
);
|
||||
setNewSubtaskName('');
|
||||
setShowAddSubtask(false);
|
||||
}
|
||||
}, [newSubtaskName, connected, socket, task.id, projectId]);
|
||||
|
||||
// Handle canceling add subtask
|
||||
const handleCancelAddSubtask = useCallback(() => {
|
||||
setNewSubtaskName('');
|
||||
@@ -541,9 +526,55 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
onToggleSubtasks?.(task.id);
|
||||
}, [onToggleSubtasks, task.id]);
|
||||
|
||||
// Handle successful subtask creation
|
||||
const handleSubtaskCreated = useCallback((newTask: any) => {
|
||||
if (newTask && newTask.id) {
|
||||
// Update parent task progress
|
||||
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||
|
||||
// Clear form and hide add subtask row
|
||||
setNewSubtaskName('');
|
||||
setShowAddSubtask(false);
|
||||
|
||||
// The global socket handler will automatically add the subtask to the parent task
|
||||
// and update the UI through Redux
|
||||
|
||||
// After creating the first subtask, the task now has subtasks
|
||||
// so we should expand it to show the new subtask
|
||||
if (task.sub_tasks_count === 0 || !task.sub_tasks_count) {
|
||||
// Trigger expansion to show the newly created subtask
|
||||
setTimeout(() => {
|
||||
onToggleSubtasks?.(task.id);
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
}, [socket, task.id, task.sub_tasks_count, onToggleSubtasks]);
|
||||
|
||||
// Handle adding new subtask
|
||||
const handleAddSubtask = useCallback(() => {
|
||||
const subtaskName = newSubtaskName?.trim();
|
||||
if (subtaskName && connected && projectId) {
|
||||
// Get current session for reporter_id and team_id
|
||||
const currentSession = JSON.parse(localStorage.getItem('session') || '{}');
|
||||
|
||||
const requestBody = {
|
||||
project_id: projectId,
|
||||
name: subtaskName,
|
||||
reporter_id: currentSession.id,
|
||||
team_id: currentSession.team_id,
|
||||
parent_task_id: task.id,
|
||||
};
|
||||
|
||||
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(requestBody));
|
||||
|
||||
// Handle the response
|
||||
socket?.once(SocketEvents.QUICK_TASK.toString(), handleSubtaskCreated);
|
||||
}
|
||||
}, [newSubtaskName, connected, socket, task.id, projectId, handleSubtaskCreated]);
|
||||
|
||||
// Handle expand/collapse or add subtask
|
||||
const handleExpandClick = useCallback(() => {
|
||||
// For now, just toggle add subtask row for all tasks
|
||||
// Always show add subtask row when clicking expand icon
|
||||
setShowAddSubtask(!showAddSubtask);
|
||||
if (!showAddSubtask) {
|
||||
// Focus the input after state update
|
||||
@@ -655,13 +686,22 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
<div className="flex items-center gap-2 h-5 overflow-hidden">
|
||||
{/* Always reserve space for expand icon */}
|
||||
<div style={{ width: 20, display: 'inline-block' }} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||
<Typography.Text
|
||||
ellipsis={{ tooltip: task.title }}
|
||||
className={styleClasses.taskName}
|
||||
>
|
||||
{task.title}
|
||||
</Typography.Text>
|
||||
{(task as any).sub_tasks_count > 0 && (
|
||||
<div
|
||||
className={`subtask-count-badge flex items-center gap-1 px-1 py-0.5 text-xs font-semibold`}
|
||||
style={{ fontSize: '10px', marginLeft: 4, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
||||
>
|
||||
<span>{(task as any).sub_tasks_count}</span>
|
||||
<span style={{ fontSize: '12px', fontWeight: 600, marginLeft: 1 }}>{'»'}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -707,12 +747,9 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
}
|
||||
|
||||
// Full rendering logic (existing code)
|
||||
// Fix border logic: if this is a fixed column, only consider it "last" if there are no scrollable columns
|
||||
// If this is a scrollable column, use the normal logic
|
||||
const isActuallyLast = isFixed
|
||||
? (index === totalColumns - 1 && (!scrollableColumns || scrollableColumns.length === 0))
|
||||
: (index === totalColumns - 1);
|
||||
const borderClasses = `${isActuallyLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
// Simplified border logic - no fixed columns
|
||||
const isLast = index === totalColumns - 1;
|
||||
const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
|
||||
switch (col.key) {
|
||||
case 'drag':
|
||||
@@ -767,7 +804,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleExpandClick();
|
||||
if (onExpandSubtaskInput) onExpandSubtaskInput(task.id);
|
||||
}}
|
||||
className={`expand-toggle-btn w-4 h-4 flex items-center justify-center border-none rounded text-xs cursor-pointer transition-all duration-200 ${
|
||||
isDarkMode
|
||||
@@ -777,12 +814,12 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
style={{ backgroundColor: 'transparent' }}
|
||||
title="Add subtask"
|
||||
>
|
||||
{showAddSubtask ? <DownOutlined /> : <RightOutlined />}
|
||||
<RightOutlined style={{ fontSize: 16, color: isDarkMode ? '#b0b3b8' : '#888' }} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Task name and input */}
|
||||
<div ref={wrapperRef} className="flex-1 min-w-0">
|
||||
<div ref={wrapperRef} className="flex-1 min-w-0 flex items-center gap-2">
|
||||
{editTaskName ? (
|
||||
<Input
|
||||
ref={inputRef}
|
||||
@@ -799,73 +836,59 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
autoFocus
|
||||
/>
|
||||
) : (
|
||||
<Typography.Text
|
||||
ellipsis={{ tooltip: task.title }}
|
||||
onClick={() => setEditTaskName(true)}
|
||||
className={styleClasses.taskName}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{task.title}
|
||||
</Typography.Text>
|
||||
<>
|
||||
<Typography.Text
|
||||
ellipsis={{ tooltip: task.title }}
|
||||
onClick={() => setEditTaskName(true)}
|
||||
className={styleClasses.taskName}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
{task.title}
|
||||
</Typography.Text>
|
||||
{(task as any).sub_tasks_count > 0 && (
|
||||
<div
|
||||
className={`subtask-count-badge flex items-center gap-1 px-1 py-0.5 text-xs font-semibold`}
|
||||
style={{ fontSize: '10px', marginLeft: 4, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
||||
>
|
||||
<span>{(task as any).sub_tasks_count}</span>
|
||||
<span style={{ fontSize: '12px', fontWeight: 600, marginLeft: 1 }}>{'»'}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Indicators section */}
|
||||
{!editTaskName && (
|
||||
<div className="task-indicators flex items-center gap-1">
|
||||
{/* Subtasks count */}
|
||||
{(task as any).subtasks_count && (task as any).subtasks_count > 0 && (
|
||||
<Tooltip title={`${(task as any).subtasks_count} ${(task as any).subtasks_count !== 1 ? t('subtasks') : t('subtask')}`}>
|
||||
<div
|
||||
className={`indicator-badge subtasks flex items-center gap-1 px-1 py-0.5 rounded text-xs font-semibold cursor-pointer transition-colors duration-200 ${
|
||||
isDarkMode
|
||||
? 'bg-gray-800 border-gray-600 text-gray-400 hover:bg-gray-700'
|
||||
: 'bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200'
|
||||
}`}
|
||||
style={{ fontSize: '10px', border: '1px solid' }}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleToggleSubtasks?.();
|
||||
}}
|
||||
>
|
||||
<span>{(task as any).subtasks_count}</span>
|
||||
<RightOutlined style={{ fontSize: '8px' }} />
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className="task-indicators flex items-center gap-2">
|
||||
{/* Comments indicator */}
|
||||
{(task as any).comments_count && (task as any).comments_count > 0 && (
|
||||
<Tooltip title={`${(task as any).comments_count} ${(task as any).comments_count !== 1 ? t('comments') : t('comment')}`}>
|
||||
<div
|
||||
className={`indicator-badge comments flex items-center gap-1 px-1 py-0.5 rounded text-xs font-semibold ${
|
||||
isDarkMode
|
||||
? 'bg-green-900 border-green-700 text-green-300'
|
||||
: 'bg-green-50 border-green-200 text-green-700'
|
||||
}`}
|
||||
style={{ fontSize: '10px', border: '1px solid' }}
|
||||
>
|
||||
<MessageOutlined style={{ fontSize: '8px' }} />
|
||||
<span>{(task as any).comments_count}</span>
|
||||
</div>
|
||||
{(task as any).comments_count > 0 && (
|
||||
<Tooltip title={t('taskManagement.comments', 'Comments')}>
|
||||
<MessageOutlined style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Attachments indicator */}
|
||||
{(task as any).attachments_count && (task as any).attachments_count > 0 && (
|
||||
<Tooltip title={`${(task as any).attachments_count} ${(task as any).attachments_count !== 1 ? t('attachments') : t('attachment')}`}>
|
||||
<div
|
||||
className={`indicator-badge attachments flex items-center gap-1 px-1 py-0.5 rounded text-xs font-semibold ${
|
||||
isDarkMode
|
||||
? 'bg-blue-900 border-blue-700 text-blue-300'
|
||||
: 'bg-blue-50 border-blue-200 text-blue-700'
|
||||
}`}
|
||||
style={{ fontSize: '10px', border: '1px solid' }}
|
||||
>
|
||||
<PaperClipOutlined style={{ fontSize: '8px' }} />
|
||||
<span>{(task as any).attachments_count}</span>
|
||||
</div>
|
||||
{(task as any).attachments_count > 0 && (
|
||||
<Tooltip title={t('taskManagement.attachments', 'Attachments')}>
|
||||
<PaperClipOutlined style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Dependencies indicator */}
|
||||
{(task as any).has_dependencies && (
|
||||
<Tooltip title={t('taskManagement.dependencies', 'Dependencies')}>
|
||||
<MinusCircleOutlined style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Subscribers indicator */}
|
||||
{(task as any).has_subscribers && (
|
||||
<Tooltip title={t('taskManagement.subscribers', 'Subscribers')}>
|
||||
<EyeOutlined style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
{/* Recurring indicator */}
|
||||
{(task as any).schedule_id && (
|
||||
<Tooltip title={t('taskManagement.recurringTask', 'Recurring Task')}>
|
||||
<RetweetOutlined style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }} />
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
@@ -1191,7 +1214,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
}, [
|
||||
shouldRenderFull, renderMinimalColumn, isDarkMode, task, isSelected, editTaskName, taskName, adapters, groupId, projectId,
|
||||
attributes, listeners, handleSelectChange, handleTaskNameSave, handleDateChange,
|
||||
dateValues, styleClasses
|
||||
dateValues, styleClasses, onExpandSubtaskInput
|
||||
]);
|
||||
|
||||
// Apply global cursor style when dragging
|
||||
@@ -1269,154 +1292,32 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
||||
data-task-id={task.id}
|
||||
data-group-id={groupId}
|
||||
>
|
||||
<div className="flex h-10 max-h-10 overflow-visible relative">
|
||||
{/* Fixed Columns */}
|
||||
{fixedColumns && fixedColumns.length > 0 && (
|
||||
<div
|
||||
className="task-table-fixed-columns flex overflow-visible"
|
||||
style={{
|
||||
width: fixedColumns.reduce((sum, col) => sum + col.width, 0),
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
zIndex: 10,
|
||||
background: isDarkMode ? 'var(--task-bg-primary, #1f1f1f)' : 'var(--task-bg-primary, white)',
|
||||
borderRight: scrollableColumns && scrollableColumns.length > 0 ? '2px solid var(--task-border-primary, #e8e8e8)' : 'none',
|
||||
boxShadow: scrollableColumns && scrollableColumns.length > 0 ? '2px 0 4px rgba(0, 0, 0, 0.1)' : 'none',
|
||||
}}
|
||||
>
|
||||
{fixedColumns.map((col, index) =>
|
||||
shouldRenderMinimal
|
||||
? renderMinimalColumn(col, true, index, fixedColumns.length)
|
||||
: renderColumn(col, true, index, fixedColumns.length)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="task-row-container flex h-10 max-h-10 relative">
|
||||
{/* All Columns - No Fixed Positioning */}
|
||||
<div className="task-table-all-columns flex">
|
||||
{/* Fixed Columns (now scrollable) */}
|
||||
{(fixedColumns ?? []).length > 0 && (
|
||||
<>
|
||||
{(fixedColumns ?? []).map((col, index) =>
|
||||
shouldRenderMinimal
|
||||
? renderMinimalColumn(col, false, index, (fixedColumns ?? []).length)
|
||||
: renderColumn(col, false, index, (fixedColumns ?? []).length)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Scrollable Columns */}
|
||||
{scrollableColumns && scrollableColumns.length > 0 && (
|
||||
<div
|
||||
className="task-table-scrollable-columns overflow-visible"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
minWidth: scrollableColumns.reduce((sum, col) => sum + col.width, 0)
|
||||
}}
|
||||
>
|
||||
{scrollableColumns.map((col, index) =>
|
||||
shouldRenderMinimal
|
||||
? renderMinimalColumn(col, false, index, scrollableColumns.length)
|
||||
: renderColumn(col, false, index, scrollableColumns.length)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Subtask Row */}
|
||||
{showAddSubtask && (
|
||||
<div className={`add-subtask-row ${showAddSubtask ? 'visible' : ''} ${isDarkMode ? 'dark' : ''}`}>
|
||||
<div className="flex h-10 max-h-10 overflow-visible relative">
|
||||
{/* Fixed Columns for Add Subtask */}
|
||||
{fixedColumns && fixedColumns.length > 0 && (
|
||||
<div
|
||||
className="task-table-fixed-columns flex overflow-visible"
|
||||
style={{
|
||||
width: fixedColumns.reduce((sum, col) => sum + col.width, 0),
|
||||
position: 'sticky',
|
||||
left: 0,
|
||||
zIndex: 10,
|
||||
background: isDarkMode ? 'var(--task-bg-primary, #1f1f1f)' : 'var(--task-bg-primary, white)',
|
||||
borderRight: scrollableColumns && scrollableColumns.length > 0 ? '2px solid var(--task-border-primary, #e8e8e8)' : 'none',
|
||||
boxShadow: scrollableColumns && scrollableColumns.length > 0 ? '2px 0 4px rgba(0, 0, 0, 0.1)' : 'none',
|
||||
}}
|
||||
>
|
||||
{fixedColumns.map((col, index) => {
|
||||
// Fix border logic for add subtask row: fixed columns should have right border if scrollable columns exist
|
||||
const isActuallyLast = index === fixedColumns.length - 1 && (!scrollableColumns || scrollableColumns.length === 0);
|
||||
const borderClasses = `${isActuallyLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
|
||||
if (col.key === 'task') {
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`flex items-center px-2 ${borderClasses}`}
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 pl-6">
|
||||
<Input
|
||||
ref={addSubtaskInputRef}
|
||||
placeholder={t('enterSubtaskName')}
|
||||
value={newSubtaskName}
|
||||
onChange={(e) => setNewSubtaskName(e.target.value)}
|
||||
onPressEnter={handleAddSubtask}
|
||||
onBlur={handleCancelAddSubtask}
|
||||
className={`add-subtask-input flex-1 ${
|
||||
isDarkMode
|
||||
? 'bg-gray-700 border-gray-600 text-gray-200'
|
||||
: 'bg-white border-gray-300 text-gray-900'
|
||||
}`}
|
||||
size="small"
|
||||
autoFocus
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleAddSubtask}
|
||||
disabled={!newSubtaskName.trim()}
|
||||
className="h-6 px-2 text-xs bg-blue-500 text-white hover:bg-blue-600"
|
||||
>
|
||||
{t('add')}
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
onClick={handleCancelAddSubtask}
|
||||
className="h-6 px-2 text-xs"
|
||||
>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`flex items-center px-2 ${borderClasses}`}
|
||||
style={{ width: col.width }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scrollable Columns for Add Subtask */}
|
||||
{scrollableColumns && scrollableColumns.length > 0 && (
|
||||
<div
|
||||
className="task-table-scrollable-columns overflow-visible"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flex: 1,
|
||||
minWidth: scrollableColumns.reduce((sum, col) => sum + col.width, 0)
|
||||
}}
|
||||
>
|
||||
{scrollableColumns.map((col, index) => {
|
||||
const isLast = index === scrollableColumns.length - 1;
|
||||
const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`flex items-center px-2 ${borderClasses}`}
|
||||
style={{ width: col.width }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Scrollable Columns */}
|
||||
{(scrollableColumns ?? []).length > 0 && (
|
||||
<>
|
||||
{(scrollableColumns ?? []).map((col, index) =>
|
||||
shouldRenderMinimal
|
||||
? renderMinimalColumn(col, false, index, (scrollableColumns ?? []).length)
|
||||
: renderColumn(col, false, index, (scrollableColumns ?? []).length)
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}, (prevProps, nextProps) => {
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
import React, { useMemo, useCallback } from 'react';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { taskManagementSelectors } from '@/features/task-management/task-management.slice';
|
||||
import { Task } from '@/types/task-management.types';
|
||||
import TaskRow from './task-row';
|
||||
|
||||
interface VirtualizedTaskGroupProps {
|
||||
group: any;
|
||||
projectId: string;
|
||||
currentGrouping: 'status' | 'priority' | 'phase';
|
||||
selectedTaskIds: string[];
|
||||
onSelectTask: (taskId: string, selected: boolean) => void;
|
||||
onToggleSubtasks: (taskId: string) => void;
|
||||
height: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
const VirtualizedTaskGroup: React.FC<VirtualizedTaskGroupProps> = React.memo(({
|
||||
group,
|
||||
projectId,
|
||||
currentGrouping,
|
||||
selectedTaskIds,
|
||||
onSelectTask,
|
||||
onToggleSubtasks,
|
||||
height,
|
||||
width
|
||||
}) => {
|
||||
const allTasks = useSelector(taskManagementSelectors.selectAll);
|
||||
|
||||
// Get tasks for this group using memoization for performance
|
||||
const groupTasks = useMemo(() => {
|
||||
return group.taskIds
|
||||
.map((taskId: string) => allTasks.find((task: Task) => task.id === taskId))
|
||||
.filter((task: Task | undefined): task is Task => task !== undefined);
|
||||
}, [group.taskIds, allTasks]);
|
||||
|
||||
const TASK_ROW_HEIGHT = 40;
|
||||
const GROUP_HEADER_HEIGHT = 40;
|
||||
const COLUMN_HEADER_HEIGHT = 40;
|
||||
const ADD_TASK_ROW_HEIGHT = 40;
|
||||
|
||||
// Calculate total height for the group
|
||||
const totalHeight = GROUP_HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + (groupTasks.length * TASK_ROW_HEIGHT) + ADD_TASK_ROW_HEIGHT;
|
||||
|
||||
// Row renderer for virtualization
|
||||
const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
// Header row
|
||||
if (index === 0) {
|
||||
return (
|
||||
<div style={style}>
|
||||
<div className="task-group-header">
|
||||
<div className="task-group-header-row">
|
||||
<div className="task-group-header-content">
|
||||
<span className="task-group-header-text">
|
||||
{group.title} ({groupTasks.length})
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Column headers row
|
||||
if (index === 1) {
|
||||
return (
|
||||
<div style={style}>
|
||||
<div className="task-group-column-headers">
|
||||
<div className="task-group-column-headers-row">
|
||||
<div className="task-table-fixed-columns">
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '40px' }}></div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '40px' }}></div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '80px' }}>
|
||||
<span className="column-header-text">Key</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '475px' }}>
|
||||
<span className="column-header-text">Task</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="task-table-scrollable-columns">
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '90px' }}>
|
||||
<span className="column-header-text">Progress</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
||||
<span className="column-header-text">Status</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '150px' }}>
|
||||
<span className="column-header-text">Members</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '200px' }}>
|
||||
<span className="column-header-text">Labels</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
||||
<span className="column-header-text">Priority</span>
|
||||
</div>
|
||||
<div className="task-table-cell task-table-header-cell" style={{ width: '120px' }}>
|
||||
<span className="column-header-text">Time Tracking</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Task rows
|
||||
const taskIndex = index - 2;
|
||||
if (taskIndex >= 0 && taskIndex < groupTasks.length) {
|
||||
const task = groupTasks[taskIndex];
|
||||
return (
|
||||
<div style={style}>
|
||||
<TaskRow
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
groupId={group.id}
|
||||
currentGrouping={currentGrouping}
|
||||
isSelected={selectedTaskIds.includes(task.id)}
|
||||
index={taskIndex}
|
||||
onSelect={onSelectTask}
|
||||
onToggleSubtasks={onToggleSubtasks}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Add task row (last row)
|
||||
if (taskIndex === groupTasks.length) {
|
||||
return (
|
||||
<div style={style}>
|
||||
<div className="task-group-add-task">
|
||||
<div className="task-table-fixed-columns">
|
||||
<div style={{ width: '380px', padding: '8px 12px' }}>
|
||||
<span className="text-gray-500">+ Add task</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks]);
|
||||
|
||||
return (
|
||||
<div className="virtualized-task-group">
|
||||
<SortableContext items={group.taskIds} strategy={verticalListSortingStrategy}>
|
||||
<List
|
||||
height={Math.min(height, totalHeight)}
|
||||
width={width}
|
||||
itemCount={groupTasks.length + 3} // +3 for header, column headers, and add task row
|
||||
itemSize={TASK_ROW_HEIGHT}
|
||||
overscanCount={10} // Increased overscan for smoother scrolling experience
|
||||
>
|
||||
{Row}
|
||||
</List>
|
||||
</SortableContext>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export default VirtualizedTaskGroup;
|
||||
@@ -3,7 +3,7 @@ import { FixedSizeList as List } from 'react-window';
|
||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { useSelector, useDispatch } from 'react-redux';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Empty, Button } from 'antd';
|
||||
import { Empty, Button, Input } from 'antd';
|
||||
import { RightOutlined, DownOutlined } from '@ant-design/icons';
|
||||
import { taskManagementSelectors } from '@/features/task-management/task-management.slice';
|
||||
import { toggleGroupCollapsed } from '@/features/task-management/grouping.slice';
|
||||
@@ -13,6 +13,8 @@ import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-tabl
|
||||
import { RootState } from '@/app/store';
|
||||
import { TaskListField } from '@/features/task-management/taskListFields.slice';
|
||||
import { Checkbox } from '@/components';
|
||||
import { useSocket } from '@/socket/socketContext';
|
||||
import { SocketEvents } from '@/shared/socket-events';
|
||||
|
||||
interface VirtualizedTaskListProps {
|
||||
group: any;
|
||||
@@ -61,6 +63,35 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
const RENDER_BATCH_SIZE = 5; // Render max 5 tasks per frame
|
||||
const FRAME_BUDGET_MS = 8;
|
||||
|
||||
const [showAddSubtaskForTaskId, setShowAddSubtaskForTaskId] = React.useState<string | null>(null);
|
||||
const [newSubtaskName, setNewSubtaskName] = React.useState('');
|
||||
const addSubtaskInputRef = React.useRef<any>(null);
|
||||
|
||||
const { socket, connected } = useSocket();
|
||||
|
||||
const handleAddSubtask = (parentTaskId: string) => {
|
||||
if (!newSubtaskName.trim() || !connected || !socket) return;
|
||||
const currentSession = JSON.parse(localStorage.getItem('session') || '{}');
|
||||
const requestBody = {
|
||||
project_id: group.project_id || group.projectId || projectId,
|
||||
name: newSubtaskName.trim(),
|
||||
reporter_id: currentSession.id,
|
||||
team_id: currentSession.team_id,
|
||||
parent_task_id: parentTaskId,
|
||||
};
|
||||
socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(requestBody));
|
||||
// Listen for the response and clear input/collapse row
|
||||
socket.once(SocketEvents.QUICK_TASK.toString(), (response: any) => {
|
||||
setNewSubtaskName('');
|
||||
setShowAddSubtaskForTaskId(null);
|
||||
// Optionally: trigger a refresh or update tasks in parent
|
||||
});
|
||||
};
|
||||
const handleCancelAddSubtask = () => {
|
||||
setNewSubtaskName('');
|
||||
setShowAddSubtaskForTaskId(null);
|
||||
};
|
||||
|
||||
// Handle collapse/expand toggle
|
||||
const handleToggleCollapse = useCallback(() => {
|
||||
dispatch(toggleGroupCollapsed(group.id));
|
||||
@@ -297,39 +328,15 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
return 20; // Very large lists: 20 items overscan for smooth scrolling
|
||||
}, [groupTasks.length]);
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Memoize row renderer with better dependency management
|
||||
const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||
const task: Task | undefined = groupTasks[index];
|
||||
if (!task) return null;
|
||||
|
||||
// PERFORMANCE OPTIMIZATION: Pre-calculate selection state
|
||||
const isSelected = selectedTaskIds.includes(task.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="task-row-container"
|
||||
style={{
|
||||
...style,
|
||||
marginLeft: '4px', // Account for sticky border
|
||||
'--group-color': group.color || '#f0f0f0',
|
||||
contain: 'layout style', // CSS containment for better performance
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
<TaskRow
|
||||
task={task}
|
||||
projectId={projectId}
|
||||
groupId={group.id}
|
||||
currentGrouping={currentGrouping}
|
||||
isSelected={isSelected}
|
||||
index={index}
|
||||
onSelect={onSelectTask}
|
||||
onToggleSubtasks={onToggleSubtasks}
|
||||
fixedColumns={fixedColumns}
|
||||
scrollableColumns={scrollableColumns}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}, [group.id, group.color, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks, fixedColumns, scrollableColumns]);
|
||||
// Build displayRows array
|
||||
const displayRows = [];
|
||||
for (let i = 0; i < groupTasks.length; i++) {
|
||||
const task = groupTasks[i];
|
||||
displayRows.push({ type: 'task', task });
|
||||
if (showAddSubtaskForTaskId === task.id) {
|
||||
displayRows.push({ type: 'add-subtask', parentTask: task });
|
||||
}
|
||||
}
|
||||
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const headerScrollRef = useRef<HTMLDivElement>(null);
|
||||
@@ -548,53 +555,170 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
{shouldVirtualize ? (
|
||||
<List
|
||||
height={availableTaskRowsHeight}
|
||||
width={totalTableWidth}
|
||||
itemCount={groupTasks.length}
|
||||
itemCount={displayRows.length}
|
||||
itemSize={TASK_ROW_HEIGHT}
|
||||
width={totalTableWidth}
|
||||
ref={scrollContainerRef}
|
||||
overscanCount={overscanCount}
|
||||
className="react-window-list"
|
||||
style={{ minWidth: totalTableWidth }}
|
||||
// PERFORMANCE OPTIMIZATION: Remove all expensive props for maximum performance
|
||||
useIsScrolling={false}
|
||||
itemData={undefined}
|
||||
// Disable all animations and transitions
|
||||
onItemsRendered={() => {}}
|
||||
onScroll={() => {}}
|
||||
>
|
||||
{Row}
|
||||
{({ index, style }) => {
|
||||
const row = displayRows[index];
|
||||
if (row.type === 'task') {
|
||||
return (
|
||||
<div style={style} key={row.task.id}>
|
||||
<TaskRow
|
||||
task={row.task}
|
||||
projectId={projectId}
|
||||
groupId={group.id}
|
||||
currentGrouping={currentGrouping}
|
||||
isSelected={selectedTaskIds.includes(row.task.id)}
|
||||
index={index}
|
||||
onSelect={onSelectTask}
|
||||
onToggleSubtasks={onToggleSubtasks}
|
||||
fixedColumns={fixedColumns}
|
||||
scrollableColumns={scrollableColumns}
|
||||
onExpandSubtaskInput={() => setShowAddSubtaskForTaskId(row.task.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (row.type === 'add-subtask') {
|
||||
return (
|
||||
<div style={style} key={row.parentTask.id + '-add-subtask'} className={`add-subtask-row visible ${isDarkMode ? 'dark' : ''}`}
|
||||
>
|
||||
<div className="task-row-container flex h-10 max-h-10 relative w-full">
|
||||
<div className="task-table-all-columns flex w-full">
|
||||
{(fixedColumns ?? []).map((col, index) => {
|
||||
const borderClasses = `border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
if (col.key === 'task') {
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`flex items-center px-2 ${borderClasses}`}
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 pl-6">
|
||||
<Input
|
||||
ref={addSubtaskInputRef}
|
||||
placeholder={t('enterSubtaskName')}
|
||||
value={newSubtaskName}
|
||||
onChange={e => setNewSubtaskName(e.target.value)}
|
||||
onPressEnter={() => handleAddSubtask(row.parentTask.id)}
|
||||
onBlur={handleCancelAddSubtask}
|
||||
className={`add-subtask-input flex-1 ${isDarkMode ? 'bg-gray-700 border-gray-600 text-gray-200' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
size="small"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`flex items-center px-2 ${borderClasses}`}
|
||||
style={{ width: col.width }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{(scrollableColumns ?? []).map((col, index) => {
|
||||
const borderClasses = `border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`flex items-center px-2 ${borderClasses}`}
|
||||
style={{ width: col.width }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}}
|
||||
</List>
|
||||
) : (
|
||||
// PERFORMANCE OPTIMIZATION: Use React.Fragment to reduce DOM nodes
|
||||
<React.Fragment>
|
||||
{groupTasks.map((task: Task, index: number) => {
|
||||
// PERFORMANCE OPTIMIZATION: Pre-calculate selection state
|
||||
const isSelected = selectedTaskIds.includes(task.id);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
className="task-row-container"
|
||||
style={{
|
||||
height: TASK_ROW_HEIGHT,
|
||||
marginLeft: '4px', // Account for sticky border
|
||||
'--group-color': group.color || '#f0f0f0',
|
||||
contain: 'layout style', // CSS containment
|
||||
} as React.CSSProperties}
|
||||
>
|
||||
{displayRows.map((row, idx) => {
|
||||
if (row.type === 'task') {
|
||||
return (
|
||||
<TaskRow
|
||||
task={task}
|
||||
key={row.task.id}
|
||||
task={row.task}
|
||||
projectId={projectId}
|
||||
groupId={group.id}
|
||||
currentGrouping={currentGrouping}
|
||||
isSelected={isSelected}
|
||||
index={index}
|
||||
isSelected={selectedTaskIds.includes(row.task.id)}
|
||||
index={idx}
|
||||
onSelect={onSelectTask}
|
||||
onToggleSubtasks={onToggleSubtasks}
|
||||
fixedColumns={fixedColumns}
|
||||
scrollableColumns={scrollableColumns}
|
||||
onExpandSubtaskInput={() => setShowAddSubtaskForTaskId(row.task.id)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
if (row.type === 'add-subtask') {
|
||||
return (
|
||||
<div key={row.parentTask.id + '-add-subtask'} className={`add-subtask-row visible ${isDarkMode ? 'dark' : ''}`}
|
||||
style={{ display: 'flex', alignItems: 'center', minHeight: 40 }}
|
||||
>
|
||||
<div className="task-row-container flex h-10 max-h-10 relative w-full">
|
||||
<div className="task-table-all-columns flex w-full">
|
||||
{(fixedColumns ?? []).map((col, index) => {
|
||||
const borderClasses = `border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
if (col.key === 'task') {
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`flex items-center px-2 ${borderClasses}`}
|
||||
style={{ width: col.width }}
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0 pl-6">
|
||||
<Input
|
||||
ref={addSubtaskInputRef}
|
||||
placeholder={t('enterSubtaskName')}
|
||||
value={newSubtaskName}
|
||||
onChange={e => setNewSubtaskName(e.target.value)}
|
||||
onPressEnter={() => handleAddSubtask(row.parentTask.id)}
|
||||
onBlur={handleCancelAddSubtask}
|
||||
className={`add-subtask-input flex-1 ${isDarkMode ? 'bg-gray-700 border-gray-600 text-gray-200' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||
size="small"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`flex items-center px-2 ${borderClasses}`}
|
||||
style={{ width: col.width }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
})}
|
||||
{(scrollableColumns ?? []).map((col, index) => {
|
||||
const borderClasses = `border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||
return (
|
||||
<div
|
||||
key={col.key}
|
||||
className={`flex items-center px-2 ${borderClasses}`}
|
||||
style={{ width: col.width }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
</React.Fragment>
|
||||
)}
|
||||
@@ -684,7 +808,7 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
||||
}
|
||||
.task-group-header-text {
|
||||
color: white !important;
|
||||
font-size: 13px !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 600 !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
@@ -157,9 +157,6 @@ export const fetchTasksV3 = createAsyncThunk(
|
||||
// Get search value from taskReducer
|
||||
const searchValue = state.taskReducer.search || '';
|
||||
|
||||
console.log('fetchTasksV3 - selectedPriorities:', selectedPriorities);
|
||||
console.log('fetchTasksV3 - searchValue:', searchValue);
|
||||
|
||||
const config: ITaskListConfigV2 = {
|
||||
id: projectId,
|
||||
archived: false,
|
||||
|
||||
@@ -50,7 +50,6 @@ function saveFields(fields: TaskListField[]) {
|
||||
}
|
||||
|
||||
const initialState: TaskListField[] = loadFields();
|
||||
console.log('TaskListFields slice initial state:', initialState);
|
||||
|
||||
const taskListFieldsSlice = createSlice({
|
||||
name: 'taskManagementFields',
|
||||
|
||||
@@ -1,36 +1,80 @@
|
||||
import { Input } from 'antd';
|
||||
import React, { useState } from 'react';
|
||||
import { Input, Button } from 'antd';
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
|
||||
import { colors } from '../../../../../../styles/colors';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
const AddSubTaskListRow = () => {
|
||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
||||
interface AddSubTaskListRowProps {
|
||||
visibleColumns: { key: string; label: string; width: number }[];
|
||||
taskColumnKey: string;
|
||||
onAdd: (name: string) => void;
|
||||
onCancel: () => void;
|
||||
parentTaskId: string;
|
||||
}
|
||||
|
||||
// localization
|
||||
const AddSubTaskListRow: React.FC<AddSubTaskListRowProps> = ({
|
||||
visibleColumns,
|
||||
taskColumnKey,
|
||||
onAdd,
|
||||
onCancel,
|
||||
}) => {
|
||||
const [subtaskName, setSubtaskName] = useState('');
|
||||
const inputRef = useRef<any>(null);
|
||||
const { t } = useTranslation('task-list-table');
|
||||
|
||||
// get data theme data from redux
|
||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||
const customBorderColor = themeMode === 'dark' && ' border-[#303030]';
|
||||
const customBorderColor = themeMode === 'dark' ? ' border-[#303030]' : '';
|
||||
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && subtaskName.trim()) {
|
||||
onAdd(subtaskName.trim());
|
||||
setSubtaskName('');
|
||||
} else if (e.key === 'Escape') {
|
||||
onCancel();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`border-t ${customBorderColor}`}>
|
||||
{isEdit ? (
|
||||
<Input
|
||||
className="h-12 w-full rounded-none"
|
||||
style={{ borderColor: colors.skyBlue }}
|
||||
placeholder={t('addTaskInputPlaceholder')}
|
||||
onBlur={() => setIsEdit(false)}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
onFocus={() => setIsEdit(true)}
|
||||
className="w-[300px] border-none"
|
||||
value={t('addSubTaskText')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<tr className={`add-subtask-row${customBorderColor}`}>
|
||||
{visibleColumns.map(col => (
|
||||
<td key={col.key} style={{ padding: 0, background: 'inherit' }}>
|
||||
{col.key === taskColumnKey ? (
|
||||
<div style={{ display: 'flex', alignItems: 'center', padding: '4px 0' }}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={subtaskName}
|
||||
onChange={e => setSubtaskName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onBlur={onCancel}
|
||||
placeholder={t('enterSubtaskName')}
|
||||
style={{ width: '100%' }}
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
style={{ marginLeft: 8 }}
|
||||
disabled={!subtaskName.trim()}
|
||||
onClick={() => {
|
||||
if (subtaskName.trim()) {
|
||||
onAdd(subtaskName.trim());
|
||||
setSubtaskName('');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('add')}
|
||||
</Button>
|
||||
<Button size="small" style={{ marginLeft: 4 }} onClick={onCancel}>
|
||||
{t('cancel')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1365,6 +1365,7 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
||||
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
||||
const [editColumnKey, setEditColumnKey] = useState<string | null>(null);
|
||||
const [showAddSubtaskFor, setShowAddSubtaskFor] = useState<string | null>(null);
|
||||
|
||||
const toggleTaskExpansion = (taskId: string) => {
|
||||
const task = displayTasks.find(t => t.id === taskId);
|
||||
@@ -1860,11 +1861,35 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
||||
{updatedTask?.sub_tasks?.map(subtask =>
|
||||
subtask?.id ? renderTaskRow(subtask, true) : null
|
||||
)}
|
||||
<tr key={`add-subtask-${updatedTask.id}`}>
|
||||
<td colSpan={visibleColumns.length + 1}>
|
||||
<AddTaskListRow groupId={tableId} parentTask={updatedTask.id} />
|
||||
</td>
|
||||
</tr>
|
||||
{showAddSubtaskFor !== updatedTask.id && (
|
||||
<tr key={`add-subtask-link-${updatedTask.id}`}>
|
||||
<td colSpan={visibleColumns.length + 1}>
|
||||
<div
|
||||
style={{
|
||||
padding: '8px 16px',
|
||||
color: '#1677ff',
|
||||
cursor: 'pointer',
|
||||
fontWeight: 500,
|
||||
background: '#f6f8fa'
|
||||
}}
|
||||
onClick={() => setShowAddSubtaskFor(updatedTask.id)}
|
||||
>
|
||||
+ Add Sub Task
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
{showAddSubtaskFor === updatedTask.id && (
|
||||
<tr key={`add-subtask-input-${updatedTask.id}`}>
|
||||
<td colSpan={visibleColumns.length + 1}>
|
||||
<AddTaskListRow
|
||||
groupId={tableId}
|
||||
parentTask={updatedTask.id}
|
||||
onCancel={() => setShowAddSubtaskFor(null)}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</React.Fragment>
|
||||
|
||||
@@ -24,6 +24,10 @@ export interface Task {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
order: number;
|
||||
// Subtask-related properties
|
||||
sub_tasks_count?: number;
|
||||
show_sub_tasks?: boolean;
|
||||
sub_tasks?: Task[];
|
||||
}
|
||||
|
||||
export interface TaskGroup {
|
||||
|
||||
Reference in New Issue
Block a user