Merge branch 'imp/task-list-performance-fixes' of https://github.com/Worklenz/worklenz into release/v2.0.4
This commit is contained in:
@@ -967,4 +967,212 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
log_error(`Error updating task weight: ${error}`);
|
log_error(`Error updating task weight: ${error}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async getTasksV3(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const isSubTasks = !!req.query.parent_task;
|
||||||
|
const groupBy = (req.query.group || GroupBy.STATUS) as string;
|
||||||
|
const archived = req.query.archived === "true";
|
||||||
|
|
||||||
|
// Skip heavy progress calculation for initial load to improve performance
|
||||||
|
// Progress values are already calculated and stored in the database
|
||||||
|
// Only refresh if explicitly requested
|
||||||
|
if (req.query.refresh_progress === "true" && req.params.id) {
|
||||||
|
await this.refreshProjectTaskProgressValues(req.params.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = TasksControllerV2.getQuery(req.user?.id as string, req.query);
|
||||||
|
const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null];
|
||||||
|
|
||||||
|
const result = await db.query(q, params);
|
||||||
|
const tasks = [...result.rows];
|
||||||
|
|
||||||
|
// Get groups metadata dynamically from database
|
||||||
|
const groups = await this.getGroups(groupBy, req.params.id);
|
||||||
|
|
||||||
|
// Create priority value to name mapping
|
||||||
|
const priorityMap: Record<string, string> = {
|
||||||
|
"0": "low",
|
||||||
|
"1": "medium",
|
||||||
|
"2": "high"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create status category mapping based on actual status names from database
|
||||||
|
const statusCategoryMap: Record<string, string> = {};
|
||||||
|
for (const group of groups) {
|
||||||
|
if (groupBy === GroupBy.STATUS && group.id) {
|
||||||
|
// Use the actual status name from database, convert to lowercase for consistency
|
||||||
|
statusCategoryMap[group.id] = group.name.toLowerCase().replace(/\s+/g, "_");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Transform tasks with all necessary data preprocessing
|
||||||
|
const transformedTasks = tasks.map((task, index) => {
|
||||||
|
// Update task with calculated values (lightweight version)
|
||||||
|
TasksControllerV2.updateTaskViewModel(task);
|
||||||
|
task.index = index;
|
||||||
|
|
||||||
|
// Convert time values
|
||||||
|
const convertTimeValue = (value: any): number => {
|
||||||
|
if (typeof value === "number") return value;
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const parsed = parseFloat(value);
|
||||||
|
return isNaN(parsed) ? 0 : parsed;
|
||||||
|
}
|
||||||
|
if (value && typeof value === "object") {
|
||||||
|
if ("hours" in value || "minutes" in value) {
|
||||||
|
const hours = Number(value.hours || 0);
|
||||||
|
const minutes = Number(value.minutes || 0);
|
||||||
|
return hours + (minutes / 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: task.id,
|
||||||
|
task_key: task.task_key || "",
|
||||||
|
title: task.name || "",
|
||||||
|
description: task.description || "",
|
||||||
|
// Use dynamic status mapping from database
|
||||||
|
status: statusCategoryMap[task.status] || task.status,
|
||||||
|
// Pre-processed priority using mapping
|
||||||
|
priority: priorityMap[task.priority_value?.toString()] || "medium",
|
||||||
|
// Use actual phase name from database
|
||||||
|
phase: task.phase_name || "Development",
|
||||||
|
progress: typeof task.complete_ratio === "number" ? task.complete_ratio : 0,
|
||||||
|
assignees: task.assignees?.map((a: any) => a.team_member_id) || [],
|
||||||
|
assignee_names: task.assignee_names || task.names || [],
|
||||||
|
labels: task.labels?.map((l: any) => ({
|
||||||
|
id: l.id || l.label_id,
|
||||||
|
name: l.name,
|
||||||
|
color: l.color_code || "#1890ff",
|
||||||
|
end: l.end,
|
||||||
|
names: l.names
|
||||||
|
})) || [],
|
||||||
|
dueDate: task.end_date,
|
||||||
|
timeTracking: {
|
||||||
|
estimated: convertTimeValue(task.total_time),
|
||||||
|
logged: convertTimeValue(task.time_spent),
|
||||||
|
},
|
||||||
|
customFields: {},
|
||||||
|
createdAt: task.created_at || new Date().toISOString(),
|
||||||
|
updatedAt: task.updated_at || new Date().toISOString(),
|
||||||
|
order: typeof task.sort_order === "number" ? task.sort_order : 0,
|
||||||
|
// Additional metadata for frontend
|
||||||
|
originalStatusId: task.status,
|
||||||
|
originalPriorityId: task.priority,
|
||||||
|
statusColor: task.status_color,
|
||||||
|
priorityColor: task.priority_color,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create groups based on dynamic data from database
|
||||||
|
const groupedResponse: Record<string, any> = {};
|
||||||
|
|
||||||
|
// Initialize groups from database data
|
||||||
|
groups.forEach(group => {
|
||||||
|
const groupKey = groupBy === GroupBy.STATUS
|
||||||
|
? group.name.toLowerCase().replace(/\s+/g, "_")
|
||||||
|
: groupBy === GroupBy.PRIORITY
|
||||||
|
? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase()
|
||||||
|
: group.name.toLowerCase().replace(/\s+/g, "_");
|
||||||
|
|
||||||
|
groupedResponse[groupKey] = {
|
||||||
|
id: group.id,
|
||||||
|
title: group.name,
|
||||||
|
groupType: groupBy,
|
||||||
|
groupValue: groupKey,
|
||||||
|
collapsed: false,
|
||||||
|
tasks: [],
|
||||||
|
taskIds: [],
|
||||||
|
color: group.color_code || this.getDefaultGroupColor(groupBy, groupKey),
|
||||||
|
// Include additional metadata from database
|
||||||
|
category_id: group.category_id,
|
||||||
|
start_date: group.start_date,
|
||||||
|
end_date: group.end_date,
|
||||||
|
sort_index: (group as any).sort_index,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Distribute tasks into groups
|
||||||
|
transformedTasks.forEach(task => {
|
||||||
|
let groupKey: string;
|
||||||
|
if (groupBy === GroupBy.STATUS) {
|
||||||
|
groupKey = task.status;
|
||||||
|
} else if (groupBy === GroupBy.PRIORITY) {
|
||||||
|
groupKey = task.priority;
|
||||||
|
} else {
|
||||||
|
groupKey = task.phase.toLowerCase().replace(/\s+/g, "_");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupedResponse[groupKey]) {
|
||||||
|
groupedResponse[groupKey].tasks.push(task);
|
||||||
|
groupedResponse[groupKey].taskIds.push(task.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort tasks within each group by order
|
||||||
|
Object.values(groupedResponse).forEach((group: any) => {
|
||||||
|
group.tasks.sort((a: any, b: any) => a.order - b.order);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to array format expected by frontend, maintaining database order
|
||||||
|
const responseGroups = groups
|
||||||
|
.map(group => {
|
||||||
|
const groupKey = groupBy === GroupBy.STATUS
|
||||||
|
? group.name.toLowerCase().replace(/\s+/g, "_")
|
||||||
|
: groupBy === GroupBy.PRIORITY
|
||||||
|
? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase()
|
||||||
|
: group.name.toLowerCase().replace(/\s+/g, "_");
|
||||||
|
|
||||||
|
return groupedResponse[groupKey];
|
||||||
|
})
|
||||||
|
.filter(group => group && (group.tasks.length > 0 || req.query.include_empty === "true"));
|
||||||
|
|
||||||
|
return res.status(200).send(new ServerResponse(true, {
|
||||||
|
groups: responseGroups,
|
||||||
|
allTasks: transformedTasks,
|
||||||
|
grouping: groupBy,
|
||||||
|
totalTasks: transformedTasks.length
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static getDefaultGroupColor(groupBy: string, groupValue: string): string {
|
||||||
|
const colorMaps: Record<string, Record<string, string>> = {
|
||||||
|
[GroupBy.STATUS]: {
|
||||||
|
todo: "#f0f0f0",
|
||||||
|
doing: "#1890ff",
|
||||||
|
done: "#52c41a",
|
||||||
|
},
|
||||||
|
[GroupBy.PRIORITY]: {
|
||||||
|
critical: "#ff4d4f",
|
||||||
|
high: "#ff7a45",
|
||||||
|
medium: "#faad14",
|
||||||
|
low: "#52c41a",
|
||||||
|
},
|
||||||
|
[GroupBy.PHASE]: {
|
||||||
|
planning: "#722ed1",
|
||||||
|
development: "#1890ff",
|
||||||
|
testing: "#faad14",
|
||||||
|
deployment: "#52c41a",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return colorMaps[groupBy]?.[groupValue] || "#d9d9d9";
|
||||||
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async refreshTaskProgress(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
try {
|
||||||
|
if (req.params.id) {
|
||||||
|
await this.refreshProjectTaskProgressValues(req.params.id);
|
||||||
|
return res.status(200).send(new ServerResponse(true, { message: "Task progress refreshed successfully" }));
|
||||||
|
}
|
||||||
|
return res.status(400).send(new ServerResponse(false, "Project ID is required"));
|
||||||
|
} catch (error) {
|
||||||
|
log_error(`Error refreshing task progress: ${error}`);
|
||||||
|
return res.status(500).send(new ServerResponse(false, "Failed to refresh task progress"));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ tasksApiRouter.get("/list/columns/:id", idParamValidator, safeControllerFunction
|
|||||||
tasksApiRouter.put("/list/columns/:id", idParamValidator, safeControllerFunction(TaskListColumnsController.toggleColumn));
|
tasksApiRouter.put("/list/columns/:id", idParamValidator, safeControllerFunction(TaskListColumnsController.toggleColumn));
|
||||||
|
|
||||||
tasksApiRouter.get("/list/v2/:id", idParamValidator, safeControllerFunction(getList));
|
tasksApiRouter.get("/list/v2/:id", idParamValidator, safeControllerFunction(getList));
|
||||||
|
tasksApiRouter.get("/list/v3/:id", idParamValidator, safeControllerFunction(TasksControllerV2.getTasksV3));
|
||||||
|
tasksApiRouter.post("/refresh-progress/:id", idParamValidator, safeControllerFunction(TasksControllerV2.refreshTaskProgress));
|
||||||
tasksApiRouter.get("/assignees/:id", idParamValidator, safeControllerFunction(TasksController.getProjectTaskAssignees));
|
tasksApiRouter.get("/assignees/:id", idParamValidator, safeControllerFunction(TasksController.getProjectTaskAssignees));
|
||||||
|
|
||||||
tasksApiRouter.put("/bulk/status", mapTasksToBulkUpdate, bulkTasksStatusValidator, safeControllerFunction(TasksController.bulkChangeStatus));
|
tasksApiRouter.put("/bulk/status", mapTasksToBulkUpdate, bulkTasksStatusValidator, safeControllerFunction(TasksController.bulkChangeStatus));
|
||||||
|
|||||||
11
worklenz-frontend/package-lock.json
generated
11
worklenz-frontend/package-lock.json
generated
@@ -67,6 +67,7 @@
|
|||||||
"@types/node": "^20.8.4",
|
"@types/node": "^20.8.4",
|
||||||
"@types/react": "19.0.0",
|
"@types/react": "19.0.0",
|
||||||
"@types/react-dom": "19.0.0",
|
"@types/react-dom": "19.0.0",
|
||||||
|
"@types/react-window": "^1.8.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.5.2",
|
"postcss": "^8.5.2",
|
||||||
@@ -2636,6 +2637,16 @@
|
|||||||
"@types/react": "*"
|
"@types/react": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/react-window": {
|
||||||
|
"version": "1.8.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/react-window/-/react-window-1.8.8.tgz",
|
||||||
|
"integrity": "sha512-8Ls660bHR1AUA2kuRvVG9D/4XpRC6wjAaPT9dil7Ckc76eP9TKWZwwmgfq8Q1LANX3QNDnoU4Zp48A3w+zK69Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/react": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/trusted-types": {
|
"node_modules/@types/trusted-types": {
|
||||||
"version": "2.0.7",
|
"version": "2.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||||
|
|||||||
@@ -71,6 +71,7 @@
|
|||||||
"@types/node": "^20.8.4",
|
"@types/node": "^20.8.4",
|
||||||
"@types/react": "19.0.0",
|
"@types/react": "19.0.0",
|
||||||
"@types/react-dom": "19.0.0",
|
"@types/react-dom": "19.0.0",
|
||||||
|
"@types/react-window": "^1.8.8",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"postcss": "^8.5.2",
|
"postcss": "^8.5.2",
|
||||||
|
|||||||
@@ -30,6 +30,22 @@ export interface ITaskListConfigV2 {
|
|||||||
isSubtasksInclude: boolean;
|
isSubtasksInclude: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ITaskListV3Response {
|
||||||
|
groups: Array<{
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
groupType: 'status' | 'priority' | 'phase';
|
||||||
|
groupValue: string;
|
||||||
|
collapsed: boolean;
|
||||||
|
tasks: any[];
|
||||||
|
taskIds: string[];
|
||||||
|
color: string;
|
||||||
|
}>;
|
||||||
|
allTasks: any[];
|
||||||
|
grouping: string;
|
||||||
|
totalTasks: number;
|
||||||
|
}
|
||||||
|
|
||||||
export const tasksApiService = {
|
export const tasksApiService = {
|
||||||
getTaskList: async (config: ITaskListConfigV2): Promise<IServerResponse<ITaskListGroup[]>> => {
|
getTaskList: async (config: ITaskListConfigV2): Promise<IServerResponse<ITaskListGroup[]>> => {
|
||||||
const q = toQueryString(config);
|
const q = toQueryString(config);
|
||||||
@@ -119,4 +135,15 @@ export const tasksApiService = {
|
|||||||
const response = await apiClient.get(`${rootUrl}/dependency-status${q}`);
|
const response = await apiClient.get(`${rootUrl}/dependency-status${q}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getTaskListV3: async (config: ITaskListConfigV2): Promise<IServerResponse<ITaskListV3Response>> => {
|
||||||
|
const q = toQueryString(config);
|
||||||
|
const response = await apiClient.get(`${rootUrl}/list/v3/${config.id}${q}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshTaskProgress: async (projectId: string): Promise<IServerResponse<{ message: string }>> => {
|
||||||
|
const response = await apiClient.post(`${rootUrl}/refresh-progress/${projectId}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
220
worklenz-frontend/src/components/AssigneeSelector.tsx
Normal file
220
worklenz-frontend/src/components/AssigneeSelector.tsx
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { PlusOutlined, 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';
|
||||||
|
|
||||||
|
interface AssigneeSelectorProps {
|
||||||
|
task: IProjectTask;
|
||||||
|
groupId?: string | null;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AssigneeSelector: React.FC<AssigneeSelectorProps> = ({
|
||||||
|
task,
|
||||||
|
groupId = null,
|
||||||
|
isDarkMode = false
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [teamMembers, setTeamMembers] = useState<ITeamMembersViewModel>({ data: [], total: 0 });
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { projectId } = useSelector((state: RootState) => state.projectReducer);
|
||||||
|
const members = useSelector((state: RootState) => state.teamMembersReducer.teamMembers);
|
||||||
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
const { socket } = useSocket();
|
||||||
|
|
||||||
|
const filteredMembers = useMemo(() => {
|
||||||
|
return teamMembers?.data?.filter(member =>
|
||||||
|
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [teamMembers, searchQuery]);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDropdownToggle = () => {
|
||||||
|
if (!isOpen) {
|
||||||
|
// Prepare team members data when opening
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
setIsOpen(!isOpen);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMemberToggle = (memberId: string, checked: boolean) => {
|
||||||
|
if (!memberId || !projectId || !task?.id || !currentSession?.id) return;
|
||||||
|
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
|
||||||
|
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body));
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkMemberSelected = (memberId: string) => {
|
||||||
|
if (!memberId) return false;
|
||||||
|
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
|
||||||
|
return assignees?.includes(memberId) || false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={dropdownRef}>
|
||||||
|
<button
|
||||||
|
onClick={handleDropdownToggle}
|
||||||
|
className={`
|
||||||
|
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
|
||||||
|
transition-colors duration-200
|
||||||
|
${isDarkMode
|
||||||
|
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
|
||||||
|
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-xs" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div
|
||||||
|
className={`
|
||||||
|
absolute top-6 left-0 z-50 w-72 rounded-md shadow-lg border
|
||||||
|
${isDarkMode
|
||||||
|
? 'bg-gray-800 border-gray-600'
|
||||||
|
: 'bg-white border-gray-200'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search members..."
|
||||||
|
className={`
|
||||||
|
w-full px-2 py-1 text-xs rounded border
|
||||||
|
${isDarkMode
|
||||||
|
? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500'
|
||||||
|
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500'
|
||||||
|
}
|
||||||
|
focus:outline-none focus:ring-1 focus:ring-blue-500
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Members List */}
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{filteredMembers && filteredMembers.length > 0 ? (
|
||||||
|
filteredMembers.map((member) => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-2 p-2 cursor-pointer transition-colors
|
||||||
|
${member.pending_invitation
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: isDarkMode
|
||||||
|
? 'hover:bg-gray-700'
|
||||||
|
: 'hover:bg-gray-50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!member.pending_invitation) {
|
||||||
|
const isSelected = checkMemberSelected(member.id || '');
|
||||||
|
handleMemberToggle(member.id || '', !isSelected);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checkMemberSelected(member.id || '')}
|
||||||
|
onChange={(checked) => handleMemberToggle(member.id || '', checked)}
|
||||||
|
disabled={member.pending_invitation}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Avatar
|
||||||
|
src={member.avatar_url}
|
||||||
|
name={member.name || ''}
|
||||||
|
size={24}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}>
|
||||||
|
{member.name}
|
||||||
|
</div>
|
||||||
|
<div className={`text-xs truncate ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||||
|
{member.email}
|
||||||
|
{member.pending_invitation && (
|
||||||
|
<span className="text-red-400 ml-1">(Pending)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||||
|
<div className="text-xs">No members found</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
|
||||||
|
<button
|
||||||
|
className={`
|
||||||
|
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
|
||||||
|
transition-colors
|
||||||
|
${isDarkMode
|
||||||
|
? 'text-blue-400 hover:bg-gray-700'
|
||||||
|
: 'text-blue-600 hover:bg-blue-50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
// TODO: Implement invite member functionality
|
||||||
|
console.log('Invite member clicked');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserAddOutlined />
|
||||||
|
Invite member
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AssigneeSelector;
|
||||||
89
worklenz-frontend/src/components/Avatar.tsx
Normal file
89
worklenz-frontend/src/components/Avatar.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface AvatarProps {
|
||||||
|
name?: string;
|
||||||
|
size?: number | 'small' | 'default' | 'large';
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
className?: string;
|
||||||
|
src?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
style?: React.CSSProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Avatar: React.FC<AvatarProps> = ({
|
||||||
|
name = '',
|
||||||
|
size = 'default',
|
||||||
|
isDarkMode = false,
|
||||||
|
className = '',
|
||||||
|
src,
|
||||||
|
backgroundColor,
|
||||||
|
onClick,
|
||||||
|
style = {}
|
||||||
|
}) => {
|
||||||
|
// Handle both numeric and string sizes
|
||||||
|
const getSize = () => {
|
||||||
|
if (typeof size === 'number') {
|
||||||
|
return { width: size, height: size, fontSize: `${size * 0.4}px` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap = {
|
||||||
|
small: { width: 24, height: 24, fontSize: '10px' },
|
||||||
|
default: { width: 32, height: 32, fontSize: '14px' },
|
||||||
|
large: { width: 48, height: 48, fontSize: '18px' }
|
||||||
|
};
|
||||||
|
|
||||||
|
return sizeMap[size];
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeStyle = getSize();
|
||||||
|
|
||||||
|
const lightColors = [
|
||||||
|
'#f56565', '#4299e1', '#48bb78', '#ed8936', '#9f7aea',
|
||||||
|
'#ed64a6', '#667eea', '#38b2ac', '#f6ad55', '#4fd1c7'
|
||||||
|
];
|
||||||
|
|
||||||
|
const darkColors = [
|
||||||
|
'#e53e3e', '#3182ce', '#38a169', '#dd6b20', '#805ad5',
|
||||||
|
'#d53f8c', '#5a67d8', '#319795', '#d69e2e', '#319795'
|
||||||
|
];
|
||||||
|
|
||||||
|
const colors = isDarkMode ? darkColors : lightColors;
|
||||||
|
const colorIndex = name.charCodeAt(0) % colors.length;
|
||||||
|
const defaultBgColor = backgroundColor || colors[colorIndex];
|
||||||
|
|
||||||
|
const handleClick = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.(e);
|
||||||
|
};
|
||||||
|
|
||||||
|
const avatarStyle = {
|
||||||
|
...sizeStyle,
|
||||||
|
backgroundColor: defaultBgColor,
|
||||||
|
...style
|
||||||
|
};
|
||||||
|
|
||||||
|
if (src) {
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
src={src}
|
||||||
|
alt={name}
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`rounded-full object-cover shadow-sm cursor-pointer ${className}`}
|
||||||
|
style={avatarStyle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onClick={handleClick}
|
||||||
|
className={`rounded-full flex items-center justify-center text-white font-medium shadow-sm cursor-pointer ${className}`}
|
||||||
|
style={avatarStyle}
|
||||||
|
>
|
||||||
|
{name.charAt(0)?.toUpperCase() || '?'}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Avatar;
|
||||||
111
worklenz-frontend/src/components/AvatarGroup.tsx
Normal file
111
worklenz-frontend/src/components/AvatarGroup.tsx
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import { Avatar, Tooltip } from './index';
|
||||||
|
|
||||||
|
interface Member {
|
||||||
|
id?: string;
|
||||||
|
team_member_id?: string;
|
||||||
|
name?: string;
|
||||||
|
names?: string[];
|
||||||
|
avatar_url?: string;
|
||||||
|
color_code?: string;
|
||||||
|
end?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AvatarGroupProps {
|
||||||
|
members: Member[];
|
||||||
|
maxCount?: number;
|
||||||
|
size?: number | 'small' | 'default' | 'large';
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
className?: string;
|
||||||
|
onClick?: (e: React.MouseEvent) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AvatarGroup: React.FC<AvatarGroupProps> = ({
|
||||||
|
members,
|
||||||
|
maxCount,
|
||||||
|
size = 28,
|
||||||
|
isDarkMode = false,
|
||||||
|
className = '',
|
||||||
|
onClick
|
||||||
|
}) => {
|
||||||
|
const stopPropagation = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onClick?.(e);
|
||||||
|
}, [onClick]);
|
||||||
|
|
||||||
|
const renderAvatar = useCallback((member: Member, index: number) => {
|
||||||
|
const memberName = member.end && member.names ? member.names.join(', ') : member.name || '';
|
||||||
|
const displayName = member.end && member.names ? member.name : member.name?.charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip
|
||||||
|
key={member.team_member_id || member.id || index}
|
||||||
|
title={memberName}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
>
|
||||||
|
<Avatar
|
||||||
|
name={member.name || ''}
|
||||||
|
src={member.avatar_url}
|
||||||
|
size={size}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
backgroundColor={member.color_code}
|
||||||
|
onClick={stopPropagation}
|
||||||
|
className="border-2 border-white"
|
||||||
|
style={isDarkMode ? { borderColor: '#374151' } : {}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}, [stopPropagation, size, isDarkMode]);
|
||||||
|
|
||||||
|
const visibleMembers = useMemo(() => {
|
||||||
|
return maxCount ? members.slice(0, maxCount) : members;
|
||||||
|
}, [members, maxCount]);
|
||||||
|
|
||||||
|
const remainingCount = useMemo(() => {
|
||||||
|
return maxCount ? Math.max(0, members.length - maxCount) : 0;
|
||||||
|
}, [members.length, maxCount]);
|
||||||
|
|
||||||
|
const avatarElements = useMemo(() => {
|
||||||
|
return visibleMembers.map((member, index) => renderAvatar(member, index));
|
||||||
|
}, [visibleMembers, renderAvatar]);
|
||||||
|
|
||||||
|
const getSizeStyle = () => {
|
||||||
|
if (typeof size === 'number') {
|
||||||
|
return { width: size, height: size, fontSize: `${size * 0.4}px` };
|
||||||
|
}
|
||||||
|
|
||||||
|
const sizeMap = {
|
||||||
|
small: { width: 24, height: 24, fontSize: '10px' },
|
||||||
|
default: { width: 32, height: 32, fontSize: '14px' },
|
||||||
|
large: { width: 48, height: 48, fontSize: '18px' }
|
||||||
|
};
|
||||||
|
|
||||||
|
return sizeMap[size];
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div onClick={stopPropagation} className={`flex -space-x-1 ${className}`}>
|
||||||
|
{avatarElements}
|
||||||
|
{remainingCount > 0 && (
|
||||||
|
<Tooltip
|
||||||
|
title={`${remainingCount} more`}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`rounded-full flex items-center justify-center text-white font-medium shadow-sm border-2 cursor-pointer ${
|
||||||
|
isDarkMode
|
||||||
|
? 'bg-gray-600 border-gray-700'
|
||||||
|
: 'bg-gray-400 border-white'
|
||||||
|
}`}
|
||||||
|
style={getSizeStyle()}
|
||||||
|
onClick={stopPropagation}
|
||||||
|
>
|
||||||
|
+{remainingCount}
|
||||||
|
</div>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AvatarGroup;
|
||||||
64
worklenz-frontend/src/components/Button.tsx
Normal file
64
worklenz-frontend/src/components/Button.tsx
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ButtonProps {
|
||||||
|
children?: React.ReactNode;
|
||||||
|
onClick?: () => void;
|
||||||
|
variant?: 'text' | 'default' | 'primary' | 'danger';
|
||||||
|
size?: 'small' | 'default' | 'large';
|
||||||
|
className?: string;
|
||||||
|
icon?: React.ReactNode;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
disabled?: boolean;
|
||||||
|
type?: 'button' | 'submit' | 'reset';
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button: React.FC<ButtonProps & React.ButtonHTMLAttributes<HTMLButtonElement>> = ({
|
||||||
|
children,
|
||||||
|
onClick,
|
||||||
|
variant = 'default',
|
||||||
|
size = 'default',
|
||||||
|
className = '',
|
||||||
|
icon,
|
||||||
|
isDarkMode = false,
|
||||||
|
disabled = false,
|
||||||
|
type = 'button',
|
||||||
|
...props
|
||||||
|
}) => {
|
||||||
|
const baseClasses = `inline-flex items-center justify-center font-medium transition-colors duration-200 focus:outline-none focus:ring-2 ${isDarkMode ? 'focus:ring-blue-400' : 'focus:ring-blue-500'} focus:ring-offset-1 ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}`;
|
||||||
|
|
||||||
|
const variantClasses = {
|
||||||
|
text: isDarkMode
|
||||||
|
? 'text-gray-400 hover:text-gray-200 hover:bg-gray-700/50'
|
||||||
|
: 'text-gray-600 hover:text-gray-800 hover:bg-gray-100',
|
||||||
|
default: isDarkMode
|
||||||
|
? 'bg-gray-800 border border-gray-600 text-gray-200 hover:bg-gray-700'
|
||||||
|
: 'bg-white border border-gray-300 text-gray-700 hover:bg-gray-50',
|
||||||
|
primary: isDarkMode
|
||||||
|
? 'bg-blue-600 text-white hover:bg-blue-700'
|
||||||
|
: 'bg-blue-500 text-white hover:bg-blue-600',
|
||||||
|
danger: isDarkMode
|
||||||
|
? 'bg-red-600 text-white hover:bg-red-700'
|
||||||
|
: 'bg-red-500 text-white hover:bg-red-600'
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizeClasses = {
|
||||||
|
small: 'px-2 py-1 text-xs rounded',
|
||||||
|
default: 'px-3 py-2 text-sm rounded-md',
|
||||||
|
large: 'px-4 py-3 text-base rounded-lg'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type={type}
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{icon && <span className={children ? "mr-1" : ""}>{icon}</span>}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Button;
|
||||||
42
worklenz-frontend/src/components/Checkbox.tsx
Normal file
42
worklenz-frontend/src/components/Checkbox.tsx
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface CheckboxProps {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (checked: boolean) => void;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
className?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Checkbox: React.FC<CheckboxProps> = ({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
isDarkMode = false,
|
||||||
|
className = '',
|
||||||
|
disabled = false
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<label className={`inline-flex items-center cursor-pointer ${disabled ? 'opacity-50 cursor-not-allowed' : ''} ${className}`}>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => !disabled && onChange(e.target.checked)}
|
||||||
|
disabled={disabled}
|
||||||
|
className="sr-only"
|
||||||
|
/>
|
||||||
|
<div className={`w-4 h-4 border-2 rounded transition-all duration-200 flex items-center justify-center ${
|
||||||
|
checked
|
||||||
|
? `${isDarkMode ? 'bg-blue-600 border-blue-600' : 'bg-blue-500 border-blue-500'}`
|
||||||
|
: `${isDarkMode ? 'bg-gray-800 border-gray-600 hover:border-gray-500' : 'bg-white border-gray-300 hover:border-gray-400'}`
|
||||||
|
} ${disabled ? 'cursor-not-allowed' : ''}`}>
|
||||||
|
{checked && (
|
||||||
|
<svg className="w-3 h-3 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Checkbox;
|
||||||
30
worklenz-frontend/src/components/CustomColordLabel.tsx
Normal file
30
worklenz-frontend/src/components/CustomColordLabel.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Tooltip } from 'antd';
|
||||||
|
import { Label } from '@/types/task-management.types';
|
||||||
|
|
||||||
|
interface CustomColordLabelProps {
|
||||||
|
label: Label;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomColordLabel: React.FC<CustomColordLabelProps> = ({
|
||||||
|
label,
|
||||||
|
isDarkMode = false
|
||||||
|
}) => {
|
||||||
|
const truncatedName = label.name && label.name.length > 10
|
||||||
|
? `${label.name.substring(0, 10)}...`
|
||||||
|
: label.name;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={label.name}>
|
||||||
|
<span
|
||||||
|
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium text-white flex-shrink-0 max-w-[120px]"
|
||||||
|
style={{ backgroundColor: label.color }}
|
||||||
|
>
|
||||||
|
<span className="truncate">{truncatedName}</span>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomColordLabel;
|
||||||
30
worklenz-frontend/src/components/CustomNumberLabel.tsx
Normal file
30
worklenz-frontend/src/components/CustomNumberLabel.tsx
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Tooltip } from 'antd';
|
||||||
|
|
||||||
|
interface CustomNumberLabelProps {
|
||||||
|
labelList: string[];
|
||||||
|
namesString: string;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const CustomNumberLabel: React.FC<CustomNumberLabelProps> = ({
|
||||||
|
labelList,
|
||||||
|
namesString,
|
||||||
|
isDarkMode = false
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<Tooltip title={labelList.join(', ')}>
|
||||||
|
<span
|
||||||
|
className={`
|
||||||
|
inline-flex items-center px-2 py-0.5 rounded text-xs font-medium
|
||||||
|
${isDarkMode ? 'bg-gray-600 text-gray-100' : 'bg-gray-200 text-gray-700'}
|
||||||
|
cursor-help
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{namesString}
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CustomNumberLabel;
|
||||||
279
worklenz-frontend/src/components/LabelsSelector.tsx
Normal file
279
worklenz-frontend/src/components/LabelsSelector.tsx
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import { PlusOutlined, TagOutlined } from '@ant-design/icons';
|
||||||
|
import { RootState } from '@/app/store';
|
||||||
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
|
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { Button, Checkbox, Tag } from '@/components';
|
||||||
|
|
||||||
|
interface LabelsSelectorProps {
|
||||||
|
task: IProjectTask;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LabelsSelector: React.FC<LabelsSelectorProps> = ({
|
||||||
|
task,
|
||||||
|
isDarkMode = false
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const { labels } = useSelector((state: RootState) => state.taskLabelsReducer);
|
||||||
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
const { socket } = useSocket();
|
||||||
|
|
||||||
|
const filteredLabels = useMemo(() => {
|
||||||
|
return (labels as ITaskLabel[])?.filter(label =>
|
||||||
|
label.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
) || [];
|
||||||
|
}, [labels, searchQuery]);
|
||||||
|
|
||||||
|
// Update dropdown position
|
||||||
|
const updateDropdownPosition = useCallback(() => {
|
||||||
|
if (buttonRef.current) {
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
setDropdownPosition({
|
||||||
|
top: rect.bottom + window.scrollY + 2,
|
||||||
|
left: rect.left + window.scrollX,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside and handle scroll
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node) &&
|
||||||
|
buttonRef.current && !buttonRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (isOpen) {
|
||||||
|
updateDropdownPosition();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (isOpen) {
|
||||||
|
updateDropdownPosition();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
window.addEventListener('scroll', handleScroll, true);
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
window.removeEventListener('scroll', handleScroll, true);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [isOpen, updateDropdownPosition]);
|
||||||
|
|
||||||
|
const handleDropdownToggle = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
console.log('Labels dropdown toggle clicked, current state:', isOpen);
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
updateDropdownPosition();
|
||||||
|
setIsOpen(true);
|
||||||
|
// Focus search input after opening
|
||||||
|
setTimeout(() => {
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const handleLabelToggle = (label: ITaskLabel) => {
|
||||||
|
const labelData = {
|
||||||
|
task_id: task.id,
|
||||||
|
label_id: label.id,
|
||||||
|
parent_task: task.parent_task_id,
|
||||||
|
team_id: currentSession?.team_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
socket?.emit(SocketEvents.TASK_LABELS_CHANGE.toString(), JSON.stringify(labelData));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCreateLabel = () => {
|
||||||
|
if (!searchQuery.trim()) return;
|
||||||
|
|
||||||
|
const labelData = {
|
||||||
|
task_id: task.id,
|
||||||
|
label: searchQuery.trim(),
|
||||||
|
parent_task: task.parent_task_id,
|
||||||
|
team_id: currentSession?.team_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
socket?.emit(SocketEvents.CREATE_LABEL.toString(), JSON.stringify(labelData));
|
||||||
|
setSearchQuery('');
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkLabelSelected = (labelId: string) => {
|
||||||
|
return task?.all_labels?.some(existingLabel => existingLabel.id === labelId) || false;
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
const existingLabel = filteredLabels.find(
|
||||||
|
label => label.name?.toLowerCase() === searchQuery.toLowerCase()
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!existingLabel && e.key === 'Enter') {
|
||||||
|
handleCreateLabel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
onClick={handleDropdownToggle}
|
||||||
|
className={`
|
||||||
|
w-5 h-5 rounded border border-dashed flex items-center justify-center
|
||||||
|
transition-colors duration-200
|
||||||
|
${isOpen
|
||||||
|
? isDarkMode
|
||||||
|
? 'border-blue-500 bg-blue-900/20 text-blue-400'
|
||||||
|
: 'border-blue-500 bg-blue-50 text-blue-600'
|
||||||
|
: isDarkMode
|
||||||
|
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
|
||||||
|
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-xs" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && createPortal(
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
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: dropdownPosition.top,
|
||||||
|
left: dropdownPosition.left,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder="Search labels..."
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Labels List */}
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{filteredLabels && filteredLabels.length > 0 ? (
|
||||||
|
filteredLabels.map((label) => (
|
||||||
|
<div
|
||||||
|
key={label.id}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-2 p-2 cursor-pointer transition-colors
|
||||||
|
${isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-50'}
|
||||||
|
`}
|
||||||
|
onClick={() => handleLabelToggle(label)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={checkLabelSelected(label.id || '')}
|
||||||
|
onChange={() => handleLabelToggle(label)}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full flex-shrink-0"
|
||||||
|
style={{ backgroundColor: label.color_code }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}>
|
||||||
|
{label.name}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||||
|
<div className="text-xs">No labels found</div>
|
||||||
|
{searchQuery.trim() && (
|
||||||
|
<button
|
||||||
|
onClick={handleCreateLabel}
|
||||||
|
className={`
|
||||||
|
mt-2 px-3 py-1 text-xs rounded border transition-colors
|
||||||
|
${isDarkMode
|
||||||
|
? 'border-gray-600 text-gray-300 hover:bg-gray-700'
|
||||||
|
: 'border-gray-300 text-gray-600 hover:bg-gray-50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
Create "{searchQuery}"
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
|
||||||
|
<button
|
||||||
|
className={`
|
||||||
|
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
|
||||||
|
transition-colors
|
||||||
|
${isDarkMode
|
||||||
|
? 'text-blue-400 hover:bg-gray-700'
|
||||||
|
: 'text-blue-600 hover:bg-blue-50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
// TODO: Implement manage labels functionality
|
||||||
|
console.log('Manage labels clicked');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TagOutlined />
|
||||||
|
Manage labels
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LabelsSelector;
|
||||||
84
worklenz-frontend/src/components/Progress.tsx
Normal file
84
worklenz-frontend/src/components/Progress.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface ProgressProps {
|
||||||
|
percent: number;
|
||||||
|
type?: 'line' | 'circle';
|
||||||
|
size?: number;
|
||||||
|
strokeColor?: string;
|
||||||
|
strokeWidth?: number;
|
||||||
|
showInfo?: boolean;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Progress: React.FC<ProgressProps> = ({
|
||||||
|
percent,
|
||||||
|
type = 'line',
|
||||||
|
size = 24,
|
||||||
|
strokeColor = '#1890ff',
|
||||||
|
strokeWidth = 2,
|
||||||
|
showInfo = true,
|
||||||
|
isDarkMode = false,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
// Ensure percent is between 0 and 100
|
||||||
|
const normalizedPercent = Math.min(Math.max(percent, 0), 100);
|
||||||
|
|
||||||
|
if (type === 'circle') {
|
||||||
|
const radius = (size - strokeWidth) / 2;
|
||||||
|
const circumference = radius * 2 * Math.PI;
|
||||||
|
const strokeDasharray = circumference;
|
||||||
|
const strokeDashoffset = circumference - (normalizedPercent / 100) * circumference;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative inline-flex items-center justify-center ${className}`}>
|
||||||
|
<svg width={size} height={size} className="transform -rotate-90">
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke={isDarkMode ? '#4b5563' : '#e5e7eb'}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={radius}
|
||||||
|
stroke={normalizedPercent === 100 ? '#52c41a' : strokeColor}
|
||||||
|
strokeWidth={strokeWidth}
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={strokeDasharray}
|
||||||
|
strokeDashoffset={strokeDashoffset}
|
||||||
|
strokeLinecap="round"
|
||||||
|
className="transition-all duration-300"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{showInfo && (
|
||||||
|
<span className={`absolute text-xs font-medium ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||||
|
{normalizedPercent}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`w-full rounded-full h-2 ${isDarkMode ? 'bg-gray-600' : 'bg-gray-200'} ${className}`}>
|
||||||
|
<div
|
||||||
|
className="h-2 rounded-full transition-all duration-300"
|
||||||
|
style={{
|
||||||
|
width: `${normalizedPercent}%`,
|
||||||
|
backgroundColor: normalizedPercent === 100 ? '#52c41a' : strokeColor
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{showInfo && (
|
||||||
|
<div className={`mt-1 text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
||||||
|
{normalizedPercent}%
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Progress;
|
||||||
54
worklenz-frontend/src/components/Tag.tsx
Normal file
54
worklenz-frontend/src/components/Tag.tsx
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface TagProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
color?: string;
|
||||||
|
backgroundColor?: string;
|
||||||
|
className?: string;
|
||||||
|
size?: 'small' | 'default';
|
||||||
|
variant?: 'default' | 'outlined';
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tag: React.FC<TagProps> = ({
|
||||||
|
children,
|
||||||
|
color = 'white',
|
||||||
|
backgroundColor = '#1890ff',
|
||||||
|
className = '',
|
||||||
|
size = 'default',
|
||||||
|
variant = 'default',
|
||||||
|
isDarkMode = false
|
||||||
|
}) => {
|
||||||
|
const sizeClasses = {
|
||||||
|
small: 'px-1 py-0.5 text-xs',
|
||||||
|
default: 'px-2 py-1 text-xs'
|
||||||
|
};
|
||||||
|
|
||||||
|
const baseClasses = `inline-flex items-center font-medium rounded ${sizeClasses[size]}`;
|
||||||
|
|
||||||
|
if (variant === 'outlined') {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`${baseClasses} border ${className}`}
|
||||||
|
style={{
|
||||||
|
borderColor: backgroundColor,
|
||||||
|
color: backgroundColor,
|
||||||
|
backgroundColor: 'transparent'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`${baseClasses} ${className}`}
|
||||||
|
style={{ backgroundColor, color }}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Tag;
|
||||||
35
worklenz-frontend/src/components/Tooltip.tsx
Normal file
35
worklenz-frontend/src/components/Tooltip.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
title: string | React.ReactNode;
|
||||||
|
children: React.ReactNode;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
placement?: 'top' | 'bottom' | 'left' | 'right';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const Tooltip: React.FC<TooltipProps> = ({
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
isDarkMode = false,
|
||||||
|
placement = 'top',
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const placementClasses = {
|
||||||
|
top: 'bottom-full left-1/2 transform -translate-x-1/2 mb-2',
|
||||||
|
bottom: 'top-full left-1/2 transform -translate-x-1/2 mt-2',
|
||||||
|
left: 'right-full top-1/2 transform -translate-y-1/2 mr-2',
|
||||||
|
right: 'left-full top-1/2 transform -translate-y-1/2 ml-2'
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative group ${className}`}>
|
||||||
|
{children}
|
||||||
|
<div className={`absolute ${placementClasses[placement]} px-2 py-1 text-xs text-white ${isDarkMode ? 'bg-gray-700' : 'bg-gray-900'} rounded shadow-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-50 pointer-events-none min-w-max`}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Tooltip;
|
||||||
12
worklenz-frontend/src/components/index.ts
Normal file
12
worklenz-frontend/src/components/index.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
// Reusable UI Components
|
||||||
|
export { default as AssigneeSelector } from './AssigneeSelector';
|
||||||
|
export { default as Avatar } from './Avatar';
|
||||||
|
export { default as AvatarGroup } from './AvatarGroup';
|
||||||
|
export { default as Button } from './Button';
|
||||||
|
export { default as Checkbox } from './Checkbox';
|
||||||
|
export { default as CustomColordLabel } from './CustomColordLabel';
|
||||||
|
export { default as CustomNumberLabel } from './CustomNumberLabel';
|
||||||
|
export { default as LabelsSelector } from './LabelsSelector';
|
||||||
|
export { default as Progress } from './Progress';
|
||||||
|
export { default as Tag } from './Tag';
|
||||||
|
export { default as Tooltip } from './Tooltip';
|
||||||
@@ -0,0 +1,820 @@
|
|||||||
|
import React, { useState, useCallback, useMemo, useEffect, useRef } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
import { createSelector } from '@reduxjs/toolkit';
|
||||||
|
import {
|
||||||
|
SearchOutlined,
|
||||||
|
FilterOutlined,
|
||||||
|
CloseOutlined,
|
||||||
|
DownOutlined,
|
||||||
|
TeamOutlined,
|
||||||
|
TagOutlined,
|
||||||
|
FlagOutlined,
|
||||||
|
GroupOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
CheckOutlined,
|
||||||
|
SettingOutlined,
|
||||||
|
MoreOutlined,
|
||||||
|
} from '@ant-design/icons';
|
||||||
|
import { RootState } from '@/app/store';
|
||||||
|
import { AppDispatch } from '@/app/store';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import useTabSearchParam from '@/hooks/useTabSearchParam';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import { colors } from '@/styles/colors';
|
||||||
|
import SingleAvatar from '@components/common/single-avatar/single-avatar';
|
||||||
|
import { useFilterDataLoader } from '@/hooks/useFilterDataLoader';
|
||||||
|
|
||||||
|
// Import Redux actions
|
||||||
|
import { fetchTasksV3, setSelectedPriorities } from '@/features/task-management/task-management.slice';
|
||||||
|
import { setCurrentGrouping, selectCurrentGrouping } from '@/features/task-management/grouping.slice';
|
||||||
|
import { fetchPriorities } from '@/features/taskAttributes/taskPrioritySlice';
|
||||||
|
import { fetchLabelsByProject, fetchTaskAssignees, setMembers, setLabels } from '@/features/tasks/tasks.slice';
|
||||||
|
import { getTeamMembers } from '@/features/team-members/team-members.slice';
|
||||||
|
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
||||||
|
import { ITaskListColumn } from '@/types/tasks/taskList.types';
|
||||||
|
import { IGroupBy } from '@/features/tasks/tasks.slice';
|
||||||
|
|
||||||
|
// Memoized selectors to prevent unnecessary re-renders
|
||||||
|
const selectPriorities = createSelector(
|
||||||
|
[(state: any) => state.priorityReducer.priorities],
|
||||||
|
(priorities) => priorities || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectTaskPriorities = createSelector(
|
||||||
|
[(state: any) => state.taskReducer.priorities],
|
||||||
|
(priorities) => priorities || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectBoardPriorities = createSelector(
|
||||||
|
[(state: any) => state.boardReducer.priorities],
|
||||||
|
(priorities) => priorities || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectTaskLabels = createSelector(
|
||||||
|
[(state: any) => state.taskReducer.labels],
|
||||||
|
(labels) => labels || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectBoardLabels = createSelector(
|
||||||
|
[(state: any) => state.boardReducer.labels],
|
||||||
|
(labels) => labels || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectTaskAssignees = createSelector(
|
||||||
|
[(state: any) => state.taskReducer.taskAssignees],
|
||||||
|
(assignees) => assignees || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectBoardAssignees = createSelector(
|
||||||
|
[(state: any) => state.boardReducer.taskAssignees],
|
||||||
|
(assignees) => assignees || []
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectProject = createSelector(
|
||||||
|
[(state: any) => state.projectReducer.project],
|
||||||
|
(project) => project
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectSelectedPriorities = createSelector(
|
||||||
|
[(state: any) => state.taskManagement.selectedPriorities],
|
||||||
|
(selectedPriorities) => selectedPriorities || []
|
||||||
|
);
|
||||||
|
|
||||||
|
// Types
|
||||||
|
interface FilterOption {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
color?: string;
|
||||||
|
avatar?: string;
|
||||||
|
count?: number;
|
||||||
|
selected?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilterSection {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
icon: React.ComponentType<{ className?: string }>;
|
||||||
|
options: FilterOption[];
|
||||||
|
selectedValues: string[];
|
||||||
|
multiSelect: boolean;
|
||||||
|
searchable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImprovedTaskFiltersProps {
|
||||||
|
position: 'board' | 'list';
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get real filter data from Redux state
|
||||||
|
const useFilterData = (): FilterSection[] => {
|
||||||
|
const { t } = useTranslation('task-list-filters');
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const { projectView } = useTabSearchParam();
|
||||||
|
|
||||||
|
// Use memoized selectors to prevent unnecessary re-renders
|
||||||
|
const priorities = useAppSelector(selectPriorities);
|
||||||
|
const taskPriorities = useAppSelector(selectTaskPriorities);
|
||||||
|
const boardPriorities = useAppSelector(selectBoardPriorities);
|
||||||
|
const taskLabels = useAppSelector(selectTaskLabels);
|
||||||
|
const boardLabels = useAppSelector(selectBoardLabels);
|
||||||
|
const taskAssignees = useAppSelector(selectTaskAssignees);
|
||||||
|
const boardAssignees = useAppSelector(selectBoardAssignees);
|
||||||
|
const taskGroupBy = useAppSelector(state => state.taskReducer.groupBy);
|
||||||
|
const boardGroupBy = useAppSelector(state => state.boardReducer.groupBy);
|
||||||
|
const project = useAppSelector(selectProject);
|
||||||
|
const currentGrouping = useAppSelector(selectCurrentGrouping);
|
||||||
|
const selectedPriorities = useAppSelector(selectSelectedPriorities);
|
||||||
|
|
||||||
|
const tab = searchParams.get('tab');
|
||||||
|
const currentProjectView = tab === 'tasks-list' ? 'list' : 'kanban';
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
console.log('Filter Data Debug:', {
|
||||||
|
priorities: priorities?.length,
|
||||||
|
taskAssignees: taskAssignees?.length,
|
||||||
|
boardAssignees: boardAssignees?.length,
|
||||||
|
labels: taskLabels?.length,
|
||||||
|
boardLabels: boardLabels?.length,
|
||||||
|
currentProjectView,
|
||||||
|
projectId: project?.id
|
||||||
|
});
|
||||||
|
|
||||||
|
return useMemo(() => {
|
||||||
|
const currentPriorities = currentProjectView === 'list' ? taskPriorities : boardPriorities;
|
||||||
|
const currentLabels = currentProjectView === 'list' ? taskLabels : boardLabels;
|
||||||
|
const currentAssignees = currentProjectView === 'list' ? taskAssignees : boardAssignees;
|
||||||
|
const groupByValue = currentGrouping || 'status';
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'priority',
|
||||||
|
label: 'Priority',
|
||||||
|
options: priorities.map((p: any) => ({
|
||||||
|
value: p.id,
|
||||||
|
label: p.name,
|
||||||
|
color: p.color_code,
|
||||||
|
})),
|
||||||
|
selectedValues: selectedPriorities,
|
||||||
|
multiSelect: true,
|
||||||
|
searchable: false,
|
||||||
|
icon: FlagOutlined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'assignees',
|
||||||
|
label: t('membersText'),
|
||||||
|
icon: TeamOutlined,
|
||||||
|
multiSelect: true,
|
||||||
|
searchable: true,
|
||||||
|
selectedValues: currentAssignees.filter((m: any) => m.selected && m.id).map((m: any) => m.id || ''),
|
||||||
|
options: currentAssignees.map((assignee: any) => ({
|
||||||
|
id: assignee.id || '',
|
||||||
|
label: assignee.name || '',
|
||||||
|
value: assignee.id || '',
|
||||||
|
avatar: assignee.avatar_url,
|
||||||
|
selected: assignee.selected,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'labels',
|
||||||
|
label: t('labelsText'),
|
||||||
|
icon: TagOutlined,
|
||||||
|
multiSelect: true,
|
||||||
|
searchable: true,
|
||||||
|
selectedValues: currentLabels.filter((l: any) => l.selected && l.id).map((l: any) => l.id || ''),
|
||||||
|
options: currentLabels.map((label: any) => ({
|
||||||
|
id: label.id || '',
|
||||||
|
label: label.name || '',
|
||||||
|
value: label.id || '',
|
||||||
|
color: label.color_code,
|
||||||
|
selected: label.selected,
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'groupBy',
|
||||||
|
label: t('groupByText'),
|
||||||
|
icon: GroupOutlined,
|
||||||
|
multiSelect: false,
|
||||||
|
searchable: false,
|
||||||
|
selectedValues: [groupByValue],
|
||||||
|
options: [
|
||||||
|
{ id: 'status', label: t('statusText'), value: 'status' },
|
||||||
|
{ id: 'priority', label: t('priorityText'), value: 'priority' },
|
||||||
|
{ id: 'phase', label: project?.phase_label || t('phaseText'), value: 'phase' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [
|
||||||
|
priorities,
|
||||||
|
taskPriorities,
|
||||||
|
boardPriorities,
|
||||||
|
taskLabels,
|
||||||
|
boardLabels,
|
||||||
|
taskAssignees,
|
||||||
|
boardAssignees,
|
||||||
|
taskGroupBy,
|
||||||
|
boardGroupBy,
|
||||||
|
project,
|
||||||
|
currentProjectView,
|
||||||
|
t,
|
||||||
|
currentGrouping,
|
||||||
|
selectedPriorities
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Filter Dropdown Component
|
||||||
|
const FilterDropdown: React.FC<{
|
||||||
|
section: FilterSection;
|
||||||
|
onSelectionChange: (sectionId: string, values: string[]) => void;
|
||||||
|
isOpen: boolean;
|
||||||
|
onToggle: () => void;
|
||||||
|
themeClasses: any;
|
||||||
|
isDarkMode: boolean;
|
||||||
|
className?: string;
|
||||||
|
}> = ({ section, onSelectionChange, isOpen, onToggle, themeClasses, isDarkMode, className = '' }) => {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [filteredOptions, setFilteredOptions] = useState(section.options);
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Filter options based on search term
|
||||||
|
useEffect(() => {
|
||||||
|
if (!section.searchable || !searchTerm.trim()) {
|
||||||
|
setFilteredOptions(section.options);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = section.options.filter(option =>
|
||||||
|
option.label.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
setFilteredOptions(filtered);
|
||||||
|
}, [searchTerm, section.options, section.searchable]);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
if (isOpen) onToggle();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, [isOpen, onToggle]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) {
|
||||||
|
setSearchTerm('');
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const handleOptionToggle = useCallback((optionValue: string) => {
|
||||||
|
if (section.multiSelect) {
|
||||||
|
const newValues = section.selectedValues.includes(optionValue)
|
||||||
|
? section.selectedValues.filter(v => v !== optionValue)
|
||||||
|
: [...section.selectedValues, optionValue];
|
||||||
|
onSelectionChange(section.id, newValues);
|
||||||
|
} else {
|
||||||
|
onSelectionChange(section.id, [optionValue]);
|
||||||
|
onToggle();
|
||||||
|
}
|
||||||
|
}, [section, onSelectionChange, onToggle]);
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
onSelectionChange(section.id, []);
|
||||||
|
}, [section.id, onSelectionChange]);
|
||||||
|
|
||||||
|
const selectedCount = section.selectedValues.length;
|
||||||
|
const IconComponent = section.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`} ref={dropdownRef}>
|
||||||
|
{/* Trigger Button */}
|
||||||
|
<button
|
||||||
|
onClick={onToggle}
|
||||||
|
className={`
|
||||||
|
inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md
|
||||||
|
border transition-all duration-200 ease-in-out
|
||||||
|
${selectedCount > 0
|
||||||
|
? (isDarkMode ? 'bg-blue-600 text-white border-blue-500' : 'bg-blue-50 text-blue-800 border-blue-300 font-semibold')
|
||||||
|
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
|
||||||
|
}
|
||||||
|
hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1
|
||||||
|
${themeClasses.containerBg === 'bg-gray-800' ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'}
|
||||||
|
`}
|
||||||
|
aria-expanded={isOpen}
|
||||||
|
aria-haspopup="true"
|
||||||
|
>
|
||||||
|
<IconComponent className="w-3.5 h-3.5" />
|
||||||
|
<span>{section.label}</span>
|
||||||
|
{selectedCount > 0 && (
|
||||||
|
<span className="inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-blue-500 rounded-full">
|
||||||
|
{selectedCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<DownOutlined
|
||||||
|
className={`w-3.5 h-3.5 transition-transform duration-200 ${isOpen ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Panel */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className={`absolute top-full left-0 z-50 mt-1 w-64 ${themeClasses.dropdownBg} rounded-md shadow-lg border ${themeClasses.dropdownBorder}`}>
|
||||||
|
{/* Search Input */}
|
||||||
|
{section.searchable && (
|
||||||
|
<div className={`p-2 border-b ${themeClasses.dividerBorder}`}>
|
||||||
|
<div className="relative w-full">
|
||||||
|
<SearchOutlined className="absolute left-2.5 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
|
placeholder={`Search ${section.label.toLowerCase()}...`}
|
||||||
|
className={`w-full pl-8 pr-2 py-1 rounded border focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-150 ${
|
||||||
|
isDarkMode
|
||||||
|
? 'bg-gray-700 text-gray-100 placeholder-gray-400 border-gray-600'
|
||||||
|
: 'bg-white text-gray-900 placeholder-gray-400 border-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Options List */}
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{filteredOptions.length === 0 ? (
|
||||||
|
<div className={`p-2 text-xs text-center ${themeClasses.secondaryText}`}>
|
||||||
|
No options found
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-0.5">
|
||||||
|
{filteredOptions.map((option) => {
|
||||||
|
const isSelected = section.selectedValues.includes(option.value);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.id}
|
||||||
|
onClick={() => handleOptionToggle(option.value)}
|
||||||
|
className={`
|
||||||
|
w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded
|
||||||
|
transition-colors duration-150 text-left
|
||||||
|
${isSelected
|
||||||
|
? (isDarkMode ? 'bg-blue-600 text-white' : 'bg-blue-50 text-blue-800 font-semibold')
|
||||||
|
: `${themeClasses.optionText} ${themeClasses.optionHover}`
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
{/* Checkbox/Radio indicator */}
|
||||||
|
<div className={`
|
||||||
|
flex items-center justify-center w-3.5 h-3.5 border rounded
|
||||||
|
${isSelected
|
||||||
|
? 'bg-blue-500 border-blue-500 text-white'
|
||||||
|
: 'border-gray-300 dark:border-gray-600'
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
{isSelected && <CheckOutlined className="w-2.5 h-2.5" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Color indicator */}
|
||||||
|
{option.color && (
|
||||||
|
<div
|
||||||
|
className="w-2.5 h-2.5 rounded-full"
|
||||||
|
style={{ backgroundColor: option.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Avatar */}
|
||||||
|
{option.avatar && (
|
||||||
|
<div className="w-5 h-5 bg-gray-300 rounded-full flex items-center justify-center text-xs font-medium text-gray-700 dark:bg-gray-600 dark:text-gray-300">
|
||||||
|
<img
|
||||||
|
src={option.avatar}
|
||||||
|
alt={option.label}
|
||||||
|
className="w-5 h-5 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Label and Count */}
|
||||||
|
<div className="flex-1 flex items-center justify-between">
|
||||||
|
<span className="truncate">{option.label}</span>
|
||||||
|
{option.count !== undefined && (
|
||||||
|
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{option.count}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Search Component
|
||||||
|
const SearchFilter: React.FC<{
|
||||||
|
value: string;
|
||||||
|
onChange: (value: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
themeClasses: any;
|
||||||
|
className?: string;
|
||||||
|
}> = ({ value, onChange, placeholder = 'Search tasks...', themeClasses, className = '' }) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(false);
|
||||||
|
const [localValue, setLocalValue] = useState(value);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const handleToggle = useCallback(() => {
|
||||||
|
setIsExpanded(!isExpanded);
|
||||||
|
if (!isExpanded) {
|
||||||
|
setTimeout(() => inputRef.current?.focus(), 100);
|
||||||
|
}
|
||||||
|
}, [isExpanded]);
|
||||||
|
|
||||||
|
const handleSubmit = useCallback((e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onChange(localValue);
|
||||||
|
}, [localValue, onChange]);
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
setLocalValue('');
|
||||||
|
onChange('');
|
||||||
|
setIsExpanded(false);
|
||||||
|
}, [onChange]);
|
||||||
|
|
||||||
|
// Redux selectors for theme and other state
|
||||||
|
const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
{!isExpanded ? (
|
||||||
|
<button
|
||||||
|
onClick={handleToggle}
|
||||||
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md border transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 ${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText} ${
|
||||||
|
themeClasses.containerBg === 'bg-gray-800' ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<SearchOutlined className="w-3.5 h-3.5" />
|
||||||
|
<span>Search</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={handleSubmit} className="flex items-center gap-1.5">
|
||||||
|
<div className="relative w-full">
|
||||||
|
<SearchOutlined className="absolute left-2.5 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="text"
|
||||||
|
value={localValue}
|
||||||
|
onChange={(e) => setLocalValue(e.target.value)}
|
||||||
|
placeholder={placeholder}
|
||||||
|
className={`w-full pr-4 pl-8 py-1 rounded border focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors duration-150 ${
|
||||||
|
isDarkMode
|
||||||
|
? 'bg-gray-700 text-gray-100 placeholder-gray-400 border-gray-600'
|
||||||
|
: 'bg-white text-gray-900 placeholder-gray-400 border-gray-300'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{localValue && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
className={`absolute right-1.5 top-1/2 transform -translate-y-1/2 ${themeClasses.secondaryText} hover:${themeClasses.optionText} transition-colors duration-150`}
|
||||||
|
>
|
||||||
|
<CloseOutlined className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
className="px-2.5 py-1.5 text-xs font-medium text-white bg-blue-500 rounded-md hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 transition-colors duration-200"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsExpanded(false)}
|
||||||
|
className={`px-2.5 py-1.5 text-xs font-medium transition-colors duration-200 ${themeClasses.secondaryText} hover:${themeClasses.optionText}`}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Main Component
|
||||||
|
const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({
|
||||||
|
position,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation('task-list-filters');
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { socket, connected } = useSocket();
|
||||||
|
|
||||||
|
// Get current state values for filter updates
|
||||||
|
const currentTaskAssignees = useAppSelector(state => state.taskReducer.taskAssignees);
|
||||||
|
const currentBoardAssignees = useAppSelector(state => state.boardReducer.taskAssignees);
|
||||||
|
const currentTaskLabels = useAppSelector(state => state.taskReducer.labels);
|
||||||
|
const currentBoardLabels = useAppSelector(state => state.boardReducer.labels);
|
||||||
|
|
||||||
|
// Use the filter data loader hook
|
||||||
|
useFilterDataLoader();
|
||||||
|
|
||||||
|
// Local state for filter sections
|
||||||
|
const [filterSections, setFilterSections] = useState<FilterSection[]>([]);
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
const [showArchived, setShowArchived] = useState(false);
|
||||||
|
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
||||||
|
const [activeFiltersCount, setActiveFiltersCount] = useState(0);
|
||||||
|
|
||||||
|
// Get real filter data
|
||||||
|
const filterSectionsData = useFilterData();
|
||||||
|
|
||||||
|
// Check if data is loaded - memoize this computation
|
||||||
|
const isDataLoaded = useMemo(() => {
|
||||||
|
return filterSectionsData.some(section => section.options.length > 0);
|
||||||
|
}, [filterSectionsData]);
|
||||||
|
|
||||||
|
// Initialize filter sections from data - memoize this to prevent unnecessary updates
|
||||||
|
const memoizedFilterSections = useMemo(() => {
|
||||||
|
return filterSectionsData;
|
||||||
|
}, [filterSectionsData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setFilterSections(memoizedFilterSections);
|
||||||
|
}, [memoizedFilterSections]);
|
||||||
|
|
||||||
|
// Redux selectors for theme and other state
|
||||||
|
const isDarkMode = useAppSelector(state => state.themeReducer?.mode === 'dark');
|
||||||
|
const { projectId } = useAppSelector(state => state.projectReducer);
|
||||||
|
const { projectView } = useTabSearchParam();
|
||||||
|
const { columns } = useAppSelector(state => state.taskReducer);
|
||||||
|
|
||||||
|
// Theme-aware class names - memoize to prevent unnecessary re-renders
|
||||||
|
const themeClasses = useMemo(() => ({
|
||||||
|
containerBg: isDarkMode ? 'bg-gray-800' : 'bg-white',
|
||||||
|
containerBorder: isDarkMode ? 'border-gray-700' : 'border-gray-200',
|
||||||
|
buttonBg: isDarkMode ? 'bg-gray-700 hover:bg-gray-600' : 'bg-white hover:bg-gray-50',
|
||||||
|
buttonBorder: isDarkMode ? 'border-gray-600' : 'border-gray-300',
|
||||||
|
buttonText: isDarkMode ? 'text-gray-200' : 'text-gray-700',
|
||||||
|
dropdownBg: isDarkMode ? 'bg-gray-800' : 'bg-white',
|
||||||
|
dropdownBorder: isDarkMode ? 'border-gray-700' : 'border-gray-200',
|
||||||
|
optionText: isDarkMode ? 'text-gray-200' : 'text-gray-700',
|
||||||
|
optionHover: isDarkMode ? 'hover:bg-gray-700' : 'hover:bg-gray-50',
|
||||||
|
secondaryText: isDarkMode ? 'text-gray-400' : 'text-gray-500',
|
||||||
|
dividerBorder: isDarkMode ? 'border-gray-700' : 'border-gray-200',
|
||||||
|
pillBg: isDarkMode ? 'bg-gray-700' : 'bg-gray-100',
|
||||||
|
pillText: isDarkMode ? 'text-gray-200' : 'text-gray-700',
|
||||||
|
pillActiveBg: isDarkMode ? 'bg-blue-600' : 'bg-blue-100',
|
||||||
|
pillActiveText: isDarkMode ? 'text-white' : 'text-blue-800',
|
||||||
|
searchBg: isDarkMode ? 'bg-gray-700' : 'bg-gray-50',
|
||||||
|
searchBorder: isDarkMode ? 'border-gray-600' : 'border-gray-300',
|
||||||
|
searchText: isDarkMode ? 'text-gray-200' : 'text-gray-900',
|
||||||
|
}), [isDarkMode]);
|
||||||
|
|
||||||
|
// Calculate active filters count
|
||||||
|
useEffect(() => {
|
||||||
|
const count = filterSections.reduce((acc, section) => acc + section.selectedValues.length, 0);
|
||||||
|
setActiveFiltersCount(count + (searchValue ? 1 : 0));
|
||||||
|
}, [filterSections, searchValue]);
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleDropdownToggle = useCallback((sectionId: string) => {
|
||||||
|
setOpenDropdown(current => current === sectionId ? null : sectionId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelectionChange = useCallback((sectionId: string, values: string[]) => {
|
||||||
|
if (!projectId) return;
|
||||||
|
|
||||||
|
// Prevent clearing all group by options
|
||||||
|
if (sectionId === 'groupBy' && values.length === 0) {
|
||||||
|
return; // Do nothing
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update local state first
|
||||||
|
setFilterSections(prev => prev.map(section =>
|
||||||
|
section.id === sectionId
|
||||||
|
? { ...section, selectedValues: values }
|
||||||
|
: section
|
||||||
|
));
|
||||||
|
|
||||||
|
// Use task management slices for groupBy
|
||||||
|
if (sectionId === 'groupBy' && values.length > 0) {
|
||||||
|
dispatch(setCurrentGrouping(values[0] as 'status' | 'priority' | 'phase'));
|
||||||
|
dispatch(fetchTasksV3(projectId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle priorities
|
||||||
|
if (sectionId === 'priority') {
|
||||||
|
console.log('Priority selection changed:', { sectionId, values, projectId });
|
||||||
|
dispatch(setSelectedPriorities(values));
|
||||||
|
dispatch(fetchTasksV3(projectId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle assignees (members)
|
||||||
|
if (sectionId === 'assignees') {
|
||||||
|
// Update selected property for each assignee
|
||||||
|
const updatedAssignees = currentTaskAssignees.map(member => ({
|
||||||
|
...member,
|
||||||
|
selected: values.includes(member.id || '')
|
||||||
|
}));
|
||||||
|
dispatch(setMembers(updatedAssignees));
|
||||||
|
dispatch(fetchTasksV3(projectId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle labels
|
||||||
|
if (sectionId === 'labels') {
|
||||||
|
// Update selected property for each label
|
||||||
|
const updatedLabels = currentTaskLabels.map(label => ({
|
||||||
|
...label,
|
||||||
|
selected: values.includes(label.id || '')
|
||||||
|
}));
|
||||||
|
dispatch(setLabels(updatedLabels));
|
||||||
|
dispatch(fetchTasksV3(projectId));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, [dispatch, projectId, currentTaskAssignees, currentTaskLabels]);
|
||||||
|
|
||||||
|
const handleSearchChange = useCallback((value: string) => {
|
||||||
|
setSearchValue(value);
|
||||||
|
|
||||||
|
// Log the search change for now
|
||||||
|
console.log('Search change:', value, { projectView, projectId });
|
||||||
|
|
||||||
|
// TODO: Implement proper search dispatch
|
||||||
|
}, [projectView, projectId]);
|
||||||
|
|
||||||
|
const clearAllFilters = useCallback(() => {
|
||||||
|
// TODO: Implement clear all filters
|
||||||
|
console.log('Clear all filters');
|
||||||
|
setSearchValue('');
|
||||||
|
setShowArchived(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const toggleArchived = useCallback(() => {
|
||||||
|
setShowArchived(!showArchived);
|
||||||
|
// TODO: Implement proper archived toggle
|
||||||
|
console.log('Toggle archived:', !showArchived);
|
||||||
|
}, [showArchived]);
|
||||||
|
|
||||||
|
// Show fields dropdown functionality
|
||||||
|
const handleColumnVisibilityChange = useCallback(async (col: ITaskListColumn) => {
|
||||||
|
if (!projectId) return;
|
||||||
|
console.log('Column visibility change:', col);
|
||||||
|
// TODO: Implement column visibility change
|
||||||
|
}, [projectId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`${themeClasses.containerBg} border ${themeClasses.containerBorder} rounded-md p-3 shadow-sm ${className}`}>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Left Section - Main Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{/* Search */}
|
||||||
|
<SearchFilter
|
||||||
|
value={searchValue}
|
||||||
|
onChange={handleSearchChange}
|
||||||
|
placeholder="Search tasks..."
|
||||||
|
themeClasses={themeClasses}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Filter Dropdowns - Only render when data is loaded */}
|
||||||
|
{isDataLoaded ? (
|
||||||
|
filterSectionsData.map((section) => (
|
||||||
|
<FilterDropdown
|
||||||
|
key={section.id}
|
||||||
|
section={section}
|
||||||
|
onSelectionChange={handleSelectionChange}
|
||||||
|
isOpen={openDropdown === section.id}
|
||||||
|
onToggle={() => handleDropdownToggle(section.id)}
|
||||||
|
themeClasses={themeClasses}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
// Loading state
|
||||||
|
<div className={`flex items-center gap-2 px-2.5 py-1.5 text-xs ${themeClasses.secondaryText}`}>
|
||||||
|
<div className="animate-spin rounded-full h-3.5 w-3.5 border-b-2 border-blue-500"></div>
|
||||||
|
<span>Loading filters...</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Section - Additional Controls */}
|
||||||
|
<div className="flex items-center gap-2 ml-auto">
|
||||||
|
{/* Active Filters Indicator */}
|
||||||
|
{activeFiltersCount > 0 && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className={`text-xs ${themeClasses.secondaryText}`}>
|
||||||
|
{activeFiltersCount} filter{activeFiltersCount !== 1 ? 's' : ''} active
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={clearAllFilters}
|
||||||
|
className={`text-xs text-blue-600 hover:text-blue-700 font-medium transition-colors duration-150 ${
|
||||||
|
isDarkMode ? 'text-blue-400 hover:text-blue-300' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Clear all
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show Archived Toggle (for list view) */}
|
||||||
|
{position === 'list' && (
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showArchived}
|
||||||
|
onChange={toggleArchived}
|
||||||
|
className={`w-3.5 h-3.5 text-blue-600 rounded focus:ring-blue-500 transition-colors duration-150 ${
|
||||||
|
isDarkMode
|
||||||
|
? 'border-gray-600 bg-gray-700 focus:ring-offset-gray-800'
|
||||||
|
: 'border-gray-300 bg-white focus:ring-offset-white'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<span className={`text-xs ${themeClasses.optionText}`}>
|
||||||
|
Show archived
|
||||||
|
</span>
|
||||||
|
<InboxOutlined className={`w-3.5 h-3.5 ${themeClasses.secondaryText}`} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Show Fields Button (for list view) */}
|
||||||
|
{position === 'list' && (
|
||||||
|
<button className={`inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium rounded-md border transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-1 ${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText} ${
|
||||||
|
isDarkMode ? 'focus:ring-offset-gray-800' : 'focus:ring-offset-white'
|
||||||
|
}`}>
|
||||||
|
<EyeOutlined className="w-3.5 h-3.5" />
|
||||||
|
<span>Fields</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Active Filters Pills */}
|
||||||
|
{activeFiltersCount > 0 && (
|
||||||
|
<div className={`flex flex-wrap items-center gap-1.5 mt-2 pt-2 border-t ${themeClasses.dividerBorder}`}>
|
||||||
|
{searchValue && (
|
||||||
|
<div className={`inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-medium rounded-full ${themeClasses.pillActiveBg} ${themeClasses.pillActiveText}`}>
|
||||||
|
<SearchOutlined className="w-2.5 h-2.5" />
|
||||||
|
<span>"{searchValue}"</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchValue('')}
|
||||||
|
className={`ml-1 rounded-full p-0.5 transition-colors duration-150 ${
|
||||||
|
isDarkMode ? 'hover:bg-blue-800' : 'hover:bg-blue-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CloseOutlined className="w-2.5 h-2.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{filterSectionsData
|
||||||
|
.filter(section => section.id !== 'groupBy') // <-- skip groupBy
|
||||||
|
.map((section) =>
|
||||||
|
section.selectedValues.map((value) => {
|
||||||
|
const option = section.options.find(opt => opt.value === value);
|
||||||
|
if (!option) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={`${section.id}-${value}`}
|
||||||
|
className={`inline-flex items-center gap-1 px-1.5 py-0.5 text-xs font-medium rounded-full ${themeClasses.pillBg} ${themeClasses.pillText}`}
|
||||||
|
>
|
||||||
|
{option.color && (
|
||||||
|
<div
|
||||||
|
className="w-2 h-2 rounded-full"
|
||||||
|
style={{ backgroundColor: option.color }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span>{option.label}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const newValues = section.selectedValues.filter(v => v !== value);
|
||||||
|
handleSelectionChange(section.id, newValues);
|
||||||
|
}}
|
||||||
|
className={`ml-1 rounded-full p-0.5 transition-colors duration-150 ${
|
||||||
|
isDarkMode ? 'hover:bg-gray-600' : 'hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CloseOutlined className="w-2.5 h-2.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default React.memo(ImprovedTaskFilters);
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState, useMemo, useCallback } from 'react';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { Button, Typography } from 'antd';
|
import { Button, Typography } from 'antd';
|
||||||
import { PlusOutlined, RightOutlined, DownOutlined } from '@ant-design/icons';
|
import { PlusOutlined, RightOutlined, DownOutlined } from '@ant-design/icons';
|
||||||
import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
import { TaskGroup as TaskGroupType, Task } from '@/types/task-management.types';
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { taskManagementSelectors } from '@/features/task-management/task-management.slice';
|
||||||
import { IGroupBy, COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import TaskRow from './task-row';
|
import TaskRow from './task-row';
|
||||||
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
|
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
|
||||||
@@ -14,9 +13,9 @@ import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-tabl
|
|||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
interface TaskGroupProps {
|
interface TaskGroupProps {
|
||||||
group: ITaskListGroup;
|
group: TaskGroupType;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
currentGrouping: IGroupBy;
|
currentGrouping: 'status' | 'priority' | 'phase';
|
||||||
selectedTaskIds: string[];
|
selectedTaskIds: string[];
|
||||||
onAddTask?: (groupId: string) => void;
|
onAddTask?: (groupId: string) => void;
|
||||||
onToggleCollapse?: (groupId: string) => void;
|
onToggleCollapse?: (groupId: string) => void;
|
||||||
@@ -24,7 +23,40 @@ interface TaskGroupProps {
|
|||||||
onToggleSubtasks?: (taskId: string) => void;
|
onToggleSubtasks?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TaskGroup: React.FC<TaskGroupProps> = ({
|
// Group color mapping - moved outside component for better performance
|
||||||
|
const GROUP_COLORS = {
|
||||||
|
status: {
|
||||||
|
todo: '#faad14',
|
||||||
|
doing: '#1890ff',
|
||||||
|
done: '#52c41a',
|
||||||
|
},
|
||||||
|
priority: {
|
||||||
|
high: '#fa8c16',
|
||||||
|
medium: '#faad14',
|
||||||
|
low: '#52c41a',
|
||||||
|
},
|
||||||
|
phase: '#722ed1',
|
||||||
|
default: '#d9d9d9',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Column configurations for consistent layout
|
||||||
|
const FIXED_COLUMNS = [
|
||||||
|
{ key: 'drag', label: '', width: 40, fixed: true },
|
||||||
|
{ key: 'select', label: '', width: 40, fixed: true },
|
||||||
|
{ key: 'key', label: 'Key', width: 80, fixed: true },
|
||||||
|
{ key: 'task', label: 'Task', width: 475, fixed: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SCROLLABLE_COLUMNS = [
|
||||||
|
{ key: 'progress', label: 'Progress', width: 90 },
|
||||||
|
{ key: 'members', label: 'Members', width: 150 },
|
||||||
|
{ key: 'labels', label: 'Labels', width: 200 },
|
||||||
|
{ key: 'status', label: 'Status', width: 100 },
|
||||||
|
{ key: 'priority', label: 'Priority', width: 100 },
|
||||||
|
{ key: 'timeTracking', label: 'Time Tracking', width: 120 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
||||||
group,
|
group,
|
||||||
projectId,
|
projectId,
|
||||||
currentGrouping,
|
currentGrouping,
|
||||||
@@ -34,7 +66,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
onSelectTask,
|
onSelectTask,
|
||||||
onToggleSubtasks,
|
onToggleSubtasks,
|
||||||
}) => {
|
}) => {
|
||||||
const [isCollapsed, setIsCollapsed] = useState(false);
|
const [isCollapsed, setIsCollapsed] = useState(group.collapsed || false);
|
||||||
|
|
||||||
const { setNodeRef, isOver } = useDroppable({
|
const { setNodeRef, isOver } = useDroppable({
|
||||||
id: group.id,
|
id: group.id,
|
||||||
@@ -44,197 +76,182 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Get column visibility from Redux store
|
// Get all tasks from the store
|
||||||
const columns = useSelector((state: RootState) => state.taskReducer.columns);
|
const allTasks = useSelector(taskManagementSelectors.selectAll);
|
||||||
|
|
||||||
|
// Get theme from Redux store
|
||||||
|
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
||||||
|
|
||||||
|
// Get tasks for this group using memoization for performance
|
||||||
|
const groupTasks = useMemo(() => {
|
||||||
|
return group.taskIds
|
||||||
|
.map(taskId => allTasks.find(task => task.id === taskId))
|
||||||
|
.filter((task): task is Task => task !== undefined);
|
||||||
|
}, [group.taskIds, allTasks]);
|
||||||
|
|
||||||
// Helper function to check if a column is visible
|
// Calculate group statistics - memoized
|
||||||
const isColumnVisible = (columnKey: string) => {
|
const { completedTasks, totalTasks, completionRate } = useMemo(() => {
|
||||||
const column = columns.find(col => col.key === columnKey);
|
const completed = groupTasks.filter(task => task.progress === 100).length;
|
||||||
return column ? column.pinned : true; // Default to visible if column not found
|
const total = groupTasks.length;
|
||||||
};
|
const rate = total > 0 ? Math.round((completed / total) * 100) : 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
completedTasks: completed,
|
||||||
|
totalTasks: total,
|
||||||
|
completionRate: rate,
|
||||||
|
};
|
||||||
|
}, [groupTasks]);
|
||||||
|
|
||||||
// Get task IDs for sortable context
|
// Get group color based on grouping type - memoized
|
||||||
const taskIds = group.tasks.map(task => task.id!);
|
const groupColor = useMemo(() => {
|
||||||
|
if (group.color) return group.color;
|
||||||
// Calculate group statistics
|
|
||||||
const completedTasks = group.tasks.filter(
|
|
||||||
task => task.status_category?.is_done || task.complete_ratio === 100
|
|
||||||
).length;
|
|
||||||
const totalTasks = group.tasks.length;
|
|
||||||
const completionRate = totalTasks > 0 ? Math.round((completedTasks / totalTasks) * 100) : 0;
|
|
||||||
|
|
||||||
// Get group color based on grouping type
|
|
||||||
const getGroupColor = () => {
|
|
||||||
if (group.color_code) return group.color_code;
|
|
||||||
|
|
||||||
// Fallback colors based on group value
|
// Fallback colors based on group value
|
||||||
switch (currentGrouping) {
|
switch (currentGrouping) {
|
||||||
case 'status':
|
case 'status':
|
||||||
return group.id === 'todo' ? '#faad14' : group.id === 'doing' ? '#1890ff' : '#52c41a';
|
return GROUP_COLORS.status[group.groupValue as keyof typeof GROUP_COLORS.status] || GROUP_COLORS.default;
|
||||||
case 'priority':
|
case 'priority':
|
||||||
return group.id === 'critical'
|
return GROUP_COLORS.priority[group.groupValue as keyof typeof GROUP_COLORS.priority] || GROUP_COLORS.default;
|
||||||
? '#ff4d4f'
|
|
||||||
: group.id === 'high'
|
|
||||||
? '#fa8c16'
|
|
||||||
: group.id === 'medium'
|
|
||||||
? '#faad14'
|
|
||||||
: '#52c41a';
|
|
||||||
case 'phase':
|
case 'phase':
|
||||||
return '#722ed1';
|
return GROUP_COLORS.phase;
|
||||||
default:
|
default:
|
||||||
return '#d9d9d9';
|
return GROUP_COLORS.default;
|
||||||
}
|
}
|
||||||
};
|
}, [group.color, group.groupValue, currentGrouping]);
|
||||||
|
|
||||||
const handleToggleCollapse = () => {
|
// Memoized event handlers
|
||||||
|
const handleToggleCollapse = useCallback(() => {
|
||||||
setIsCollapsed(!isCollapsed);
|
setIsCollapsed(!isCollapsed);
|
||||||
onToggleCollapse?.(group.id);
|
onToggleCollapse?.(group.id);
|
||||||
};
|
}, [isCollapsed, onToggleCollapse, group.id]);
|
||||||
|
|
||||||
const handleAddTask = () => {
|
const handleAddTask = useCallback(() => {
|
||||||
onAddTask?.(group.id);
|
onAddTask?.(group.id);
|
||||||
};
|
}, [onAddTask, group.id]);
|
||||||
|
|
||||||
|
// Memoized style object
|
||||||
|
const containerStyle = useMemo(() => ({
|
||||||
|
backgroundColor: isOver
|
||||||
|
? (isDarkMode ? '#1a2332' : '#f0f8ff')
|
||||||
|
: undefined,
|
||||||
|
}), [isOver, isDarkMode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
className={`task-group`}
|
||||||
className={`task-group ${isOver ? 'drag-over' : ''}`}
|
style={{ ...containerStyle, overflowX: 'unset' }}
|
||||||
style={{
|
|
||||||
backgroundColor: isOver ? '#f0f8ff' : undefined,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{/* Group Header Row */}
|
<div className="task-group-scroll-wrapper" style={{ overflowX: 'auto', width: '100%' }}>
|
||||||
<div className="task-group-header">
|
<div style={{ minWidth: FIXED_COLUMNS.reduce((sum, col) => sum + col.width, 0) + SCROLLABLE_COLUMNS.reduce((sum, col) => sum + col.width, 0) }}>
|
||||||
<div className="task-group-header-row">
|
{/* Group Header Row */}
|
||||||
<div
|
<div className="task-group-header">
|
||||||
className="task-group-header-content"
|
<div className="task-group-header-row">
|
||||||
style={{ backgroundColor: getGroupColor() }}
|
<div
|
||||||
>
|
className="task-group-header-content"
|
||||||
<Button
|
style={{ backgroundColor: groupColor }}
|
||||||
type="text"
|
>
|
||||||
size="small"
|
<Button
|
||||||
icon={isCollapsed ? <RightOutlined /> : <DownOutlined />}
|
type="text"
|
||||||
onClick={handleToggleCollapse}
|
size="small"
|
||||||
className="task-group-header-button"
|
icon={isCollapsed ? <RightOutlined /> : <DownOutlined />}
|
||||||
/>
|
onClick={handleToggleCollapse}
|
||||||
<Text strong className="task-group-header-text">
|
className="task-group-header-button"
|
||||||
{group.name} ({totalTasks})
|
/>
|
||||||
</Text>
|
<Text strong className="task-group-header-text">
|
||||||
</div>
|
{group.title} ({totalTasks})
|
||||||
</div>
|
</Text>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Column Headers */}
|
|
||||||
{!isCollapsed && totalTasks > 0 && (
|
|
||||||
<div
|
|
||||||
className="task-group-column-headers"
|
|
||||||
style={{ borderLeft: `4px solid ${getGroupColor()}` }}
|
|
||||||
>
|
|
||||||
<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' }}>
|
|
||||||
<Text className="column-header-text">Key</Text>
|
|
||||||
</div>
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '475px' }}>
|
|
||||||
<Text className="column-header-text">Task</Text>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="task-table-scrollable-columns">
|
|
||||||
{isColumnVisible(COLUMN_KEYS.PROGRESS) && (
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '90px' }}>
|
|
||||||
<Text className="column-header-text">Progress</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isColumnVisible(COLUMN_KEYS.ASSIGNEES) && (
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '150px' }}>
|
|
||||||
<Text className="column-header-text">Members</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isColumnVisible(COLUMN_KEYS.LABELS) && (
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '150px' }}>
|
|
||||||
<Text className="column-header-text">Labels</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isColumnVisible(COLUMN_KEYS.STATUS) && (
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
|
||||||
<Text className="column-header-text">Status</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isColumnVisible(COLUMN_KEYS.PRIORITY) && (
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
|
||||||
<Text className="column-header-text">Priority</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{isColumnVisible(COLUMN_KEYS.TIME_TRACKING) && (
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '120px' }}>
|
|
||||||
<Text className="column-header-text">Time Tracking</Text>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tasks List */}
|
{/* Column Headers */}
|
||||||
{!isCollapsed && (
|
{!isCollapsed && totalTasks > 0 && (
|
||||||
<div
|
<div
|
||||||
className="task-group-body"
|
className="task-group-column-headers"
|
||||||
style={{ borderLeft: `4px solid ${getGroupColor()}` }}
|
style={{ borderLeft: `4px solid ${groupColor}` }}
|
||||||
>
|
>
|
||||||
{group.tasks.length === 0 ? (
|
<div className="task-group-column-headers-row">
|
||||||
<div className="task-group-empty">
|
<div className="task-table-fixed-columns">
|
||||||
<div className="task-table-fixed-columns">
|
{FIXED_COLUMNS.map(col => (
|
||||||
<div style={{ width: '380px', padding: '20px 12px' }}>
|
<div
|
||||||
<div className="text-center text-gray-500">
|
key={col.key}
|
||||||
<Text type="secondary">No tasks in this group</Text>
|
className="task-table-cell task-table-header-cell"
|
||||||
<br />
|
style={{ width: col.width }}
|
||||||
<Button
|
|
||||||
type="link"
|
|
||||||
icon={<PlusOutlined />}
|
|
||||||
onClick={handleAddTask}
|
|
||||||
className="mt-2"
|
|
||||||
>
|
>
|
||||||
Add first task
|
{col.label && <Text className="column-header-text">{col.label}</Text>}
|
||||||
</Button>
|
</div>
|
||||||
</div>
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="task-table-scrollable-columns">
|
||||||
|
{SCROLLABLE_COLUMNS.map(col => (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
className="task-table-cell task-table-header-cell"
|
||||||
|
style={{ width: col.width }}
|
||||||
|
>
|
||||||
|
<Text className="column-header-text">{col.label}</Text>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<SortableContext items={taskIds} strategy={verticalListSortingStrategy}>
|
|
||||||
<div className="task-group-tasks">
|
|
||||||
{group.tasks.map((task, index) => (
|
|
||||||
<TaskRow
|
|
||||||
key={task.id}
|
|
||||||
task={task}
|
|
||||||
projectId={projectId}
|
|
||||||
groupId={group.id}
|
|
||||||
currentGrouping={currentGrouping}
|
|
||||||
isSelected={selectedTaskIds.includes(task.id!)}
|
|
||||||
index={index}
|
|
||||||
onSelect={onSelectTask}
|
|
||||||
onToggleSubtasks={onToggleSubtasks}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Task Row - Always show when not collapsed */}
|
{/* Tasks List */}
|
||||||
<div className="task-group-add-task">
|
{!isCollapsed && (
|
||||||
<AddTaskListRow groupId={group.id} />
|
<div
|
||||||
</div>
|
className="task-group-body"
|
||||||
</div>
|
style={{ borderLeft: `4px solid ${groupColor}` }}
|
||||||
)}
|
>
|
||||||
|
{groupTasks.length === 0 ? (
|
||||||
|
<div className="task-group-empty">
|
||||||
|
<div className="task-table-fixed-columns">
|
||||||
|
<div style={{ width: '380px', padding: '20px 12px' }}>
|
||||||
|
<div className="text-center text-gray-500">
|
||||||
|
<Text type="secondary">No tasks in this group</Text>
|
||||||
|
<br />
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
icon={<PlusOutlined />}
|
||||||
|
onClick={handleAddTask}
|
||||||
|
className="mt-2"
|
||||||
|
>
|
||||||
|
Add first task
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<SortableContext items={group.taskIds} strategy={verticalListSortingStrategy}>
|
||||||
|
<div className="task-group-tasks">
|
||||||
|
{groupTasks.map((task, index) => (
|
||||||
|
<TaskRow
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
projectId={projectId}
|
||||||
|
groupId={group.id}
|
||||||
|
currentGrouping={currentGrouping}
|
||||||
|
isSelected={selectedTaskIds.includes(task.id)}
|
||||||
|
index={index}
|
||||||
|
onSelect={onSelectTask}
|
||||||
|
onToggleSubtasks={onToggleSubtasks}
|
||||||
|
fixedColumns={FIXED_COLUMNS}
|
||||||
|
scrollableColumns={SCROLLABLE_COLUMNS}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add Task Row - Always show when not collapsed */}
|
||||||
|
<div className="task-group-add-task">
|
||||||
|
<AddTaskListRow groupId={group.id} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<style>{`
|
<style>{`
|
||||||
.task-group {
|
.task-group {
|
||||||
border: 1px solid var(--task-border-primary, #e8e8e8);
|
border: 1px solid var(--task-border-primary, #e8e8e8);
|
||||||
@@ -242,7 +259,6 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
background: var(--task-bg-primary, white);
|
background: var(--task-bg-primary, white);
|
||||||
box-shadow: 0 1px 3px var(--task-shadow, rgba(0, 0, 0, 0.1));
|
box-shadow: 0 1px 3px var(--task-shadow, rgba(0, 0, 0, 0.1));
|
||||||
overflow-x: auto;
|
|
||||||
overflow-y: visible;
|
overflow-y: visible;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -258,14 +274,14 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
.task-group-header-row {
|
.task-group-header-row {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
height: auto;
|
height: auto;
|
||||||
max-height: none;
|
max-height: none;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-group-header-content {
|
.task-group-header-content {
|
||||||
display: inline-flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 6px 6px 0 0;
|
border-radius: 6px 6px 0 0;
|
||||||
@@ -322,7 +338,6 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
max-height: 40px;
|
max-height: 40px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
position: relative;
|
position: relative;
|
||||||
min-width: 1200px; /* Ensure minimum width for all columns */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-table-header-cell {
|
.task-table-header-cell {
|
||||||
@@ -387,11 +402,7 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
.task-table-fixed-columns {
|
.task-table-fixed-columns {
|
||||||
display: flex;
|
display: flex;
|
||||||
background: var(--task-bg-secondary, #f5f5f5);
|
background: var(--task-bg-secondary, #f5f5f5);
|
||||||
position: sticky;
|
|
||||||
left: 0;
|
|
||||||
z-index: 11;
|
|
||||||
border-right: 2px solid var(--task-border-primary, #e8e8e8);
|
border-right: 2px solid var(--task-border-primary, #e8e8e8);
|
||||||
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
|
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -445,6 +456,17 @@ const TaskGroup: React.FC<TaskGroupProps> = ({
|
|||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
}, (prevProps, nextProps) => {
|
||||||
|
// Simplified comparison for better performance
|
||||||
|
return (
|
||||||
|
prevProps.group.id === nextProps.group.id &&
|
||||||
|
prevProps.group.taskIds.length === nextProps.group.taskIds.length &&
|
||||||
|
prevProps.group.collapsed === nextProps.group.collapsed &&
|
||||||
|
prevProps.selectedTaskIds.length === nextProps.selectedTaskIds.length &&
|
||||||
|
prevProps.currentGrouping === nextProps.currentGrouping
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
TaskGroup.displayName = 'TaskGroup';
|
||||||
|
|
||||||
export default TaskGroup;
|
export default TaskGroup;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState, useMemo } from 'react';
|
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
@@ -12,25 +12,41 @@ import {
|
|||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
} from '@dnd-kit/core';
|
} from '@dnd-kit/core';
|
||||||
import {
|
import { sortableKeyboardCoordinates } from '@dnd-kit/sortable';
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { Card, Spin, Empty } from 'antd';
|
import { Card, Spin, Empty } from 'antd';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import {
|
import {
|
||||||
IGroupBy,
|
taskManagementSelectors,
|
||||||
setGroup,
|
|
||||||
fetchTaskGroups,
|
|
||||||
reorderTasks,
|
reorderTasks,
|
||||||
} from '@/features/tasks/tasks.slice';
|
moveTaskToGroup,
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
optimisticTaskMove,
|
||||||
import TaskGroup from './task-group';
|
setLoading,
|
||||||
|
fetchTasks,
|
||||||
|
fetchTasksV3,
|
||||||
|
selectTaskGroupsV3,
|
||||||
|
selectCurrentGroupingV3,
|
||||||
|
} from '@/features/task-management/task-management.slice';
|
||||||
|
import {
|
||||||
|
selectTaskGroups,
|
||||||
|
selectCurrentGrouping,
|
||||||
|
setCurrentGrouping,
|
||||||
|
} from '@/features/task-management/grouping.slice';
|
||||||
|
import {
|
||||||
|
selectSelectedTaskIds,
|
||||||
|
toggleTaskSelection,
|
||||||
|
clearSelection,
|
||||||
|
} from '@/features/task-management/selection.slice';
|
||||||
|
import { Task } from '@/types/task-management.types';
|
||||||
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
import TaskRow from './task-row';
|
import TaskRow from './task-row';
|
||||||
import BulkActionBar from './bulk-action-bar';
|
// import BulkActionBar from './bulk-action-bar';
|
||||||
|
import VirtualizedTaskList from './virtualized-task-list';
|
||||||
import { AppDispatch } from '@/app/store';
|
import { AppDispatch } from '@/app/store';
|
||||||
|
|
||||||
// Import the TaskListFilters component
|
// Import the improved TaskListFilters component
|
||||||
const TaskListFilters = React.lazy(() => import('@/pages/projects/projectView/taskList/task-list-filters/task-list-filters'));
|
const ImprovedTaskFilters = React.lazy(
|
||||||
|
() => import('./improved-task-filters')
|
||||||
|
);
|
||||||
|
|
||||||
interface TaskListBoardProps {
|
interface TaskListBoardProps {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -38,10 +54,34 @@ interface TaskListBoardProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DragState {
|
interface DragState {
|
||||||
activeTask: IProjectTask | null;
|
activeTask: Task | null;
|
||||||
activeGroupId: string | null;
|
activeGroupId: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Throttle utility for performance optimization
|
||||||
|
const throttle = <T extends (...args: any[]) => void>(func: T, delay: number): T => {
|
||||||
|
let timeoutId: NodeJS.Timeout | null = null;
|
||||||
|
let lastExecTime = 0;
|
||||||
|
|
||||||
|
return ((...args: any[]) => {
|
||||||
|
const currentTime = Date.now();
|
||||||
|
|
||||||
|
if (currentTime - lastExecTime > delay) {
|
||||||
|
func(...args);
|
||||||
|
lastExecTime = currentTime;
|
||||||
|
} else {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
timeoutId = setTimeout(
|
||||||
|
() => {
|
||||||
|
func(...args);
|
||||||
|
lastExecTime = Date.now();
|
||||||
|
},
|
||||||
|
delay - (currentTime - lastExecTime)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}) as T;
|
||||||
|
};
|
||||||
|
|
||||||
const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = '' }) => {
|
const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = '' }) => {
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
const [dragState, setDragState] = useState<DragState>({
|
const [dragState, setDragState] = useState<DragState>({
|
||||||
@@ -49,24 +89,29 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
activeGroupId: null,
|
activeGroupId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Redux selectors
|
// Refs for performance optimization
|
||||||
const {
|
const dragOverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
taskGroups,
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
loadingGroups,
|
|
||||||
error,
|
|
||||||
groupBy,
|
|
||||||
search,
|
|
||||||
archived,
|
|
||||||
} = useSelector((state: RootState) => state.taskReducer);
|
|
||||||
|
|
||||||
// Selection state
|
// Enable real-time socket updates for task changes
|
||||||
const [selectedTaskIds, setSelectedTaskIds] = useState<string[]>([]);
|
useTaskSocketHandlers();
|
||||||
|
|
||||||
// Drag and Drop sensors
|
// Redux selectors using V3 API (pre-processed data, minimal loops)
|
||||||
|
const tasks = useSelector(taskManagementSelectors.selectAll);
|
||||||
|
const taskGroups = useSelector(selectTaskGroupsV3); // Pre-processed groups from backend
|
||||||
|
const currentGrouping = useSelector(selectCurrentGroupingV3); // Current grouping from backend
|
||||||
|
const selectedTaskIds = useSelector(selectSelectedTaskIds);
|
||||||
|
const loading = useSelector((state: RootState) => state.taskManagement.loading);
|
||||||
|
const error = useSelector((state: RootState) => state.taskManagement.error);
|
||||||
|
|
||||||
|
// Get theme from Redux store
|
||||||
|
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
||||||
|
|
||||||
|
// Drag and Drop sensors - optimized for better performance
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
useSensor(PointerSensor, {
|
useSensor(PointerSensor, {
|
||||||
activationConstraint: {
|
activationConstraint: {
|
||||||
distance: 8,
|
distance: 3, // Reduced from 8 for more responsive dragging
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
useSensor(KeyboardSensor, {
|
useSensor(KeyboardSensor, {
|
||||||
@@ -77,227 +122,322 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
// Fetch task groups when component mounts or dependencies change
|
// Fetch task groups when component mounts or dependencies change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
dispatch(fetchTaskGroups(projectId));
|
// Fetch real tasks from V3 API (minimal processing needed)
|
||||||
|
dispatch(fetchTasksV3(projectId));
|
||||||
}
|
}
|
||||||
}, [dispatch, projectId, groupBy, search, archived]);
|
}, [dispatch, projectId, currentGrouping]);
|
||||||
|
|
||||||
// Memoized calculations
|
|
||||||
const allTaskIds = useMemo(() => {
|
|
||||||
return taskGroups.flatMap(group => group.tasks.map(task => task.id!));
|
|
||||||
}, [taskGroups]);
|
|
||||||
|
|
||||||
const totalTasksCount = useMemo(() => {
|
|
||||||
return taskGroups.reduce((sum, group) => sum + group.tasks.length, 0);
|
|
||||||
}, [taskGroups]);
|
|
||||||
|
|
||||||
|
// Memoized calculations - optimized
|
||||||
|
const allTaskIds = useMemo(() => tasks.map(task => task.id), [tasks]);
|
||||||
|
const totalTasksCount = useMemo(() => tasks.length, [tasks]);
|
||||||
const hasSelection = selectedTaskIds.length > 0;
|
const hasSelection = selectedTaskIds.length > 0;
|
||||||
|
|
||||||
// Handlers
|
// Memoized handlers for better performance
|
||||||
const handleGroupingChange = (newGroupBy: IGroupBy) => {
|
const handleGroupingChange = useCallback(
|
||||||
dispatch(setGroup(newGroupBy));
|
(newGroupBy: 'status' | 'priority' | 'phase') => {
|
||||||
};
|
dispatch(setCurrentGrouping(newGroupBy));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
const handleDragStart = useCallback(
|
||||||
const { active } = event;
|
(event: DragStartEvent) => {
|
||||||
const taskId = active.id as string;
|
const { active } = event;
|
||||||
|
const taskId = active.id as string;
|
||||||
// Find the task and its group
|
|
||||||
let activeTask: IProjectTask | null = null;
|
|
||||||
let activeGroupId: string | null = null;
|
|
||||||
|
|
||||||
for (const group of taskGroups) {
|
// Find the task and its group
|
||||||
const task = group.tasks.find(t => t.id === taskId);
|
const activeTask = tasks.find(t => t.id === taskId) || null;
|
||||||
if (task) {
|
let activeGroupId: string | null = null;
|
||||||
activeTask = task;
|
|
||||||
activeGroupId = group.id;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setDragState({
|
if (activeTask) {
|
||||||
activeTask,
|
// Determine group ID based on current grouping
|
||||||
activeGroupId,
|
if (currentGrouping === 'status') {
|
||||||
});
|
activeGroupId = `status-${activeTask.status}`;
|
||||||
};
|
} else if (currentGrouping === 'priority') {
|
||||||
|
activeGroupId = `priority-${activeTask.priority}`;
|
||||||
const handleDragOver = (event: DragOverEvent) => {
|
} else if (currentGrouping === 'phase') {
|
||||||
// Handle drag over logic if needed for visual feedback
|
activeGroupId = `phase-${activeTask.phase}`;
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
|
|
||||||
setDragState({
|
|
||||||
activeTask: null,
|
|
||||||
activeGroupId: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!over || !dragState.activeTask || !dragState.activeGroupId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeTaskId = active.id as string;
|
|
||||||
const overContainer = over.id as string;
|
|
||||||
|
|
||||||
// Determine if dropping on a group or task
|
|
||||||
const overGroup = taskGroups.find(g => g.id === overContainer);
|
|
||||||
let targetGroupId = overContainer;
|
|
||||||
let targetIndex = -1;
|
|
||||||
|
|
||||||
if (!overGroup) {
|
|
||||||
// Dropping on a task, find which group it belongs to
|
|
||||||
for (const group of taskGroups) {
|
|
||||||
const taskIndex = group.tasks.findIndex(t => t.id === overContainer);
|
|
||||||
if (taskIndex !== -1) {
|
|
||||||
targetGroupId = group.id;
|
|
||||||
targetIndex = taskIndex;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const sourceGroup = taskGroups.find(g => g.id === dragState.activeGroupId);
|
setDragState({
|
||||||
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
activeTask,
|
||||||
|
activeGroupId,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[tasks, currentGrouping]
|
||||||
|
);
|
||||||
|
|
||||||
if (!sourceGroup || !targetGroup) return;
|
// Throttled drag over handler for better performance
|
||||||
|
const handleDragOver = useCallback(
|
||||||
|
throttle((event: DragOverEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
const sourceIndex = sourceGroup.tasks.findIndex(t => t.id === activeTaskId);
|
if (!over || !dragState.activeTask) return;
|
||||||
if (sourceIndex === -1) return;
|
|
||||||
|
|
||||||
// Calculate new positions
|
const activeTaskId = active.id as string;
|
||||||
const finalTargetIndex = targetIndex === -1 ? targetGroup.tasks.length : targetIndex;
|
const overContainer = over.id as string;
|
||||||
|
|
||||||
// Create updated task arrays
|
// Clear any existing timeout
|
||||||
const updatedSourceTasks = [...sourceGroup.tasks];
|
if (dragOverTimeoutRef.current) {
|
||||||
const [movedTask] = updatedSourceTasks.splice(sourceIndex, 1);
|
clearTimeout(dragOverTimeoutRef.current);
|
||||||
|
|
||||||
let updatedTargetTasks: IProjectTask[];
|
|
||||||
if (sourceGroup.id === targetGroup.id) {
|
|
||||||
// Moving within the same group
|
|
||||||
updatedTargetTasks = updatedSourceTasks;
|
|
||||||
updatedTargetTasks.splice(finalTargetIndex, 0, movedTask);
|
|
||||||
} else {
|
|
||||||
// Moving between different groups
|
|
||||||
updatedTargetTasks = [...targetGroup.tasks];
|
|
||||||
updatedTargetTasks.splice(finalTargetIndex, 0, movedTask);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dispatch the reorder action
|
|
||||||
dispatch(reorderTasks({
|
|
||||||
activeGroupId: sourceGroup.id,
|
|
||||||
overGroupId: targetGroup.id,
|
|
||||||
fromIndex: sourceIndex,
|
|
||||||
toIndex: finalTargetIndex,
|
|
||||||
task: movedTask,
|
|
||||||
updatedSourceTasks,
|
|
||||||
updatedTargetTasks,
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleSelectTask = (taskId: string, selected: boolean) => {
|
|
||||||
setSelectedTaskIds(prev => {
|
|
||||||
if (selected) {
|
|
||||||
return [...prev, taskId];
|
|
||||||
} else {
|
|
||||||
return prev.filter(id => id !== taskId);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleToggleSubtasks = (taskId: string) => {
|
// Optimistic update with throttling
|
||||||
|
dragOverTimeoutRef.current = setTimeout(() => {
|
||||||
|
// Only update if we're hovering over a different container
|
||||||
|
const targetTask = tasks.find(t => t.id === overContainer);
|
||||||
|
let targetGroupId = overContainer;
|
||||||
|
|
||||||
|
if (targetTask) {
|
||||||
|
if (currentGrouping === 'status') {
|
||||||
|
targetGroupId = `status-${targetTask.status}`;
|
||||||
|
} else if (currentGrouping === 'priority') {
|
||||||
|
targetGroupId = `priority-${targetTask.priority}`;
|
||||||
|
} else if (currentGrouping === 'phase') {
|
||||||
|
targetGroupId = `phase-${targetTask.phase}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (targetGroupId !== dragState.activeGroupId) {
|
||||||
|
// Perform optimistic update for visual feedback
|
||||||
|
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
||||||
|
if (targetGroup) {
|
||||||
|
dispatch(
|
||||||
|
optimisticTaskMove({
|
||||||
|
taskId: activeTaskId,
|
||||||
|
newGroupId: targetGroupId,
|
||||||
|
newIndex: targetGroup.taskIds.length,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 50); // 50ms throttle for drag over events
|
||||||
|
}, 50),
|
||||||
|
[dragState, tasks, taskGroups, currentGrouping, dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
// Clear any pending drag over timeouts
|
||||||
|
if (dragOverTimeoutRef.current) {
|
||||||
|
clearTimeout(dragOverTimeoutRef.current);
|
||||||
|
dragOverTimeoutRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset drag state immediately for better UX
|
||||||
|
const currentDragState = dragState;
|
||||||
|
setDragState({
|
||||||
|
activeTask: null,
|
||||||
|
activeGroupId: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!over || !currentDragState.activeTask || !currentDragState.activeGroupId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeTaskId = active.id as string;
|
||||||
|
const overContainer = over.id as string;
|
||||||
|
|
||||||
|
// Parse the group ID to get group type and value - optimized
|
||||||
|
const parseGroupId = (groupId: string) => {
|
||||||
|
const [groupType, ...groupValueParts] = groupId.split('-');
|
||||||
|
return {
|
||||||
|
groupType: groupType as 'status' | 'priority' | 'phase',
|
||||||
|
groupValue: groupValueParts.join('-'),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine target group
|
||||||
|
let targetGroupId = overContainer;
|
||||||
|
let targetIndex = -1;
|
||||||
|
|
||||||
|
// Check if dropping on a task or a group
|
||||||
|
const targetTask = tasks.find(t => t.id === overContainer);
|
||||||
|
if (targetTask) {
|
||||||
|
// Dropping on a task, determine its group
|
||||||
|
if (currentGrouping === 'status') {
|
||||||
|
targetGroupId = `status-${targetTask.status}`;
|
||||||
|
} else if (currentGrouping === 'priority') {
|
||||||
|
targetGroupId = `priority-${targetTask.priority}`;
|
||||||
|
} else if (currentGrouping === 'phase') {
|
||||||
|
targetGroupId = `phase-${targetTask.phase}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the index of the target task within its group
|
||||||
|
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
||||||
|
if (targetGroup) {
|
||||||
|
targetIndex = targetGroup.taskIds.indexOf(targetTask.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourceGroupInfo = parseGroupId(currentDragState.activeGroupId);
|
||||||
|
const targetGroupInfo = parseGroupId(targetGroupId);
|
||||||
|
|
||||||
|
// If moving between different groups, update the task's group property
|
||||||
|
if (currentDragState.activeGroupId !== targetGroupId) {
|
||||||
|
dispatch(
|
||||||
|
moveTaskToGroup({
|
||||||
|
taskId: activeTaskId,
|
||||||
|
groupType: targetGroupInfo.groupType,
|
||||||
|
groupValue: targetGroupInfo.groupValue,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle reordering within the same group or between groups
|
||||||
|
const sourceGroup = taskGroups.find(g => g.id === currentDragState.activeGroupId);
|
||||||
|
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
||||||
|
|
||||||
|
if (sourceGroup && targetGroup && targetIndex !== -1) {
|
||||||
|
const sourceIndex = sourceGroup.taskIds.indexOf(activeTaskId);
|
||||||
|
const finalTargetIndex = targetIndex === -1 ? targetGroup.taskIds.length : targetIndex;
|
||||||
|
|
||||||
|
// Only reorder if actually moving to a different position
|
||||||
|
if (sourceGroup.id !== targetGroup.id || sourceIndex !== finalTargetIndex) {
|
||||||
|
// Calculate new order values - simplified
|
||||||
|
const allTasksInTargetGroup = targetGroup.taskIds.map(
|
||||||
|
id => tasks.find(t => t.id === id)!
|
||||||
|
);
|
||||||
|
const newOrder = allTasksInTargetGroup.map((task, index) => {
|
||||||
|
if (index < finalTargetIndex) return task.order;
|
||||||
|
if (index === finalTargetIndex) return currentDragState.activeTask!.order;
|
||||||
|
return task.order + 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Dispatch reorder action
|
||||||
|
dispatch(
|
||||||
|
reorderTasks({
|
||||||
|
taskIds: [activeTaskId, ...allTasksInTargetGroup.map(t => t.id)],
|
||||||
|
newOrder: [currentDragState.activeTask!.order, ...newOrder],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dragState, tasks, taskGroups, currentGrouping, dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSelectTask = useCallback(
|
||||||
|
(taskId: string, selected: boolean) => {
|
||||||
|
dispatch(toggleTaskSelection(taskId));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleSubtasks = useCallback((taskId: string) => {
|
||||||
// Implementation for toggling subtasks
|
// Implementation for toggling subtasks
|
||||||
console.log('Toggle subtasks for task:', taskId);
|
console.log('Toggle subtasks for task:', taskId);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
// Memoized DragOverlay content for better performance
|
||||||
|
const dragOverlayContent = useMemo(() => {
|
||||||
|
if (!dragState.activeTask || !dragState.activeGroupId) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TaskRow
|
||||||
|
task={dragState.activeTask}
|
||||||
|
projectId={projectId}
|
||||||
|
groupId={dragState.activeGroupId}
|
||||||
|
currentGrouping={(currentGrouping as 'status' | 'priority' | 'phase') || 'status'}
|
||||||
|
isSelected={false}
|
||||||
|
isDragOverlay
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}, [dragState.activeTask, dragState.activeGroupId, projectId, currentGrouping]);
|
||||||
|
|
||||||
|
// Cleanup effect
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (dragOverTimeoutRef.current) {
|
||||||
|
clearTimeout(dragOverTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
<Empty
|
<Empty description={`Error loading tasks: ${error}`} image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
description={`Error loading tasks: ${error}`}
|
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
|
||||||
/>
|
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`task-list-board ${className}`}>
|
<div className={`task-list-board ${className}`} ref={containerRef}>
|
||||||
{/* Task Filters */}
|
<DndContext
|
||||||
<Card
|
sensors={sensors}
|
||||||
size="small"
|
collisionDetection={closestCorners}
|
||||||
className="mb-4"
|
onDragStart={handleDragStart}
|
||||||
styles={{ body: { padding: '12px 16px' } }}
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<React.Suspense fallback={<div>Loading filters...</div>}>
|
{/* Task Filters */}
|
||||||
<TaskListFilters position="list" />
|
<div className="mb-4">
|
||||||
</React.Suspense>
|
<React.Suspense fallback={<div>Loading filters...</div>}>
|
||||||
</Card>
|
<ImprovedTaskFilters position="list" />
|
||||||
|
</React.Suspense>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Bulk Action Bar */}
|
{/* Virtualized Task Groups Container */}
|
||||||
{hasSelection && (
|
<div className="task-groups-container">
|
||||||
<BulkActionBar
|
{loading ? (
|
||||||
selectedTaskIds={selectedTaskIds}
|
<Card>
|
||||||
totalSelected={selectedTaskIds.length}
|
<div className="flex justify-center items-center py-8">
|
||||||
currentGrouping={groupBy}
|
<Spin size="large" />
|
||||||
projectId={projectId}
|
</div>
|
||||||
onClearSelection={() => setSelectedTaskIds([])}
|
</Card>
|
||||||
/>
|
) : taskGroups.length === 0 ? (
|
||||||
)}
|
<Card>
|
||||||
|
<Empty description="No tasks found" image={Empty.PRESENTED_IMAGE_SIMPLE} />
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="virtualized-task-groups">
|
||||||
|
{taskGroups.map((group, index) => {
|
||||||
|
// Calculate dynamic height for each group
|
||||||
|
const groupTasks = group.taskIds.length;
|
||||||
|
const baseHeight = 120; // Header + column headers + add task row
|
||||||
|
const taskRowsHeight = groupTasks * 40; // 40px per task row
|
||||||
|
const minGroupHeight = 300; // Minimum height for better visual appearance
|
||||||
|
const maxGroupHeight = 600; // Increased maximum height per group
|
||||||
|
const calculatedHeight = baseHeight + taskRowsHeight;
|
||||||
|
const groupHeight = Math.max(
|
||||||
|
minGroupHeight,
|
||||||
|
Math.min(calculatedHeight, maxGroupHeight)
|
||||||
|
);
|
||||||
|
|
||||||
{/* Task Groups Container */}
|
return (
|
||||||
<div className="task-groups-container">
|
<VirtualizedTaskList
|
||||||
{loadingGroups ? (
|
key={group.id}
|
||||||
<Card>
|
group={group}
|
||||||
<div className="flex justify-center items-center py-8">
|
projectId={projectId}
|
||||||
<Spin size="large" />
|
currentGrouping={
|
||||||
</div>
|
(currentGrouping as 'status' | 'priority' | 'phase') || 'status'
|
||||||
</Card>
|
}
|
||||||
) : taskGroups.length === 0 ? (
|
selectedTaskIds={selectedTaskIds}
|
||||||
<Card>
|
onSelectTask={handleSelectTask}
|
||||||
<Empty
|
onToggleSubtasks={handleToggleSubtasks}
|
||||||
description="No tasks found"
|
height={groupHeight}
|
||||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
width={1200}
|
||||||
/>
|
/>
|
||||||
</Card>
|
);
|
||||||
) : (
|
})}
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCorners}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<div className="task-groups">
|
|
||||||
{taskGroups.map((group) => (
|
|
||||||
<TaskGroup
|
|
||||||
key={group.id}
|
|
||||||
group={group}
|
|
||||||
projectId={projectId}
|
|
||||||
currentGrouping={groupBy}
|
|
||||||
selectedTaskIds={selectedTaskIds}
|
|
||||||
onSelectTask={handleSelectTask}
|
|
||||||
onToggleSubtasks={handleToggleSubtasks}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<DragOverlay>
|
<DragOverlay
|
||||||
{dragState.activeTask ? (
|
adjustScale={false}
|
||||||
<TaskRow
|
dropAnimation={null}
|
||||||
task={dragState.activeTask}
|
style={{
|
||||||
projectId={projectId}
|
cursor: 'grabbing',
|
||||||
groupId={dragState.activeGroupId!}
|
}}
|
||||||
currentGrouping={groupBy}
|
>
|
||||||
isSelected={false}
|
{dragOverlayContent}
|
||||||
isDragOverlay
|
</DragOverlay>
|
||||||
/>
|
</DndContext>
|
||||||
) : null}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>{`
|
<style>{`
|
||||||
.task-groups-container {
|
.task-groups-container {
|
||||||
@@ -306,13 +446,189 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
overflow-x: visible;
|
overflow-x: visible;
|
||||||
padding: 8px 8px 8px 0;
|
padding: 8px 8px 8px 0;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
transition: background-color 0.3s ease;
|
position: relative;
|
||||||
|
/* GPU acceleration for smooth scrolling */
|
||||||
|
transform: translateZ(0);
|
||||||
|
will-change: scroll-position;
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtualized-task-groups {
|
||||||
|
min-width: fit-content;
|
||||||
|
position: relative;
|
||||||
|
/* GPU acceleration for drag operations */
|
||||||
|
transform: translateZ(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.virtualized-task-group {
|
||||||
|
border: 1px solid var(--task-border-primary, #e8e8e8);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: var(--task-bg-primary, white);
|
||||||
|
box-shadow: 0 1px 3px var(--task-shadow, rgba(0, 0, 0, 0.1));
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-groups {
|
.virtualized-task-group:last-child {
|
||||||
min-width: fit-content;
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Task group header styles */
|
||||||
|
.task-group-header {
|
||||||
|
background: var(--task-bg-primary, white);
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-group-header-row {
|
||||||
|
display: inline-flex;
|
||||||
|
height: auto;
|
||||||
|
max-height: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-group-header-content {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-group-header-text {
|
||||||
|
color: white !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Column headers styles */
|
||||||
|
.task-group-column-headers {
|
||||||
|
background: var(--task-bg-secondary, #f5f5f5);
|
||||||
|
border-bottom: 1px solid var(--task-border-tertiary, #d9d9d9);
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-group-column-headers-row {
|
||||||
|
display: flex;
|
||||||
|
height: 40px;
|
||||||
|
max-height: 40px;
|
||||||
|
overflow: visible;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
min-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-header-cell {
|
||||||
|
background: var(--task-bg-secondary, #f5f5f5);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--task-text-secondary, #595959);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid var(--task-border-tertiary, #d9d9d9);
|
||||||
|
height: 32px;
|
||||||
|
max-height: 32px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-header-text {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--task-text-secondary, #595959);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Add task row styles */
|
||||||
|
.task-group-add-task {
|
||||||
|
background: var(--task-bg-primary, white);
|
||||||
|
border-top: 1px solid var(--task-border-secondary, #f0f0f0);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
padding: 0 12px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-group-add-task:hover {
|
||||||
|
background: var(--task-hover-bg, #fafafa);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-fixed-columns {
|
||||||
|
display: flex;
|
||||||
|
background: var(--task-bg-secondary, #f5f5f5);
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 11;
|
||||||
|
border-right: 2px solid var(--task-border-primary, #e8e8e8);
|
||||||
|
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-scrollable-columns {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-right: 1px solid var(--task-border-secondary, #f0f0f0);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
height: 40px;
|
||||||
|
max-height: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--task-text-primary, #262626);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Optimized drag overlay styles */
|
||||||
|
[data-dnd-overlay] {
|
||||||
|
/* GPU acceleration for smooth dragging */
|
||||||
|
transform: translateZ(0);
|
||||||
|
will-change: transform;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix drag overlay positioning */
|
||||||
|
[data-rbd-drag-handle-dragging-id] {
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* DndKit drag overlay specific styles */
|
||||||
|
.dndkit-drag-overlay {
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateZ(0);
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure drag overlay follows cursor properly */
|
||||||
|
[data-dnd-context] {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Fix for scrollable containers affecting drag overlay */
|
||||||
|
.task-groups-container [data-dnd-overlay] {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
transform: translateZ(0);
|
||||||
|
z-index: 9999;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode support */
|
/* Dark mode support */
|
||||||
@@ -352,9 +668,32 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
--task-drag-over-bg: #1a2332;
|
--task-drag-over-bg: #1a2332;
|
||||||
--task-drag-over-border: #40a9ff;
|
--task-drag-over-border: #40a9ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Performance optimizations */
|
||||||
|
.virtualized-task-group {
|
||||||
|
contain: layout style paint;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row {
|
||||||
|
contain: layout style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Reduce layout thrashing */
|
||||||
|
.task-table-cell {
|
||||||
|
contain: layout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* React Window specific optimizations */
|
||||||
|
.react-window-list {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-window-list-item {
|
||||||
|
contain: layout style;
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default TaskListBoard;
|
export default TaskListBoard;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,163 @@
|
|||||||
|
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: '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">Status</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={5} // Render 5 extra items for smooth scrolling
|
||||||
|
>
|
||||||
|
{Row}
|
||||||
|
</List>
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default VirtualizedTaskGroup;
|
||||||
@@ -0,0 +1,416 @@
|
|||||||
|
import React, { useMemo, useCallback, useEffect, useRef } 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';
|
||||||
|
import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-rows/add-task-list-row';
|
||||||
|
|
||||||
|
interface VirtualizedTaskListProps {
|
||||||
|
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 VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = 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 HEADER_HEIGHT = 40;
|
||||||
|
const COLUMN_HEADER_HEIGHT = 40;
|
||||||
|
|
||||||
|
// Calculate the actual height needed for the virtualized list
|
||||||
|
const actualContentHeight = HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + (groupTasks.length * TASK_ROW_HEIGHT);
|
||||||
|
const listHeight = Math.min(height - 40, actualContentHeight);
|
||||||
|
|
||||||
|
// Calculate item count - only include actual content
|
||||||
|
const getItemCount = () => {
|
||||||
|
return groupTasks.length + 2; // +2 for header and column headers only
|
||||||
|
};
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('VirtualizedTaskList:', {
|
||||||
|
groupId: group.id,
|
||||||
|
groupTasks: groupTasks.length,
|
||||||
|
height,
|
||||||
|
listHeight,
|
||||||
|
itemCount: getItemCount(),
|
||||||
|
isVirtualized: groupTasks.length > 10, // Show if virtualization should be active
|
||||||
|
minHeight: 300,
|
||||||
|
maxHeight: 600
|
||||||
|
});
|
||||||
|
}, [group.id, groupTasks.length, height, listHeight]);
|
||||||
|
|
||||||
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const headerScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Synchronize header scroll with body scroll
|
||||||
|
useEffect(() => {
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (headerScrollRef.current && scrollContainerRef.current) {
|
||||||
|
headerScrollRef.current.scrollLeft = scrollContainerRef.current.scrollLeft;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const scrollDiv = scrollContainerRef.current;
|
||||||
|
if (scrollDiv) {
|
||||||
|
scrollDiv.addEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (scrollDiv) {
|
||||||
|
scrollDiv.removeEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Define columns array for alignment
|
||||||
|
const columns = [
|
||||||
|
{ key: 'drag', label: '', width: 40, fixed: true },
|
||||||
|
{ key: 'select', label: '', width: 40, fixed: true },
|
||||||
|
{ key: 'key', label: 'KEY', width: 80, fixed: true },
|
||||||
|
{ key: 'task', label: 'TASK', width: 475, fixed: true },
|
||||||
|
{ key: 'progress', label: 'PROGRESS', width: 90 },
|
||||||
|
{ key: 'members', label: 'MEMBERS', width: 150 },
|
||||||
|
{ key: 'labels', label: 'LABELS', width: 200 },
|
||||||
|
{ key: 'status', label: 'STATUS', width: 100 },
|
||||||
|
{ key: 'priority', label: 'PRIORITY', width: 100 },
|
||||||
|
{ key: 'timeTracking', label: 'TIME TRACKING', width: 120 },
|
||||||
|
];
|
||||||
|
const fixedColumns = columns.filter(col => col.fixed);
|
||||||
|
const scrollableColumns = columns.filter(col => !col.fixed);
|
||||||
|
const fixedWidth = fixedColumns.reduce((sum, col) => sum + col.width, 0);
|
||||||
|
const scrollableWidth = scrollableColumns.reduce((sum, col) => sum + col.width, 0);
|
||||||
|
const totalTableWidth = fixedWidth + scrollableWidth;
|
||||||
|
|
||||||
|
// Row renderer for virtualization (remove header/column header rows)
|
||||||
|
const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => {
|
||||||
|
const task = groupTasks[index];
|
||||||
|
if (!task) return null;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="task-row-container"
|
||||||
|
style={{
|
||||||
|
...style,
|
||||||
|
'--group-color': group.color || '#f0f0f0'
|
||||||
|
} as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<TaskRow
|
||||||
|
task={task}
|
||||||
|
projectId={projectId}
|
||||||
|
groupId={group.id}
|
||||||
|
currentGrouping={currentGrouping}
|
||||||
|
isSelected={selectedTaskIds.includes(task.id)}
|
||||||
|
index={index}
|
||||||
|
onSelect={onSelectTask}
|
||||||
|
onToggleSubtasks={onToggleSubtasks}
|
||||||
|
fixedColumns={fixedColumns}
|
||||||
|
scrollableColumns={scrollableColumns}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="virtualized-task-list" style={{ height: height }}>
|
||||||
|
{/* Group Header */}
|
||||||
|
<div className="task-group-header">
|
||||||
|
<div className="task-group-header-row">
|
||||||
|
<div
|
||||||
|
className="task-group-header-content"
|
||||||
|
style={{
|
||||||
|
backgroundColor: group.color || '#f0f0f0',
|
||||||
|
borderLeft: `4px solid ${group.color || '#f0f0f0'}`
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="task-group-header-text">
|
||||||
|
{group.title} ({groupTasks.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Column Headers (sync scroll) */}
|
||||||
|
<div
|
||||||
|
className="task-group-column-headers-scroll"
|
||||||
|
ref={headerScrollRef}
|
||||||
|
style={{ overflowX: 'auto', overflowY: 'hidden' }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="task-group-column-headers"
|
||||||
|
style={{ borderLeft: `4px solid ${group.color || '#f0f0f0'}`, minWidth: totalTableWidth, display: 'flex', position: 'relative' }}
|
||||||
|
>
|
||||||
|
<div className="fixed-columns-header" style={{ display: 'flex', position: 'sticky', left: 0, zIndex: 2, background: 'inherit', width: fixedWidth }}>
|
||||||
|
{fixedColumns.map(col => (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
className="task-table-cell task-table-header-cell fixed-column"
|
||||||
|
style={{ width: col.width }}
|
||||||
|
>
|
||||||
|
<span className="column-header-text">{col.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="scrollable-columns-header" style={{ display: 'flex', minWidth: scrollableWidth }}>
|
||||||
|
{scrollableColumns.map(col => (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
className="task-table-cell task-table-header-cell"
|
||||||
|
style={{ width: col.width }}
|
||||||
|
>
|
||||||
|
<span className="column-header-text">{col.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Scrollable List */}
|
||||||
|
<div
|
||||||
|
className="task-list-scroll-container"
|
||||||
|
ref={scrollContainerRef}
|
||||||
|
style={{ overflowX: 'auto', overflowY: 'hidden', width: '100%', minWidth: totalTableWidth }}
|
||||||
|
>
|
||||||
|
<SortableContext items={group.taskIds} strategy={verticalListSortingStrategy}>
|
||||||
|
<List
|
||||||
|
height={listHeight}
|
||||||
|
width={width}
|
||||||
|
itemCount={groupTasks.length}
|
||||||
|
itemSize={TASK_ROW_HEIGHT}
|
||||||
|
overscanCount={15}
|
||||||
|
className="react-window-list"
|
||||||
|
style={{ minWidth: totalTableWidth }}
|
||||||
|
>
|
||||||
|
{Row}
|
||||||
|
</List>
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
{/* Add Task Row - Always show at the bottom */}
|
||||||
|
<div
|
||||||
|
className="task-group-add-task"
|
||||||
|
style={{ borderLeft: `4px solid ${group.color || '#f0f0f0'}` }}
|
||||||
|
>
|
||||||
|
<AddTaskListRow groupId={group.id} />
|
||||||
|
</div>
|
||||||
|
<style>{`
|
||||||
|
.virtualized-task-list {
|
||||||
|
border: 1px solid var(--task-border-primary, #e8e8e8);
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
background: var(--task-bg-primary, white);
|
||||||
|
box-shadow: 0 1px 3px var(--task-shadow, rgba(0, 0, 0, 0.1));
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.task-group-header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.task-group-column-headers-scroll {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.task-group-column-headers {
|
||||||
|
background: var(--task-bg-secondary, #f5f5f5);
|
||||||
|
border-bottom: 1px solid var(--task-border-tertiary, #d9d9d9);
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
min-width: 1200px;
|
||||||
|
}
|
||||||
|
.task-list-scroll-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.react-window-list {
|
||||||
|
outline: none;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.react-window-list-item {
|
||||||
|
contain: layout style;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
/* Task row container styles */
|
||||||
|
.task-row-container {
|
||||||
|
position: relative;
|
||||||
|
background: var(--task-bg-primary, white);
|
||||||
|
}
|
||||||
|
.task-row-container::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 4px;
|
||||||
|
background-color: var(--group-color, #f0f0f0);
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
/* Ensure no gaps between list items */
|
||||||
|
.react-window-list > div {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
/* Task group header styles */
|
||||||
|
.task-group-header-row {
|
||||||
|
display: inline-flex;
|
||||||
|
height: auto;
|
||||||
|
max-height: none;
|
||||||
|
overflow: hidden;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.task-group-header-content {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
color: white;
|
||||||
|
font-weight: 500;
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
margin: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
.task-group-header-text {
|
||||||
|
color: white !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
}
|
||||||
|
/* Column headers styles */
|
||||||
|
.task-table-header-cell {
|
||||||
|
background: var(--task-bg-secondary, #f5f5f5);
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--task-text-secondary, #595959);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
border-bottom: 1px solid var(--task-border-tertiary, #d9d9d9);
|
||||||
|
height: 32px;
|
||||||
|
max-height: 32px;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.column-header-text {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--task-text-secondary, #595959);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
transition: color 0.3s ease;
|
||||||
|
}
|
||||||
|
/* Add task row styles */
|
||||||
|
.task-group-add-task {
|
||||||
|
background: var(--task-bg-primary, white);
|
||||||
|
border-top: 1px solid var(--task-border-secondary, #f0f0f0);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
padding: 0 12px;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.task-group-add-task:hover {
|
||||||
|
background: var(--task-hover-bg, #fafafa);
|
||||||
|
}
|
||||||
|
.task-table-fixed-columns {
|
||||||
|
display: flex;
|
||||||
|
background: var(--task-bg-secondary, #f5f5f5);
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 11;
|
||||||
|
border-right: 2px solid var(--task-border-primary, #e8e8e8);
|
||||||
|
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.task-table-scrollable-columns {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.task-table-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-right: 1px solid var(--task-border-secondary, #f0f0f0);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
height: 40px;
|
||||||
|
max-height: 40px;
|
||||||
|
min-height: 40px;
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--task-text-primary, #262626);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.task-table-cell:last-child {
|
||||||
|
border-right: none;
|
||||||
|
}
|
||||||
|
/* Performance optimizations */
|
||||||
|
.virtualized-task-list {
|
||||||
|
contain: layout style paint;
|
||||||
|
}
|
||||||
|
/* Dark mode support */
|
||||||
|
:root {
|
||||||
|
--task-bg-primary: #ffffff;
|
||||||
|
--task-bg-secondary: #f5f5f5;
|
||||||
|
--task-bg-tertiary: #f8f9fa;
|
||||||
|
--task-border-primary: #e8e8e8;
|
||||||
|
--task-border-secondary: #f0f0f0;
|
||||||
|
--task-border-tertiary: #d9d9d9;
|
||||||
|
--task-text-primary: #262626;
|
||||||
|
--task-text-secondary: #595959;
|
||||||
|
--task-text-tertiary: #8c8c8c;
|
||||||
|
--task-shadow: rgba(0, 0, 0, 0.1);
|
||||||
|
--task-hover-bg: #fafafa;
|
||||||
|
--task-selected-bg: #e6f7ff;
|
||||||
|
--task-selected-border: #1890ff;
|
||||||
|
--task-drag-over-bg: #f0f8ff;
|
||||||
|
--task-drag-over-border: #40a9ff;
|
||||||
|
}
|
||||||
|
.dark .virtualized-task-list,
|
||||||
|
[data-theme="dark"] .virtualized-task-list {
|
||||||
|
--task-bg-primary: #1f1f1f;
|
||||||
|
--task-bg-secondary: #141414;
|
||||||
|
--task-bg-tertiary: #262626;
|
||||||
|
--task-border-primary: #303030;
|
||||||
|
--task-border-secondary: #404040;
|
||||||
|
--task-border-tertiary: #505050;
|
||||||
|
--task-text-primary: #ffffff;
|
||||||
|
--task-text-secondary: #d9d9d9;
|
||||||
|
--task-text-tertiary: #8c8c8c;
|
||||||
|
--task-shadow: rgba(0, 0, 0, 0.3);
|
||||||
|
--task-hover-bg: #2a2a2a;
|
||||||
|
--task-selected-bg: #1a2332;
|
||||||
|
--task-selected-border: #1890ff;
|
||||||
|
--task-drag-over-bg: #1a2332;
|
||||||
|
--task-drag-over-border: #40a9ff;
|
||||||
|
}
|
||||||
|
`}</style>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export default VirtualizedTaskList;
|
||||||
@@ -76,6 +76,10 @@ interface BoardState {
|
|||||||
priorities: string[];
|
priorities: string[];
|
||||||
members: string[];
|
members: string[];
|
||||||
editableSectionId: string | null;
|
editableSectionId: string | null;
|
||||||
|
|
||||||
|
allTasks: IProjectTask[];
|
||||||
|
grouping: string;
|
||||||
|
totalTasks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: BoardState = {
|
const initialState: BoardState = {
|
||||||
@@ -98,6 +102,9 @@ const initialState: BoardState = {
|
|||||||
priorities: [],
|
priorities: [],
|
||||||
members: [],
|
members: [],
|
||||||
editableSectionId: null,
|
editableSectionId: null,
|
||||||
|
allTasks: [],
|
||||||
|
grouping: '',
|
||||||
|
totalTasks: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteTaskFromGroup = (
|
const deleteTaskFromGroup = (
|
||||||
@@ -186,7 +193,7 @@ export const fetchBoardTaskGroups = createAsyncThunk(
|
|||||||
priorities: boardReducer.priorities.join(' '),
|
priorities: boardReducer.priorities.join(' '),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await tasksApiService.getTaskList(config);
|
const response = await tasksApiService.getTaskListV3(config);
|
||||||
return response.body;
|
return response.body;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Fetch Task Groups', error);
|
logger.error('Fetch Task Groups', error);
|
||||||
@@ -803,7 +810,10 @@ const boardSlice = createSlice({
|
|||||||
})
|
})
|
||||||
.addCase(fetchBoardTaskGroups.fulfilled, (state, action) => {
|
.addCase(fetchBoardTaskGroups.fulfilled, (state, action) => {
|
||||||
state.loadingGroups = false;
|
state.loadingGroups = false;
|
||||||
state.taskGroups = action.payload;
|
state.taskGroups = action.payload && action.payload.groups ? action.payload.groups : [];
|
||||||
|
state.allTasks = action.payload && action.payload.allTasks ? action.payload.allTasks : [];
|
||||||
|
state.grouping = action.payload && action.payload.grouping ? action.payload.grouping : '';
|
||||||
|
state.totalTasks = action.payload && action.payload.totalTasks ? action.payload.totalTasks : 0;
|
||||||
})
|
})
|
||||||
.addCase(fetchBoardTaskGroups.rejected, (state, action) => {
|
.addCase(fetchBoardTaskGroups.rejected, (state, action) => {
|
||||||
state.loadingGroups = false;
|
state.loadingGroups = false;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { createSlice, createEntityAdapter, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, createEntityAdapter, PayloadAction, createAsyncThunk } from '@reduxjs/toolkit';
|
||||||
import { Task, TaskManagementState } from '@/types/task-management.types';
|
import { Task, TaskManagementState } from '@/types/task-management.types';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
|
import { tasksApiService, ITaskListConfigV2, ITaskListV3Response } from '@/api/tasks/tasks.api.service';
|
||||||
|
import logger from '@/utils/errorLogger';
|
||||||
|
|
||||||
// Entity adapter for normalized state
|
// Entity adapter for normalized state
|
||||||
const tasksAdapter = createEntityAdapter<Task>({
|
const tasksAdapter = createEntityAdapter<Task>({
|
||||||
selectId: (task) => task.id,
|
|
||||||
sortComparer: (a, b) => a.order - b.order,
|
sortComparer: (a, b) => a.order - b.order,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -13,8 +14,198 @@ const initialState: TaskManagementState = {
|
|||||||
ids: [],
|
ids: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
|
groups: [],
|
||||||
|
grouping: null,
|
||||||
|
selectedPriorities: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Async thunk to fetch tasks from API
|
||||||
|
export const fetchTasks = createAsyncThunk(
|
||||||
|
'taskManagement/fetchTasks',
|
||||||
|
async (projectId: string, { rejectWithValue, getState }) => {
|
||||||
|
try {
|
||||||
|
const state = getState() as RootState;
|
||||||
|
const currentGrouping = state.grouping.currentGrouping;
|
||||||
|
|
||||||
|
const config: ITaskListConfigV2 = {
|
||||||
|
id: projectId,
|
||||||
|
archived: false,
|
||||||
|
group: currentGrouping,
|
||||||
|
field: '',
|
||||||
|
order: '',
|
||||||
|
search: '',
|
||||||
|
statuses: '',
|
||||||
|
members: '',
|
||||||
|
projects: '',
|
||||||
|
isSubtasksInclude: false,
|
||||||
|
labels: '',
|
||||||
|
priorities: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await tasksApiService.getTaskList(config);
|
||||||
|
|
||||||
|
// Helper function to safely convert time values
|
||||||
|
const convertTimeValue = (value: any): number => {
|
||||||
|
if (typeof value === 'number') return value;
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const parsed = parseFloat(value);
|
||||||
|
return isNaN(parsed) ? 0 : parsed;
|
||||||
|
}
|
||||||
|
if (typeof value === 'object' && value !== null) {
|
||||||
|
// Handle time objects like {hours: 2, minutes: 30}
|
||||||
|
if ('hours' in value || 'minutes' in value) {
|
||||||
|
const hours = Number(value.hours || 0);
|
||||||
|
const minutes = Number(value.minutes || 0);
|
||||||
|
return hours + (minutes / 60);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create a mapping from status IDs to group names
|
||||||
|
const statusIdToNameMap: Record<string, string> = {};
|
||||||
|
const priorityIdToNameMap: Record<string, string> = {};
|
||||||
|
|
||||||
|
response.body.forEach((group: any) => {
|
||||||
|
statusIdToNameMap[group.id] = group.name.toLowerCase();
|
||||||
|
});
|
||||||
|
|
||||||
|
// For priority mapping, we need to get priority names from the tasks themselves
|
||||||
|
// Since the API doesn't provide priority names in the group structure
|
||||||
|
response.body.forEach((group: any) => {
|
||||||
|
group.tasks.forEach((task: any) => {
|
||||||
|
// Map priority value to name (this is an assumption based on common patterns)
|
||||||
|
if (task.priority_value !== undefined) {
|
||||||
|
switch (task.priority_value) {
|
||||||
|
case 0: priorityIdToNameMap[task.priority] = 'low'; break;
|
||||||
|
case 1: priorityIdToNameMap[task.priority] = 'medium'; break;
|
||||||
|
case 2: priorityIdToNameMap[task.priority] = 'high'; break;
|
||||||
|
case 3: priorityIdToNameMap[task.priority] = 'critical'; break;
|
||||||
|
default: priorityIdToNameMap[task.priority] = 'medium';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transform the API response to our Task type
|
||||||
|
const tasks: Task[] = response.body.flatMap((group: any) =>
|
||||||
|
group.tasks.map((task: any) => ({
|
||||||
|
id: task.id,
|
||||||
|
task_key: task.task_key || '',
|
||||||
|
title: task.name || '',
|
||||||
|
description: task.description || '',
|
||||||
|
status: statusIdToNameMap[task.status] || 'todo',
|
||||||
|
priority: priorityIdToNameMap[task.priority] || 'medium',
|
||||||
|
phase: task.phase_name || 'Development',
|
||||||
|
progress: typeof task.complete_ratio === 'number' ? task.complete_ratio : 0,
|
||||||
|
assignees: task.assignees?.map((a: any) => a.team_member_id) || [],
|
||||||
|
assignee_names: task.assignee_names || task.names || [],
|
||||||
|
labels: task.labels?.map((l: any) => ({
|
||||||
|
id: l.id || l.label_id,
|
||||||
|
name: l.name,
|
||||||
|
color: l.color_code || '#1890ff',
|
||||||
|
end: l.end,
|
||||||
|
names: l.names
|
||||||
|
})) || [],
|
||||||
|
dueDate: task.end_date,
|
||||||
|
timeTracking: {
|
||||||
|
estimated: convertTimeValue(task.total_time),
|
||||||
|
logged: convertTimeValue(task.time_spent),
|
||||||
|
},
|
||||||
|
customFields: {},
|
||||||
|
createdAt: task.created_at || new Date().toISOString(),
|
||||||
|
updatedAt: task.updated_at || new Date().toISOString(),
|
||||||
|
order: typeof task.sort_order === 'number' ? task.sort_order : 0,
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
|
||||||
|
return tasks;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fetch Tasks', error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
return rejectWithValue('Failed to fetch tasks');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// New V3 fetch that minimizes frontend processing
|
||||||
|
export const fetchTasksV3 = createAsyncThunk(
|
||||||
|
'taskManagement/fetchTasksV3',
|
||||||
|
async (projectId: string, { rejectWithValue, getState }) => {
|
||||||
|
try {
|
||||||
|
const state = getState() as RootState;
|
||||||
|
const currentGrouping = state.grouping.currentGrouping;
|
||||||
|
|
||||||
|
// Get selected labels from taskReducer
|
||||||
|
const selectedLabels = state.taskReducer.labels
|
||||||
|
? state.taskReducer.labels.filter(l => l.selected).map(l => l.id).join(',')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Get selected assignees from taskReducer
|
||||||
|
const selectedAssignees = state.taskReducer.taskAssignees
|
||||||
|
? state.taskReducer.taskAssignees.filter(m => m.selected).map(m => m.id).join(',')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
// Get selected priorities from taskManagement slice
|
||||||
|
const selectedPriorities = state.taskManagement.selectedPriorities
|
||||||
|
? state.taskManagement.selectedPriorities.join(',')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
console.log('fetchTasksV3 - selectedPriorities:', selectedPriorities);
|
||||||
|
|
||||||
|
const config: ITaskListConfigV2 = {
|
||||||
|
id: projectId,
|
||||||
|
archived: false,
|
||||||
|
group: currentGrouping,
|
||||||
|
field: '',
|
||||||
|
order: '',
|
||||||
|
search: '',
|
||||||
|
statuses: '',
|
||||||
|
members: selectedAssignees,
|
||||||
|
projects: '',
|
||||||
|
isSubtasksInclude: false,
|
||||||
|
labels: selectedLabels,
|
||||||
|
priorities: selectedPriorities,
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await tasksApiService.getTaskListV3(config);
|
||||||
|
|
||||||
|
// Minimal processing - tasks are already processed by backend
|
||||||
|
return {
|
||||||
|
tasks: response.body.allTasks,
|
||||||
|
groups: response.body.groups,
|
||||||
|
grouping: response.body.grouping,
|
||||||
|
totalTasks: response.body.totalTasks
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fetch Tasks V3', error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
return rejectWithValue('Failed to fetch tasks');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Refresh task progress separately to avoid slowing down initial load
|
||||||
|
export const refreshTaskProgress = createAsyncThunk(
|
||||||
|
'taskManagement/refreshTaskProgress',
|
||||||
|
async (projectId: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await tasksApiService.refreshTaskProgress(projectId);
|
||||||
|
return response.body;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Refresh Task Progress', error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
return rejectWithValue('Failed to refresh task progress');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const taskManagementSlice = createSlice({
|
const taskManagementSlice = createSlice({
|
||||||
name: 'taskManagement',
|
name: 'taskManagement',
|
||||||
initialState: tasksAdapter.getInitialState(initialState),
|
initialState: tasksAdapter.getInitialState(initialState),
|
||||||
@@ -61,13 +252,19 @@ const taskManagementSlice = createSlice({
|
|||||||
tasksAdapter.removeMany(state, action.payload);
|
tasksAdapter.removeMany(state, action.payload);
|
||||||
},
|
},
|
||||||
|
|
||||||
// Drag and drop operations
|
// Optimized drag and drop operations
|
||||||
reorderTasks: (state, action: PayloadAction<{ taskIds: string[]; newOrder: number[] }>) => {
|
reorderTasks: (state, action: PayloadAction<{ taskIds: string[]; newOrder: number[] }>) => {
|
||||||
const { taskIds, newOrder } = action.payload;
|
const { taskIds, newOrder } = action.payload;
|
||||||
|
|
||||||
|
// Batch update for better performance
|
||||||
const updates = taskIds.map((id, index) => ({
|
const updates = taskIds.map((id, index) => ({
|
||||||
id,
|
id,
|
||||||
changes: { order: newOrder[index] },
|
changes: {
|
||||||
|
order: newOrder[index],
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
tasksAdapter.updateMany(state, updates);
|
tasksAdapter.updateMany(state, updates);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -89,6 +286,34 @@ const taskManagementSlice = createSlice({
|
|||||||
tasksAdapter.updateOne(state, { id: taskId, changes });
|
tasksAdapter.updateOne(state, { id: taskId, changes });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Optimistic update for drag operations - reduces perceived lag
|
||||||
|
optimisticTaskMove: (state, action: PayloadAction<{ taskId: string; newGroupId: string; newIndex: number }>) => {
|
||||||
|
const { taskId, newGroupId, newIndex } = action.payload;
|
||||||
|
const task = state.entities[taskId];
|
||||||
|
|
||||||
|
if (task) {
|
||||||
|
// Parse group ID to determine new values
|
||||||
|
const [groupType, ...groupValueParts] = newGroupId.split('-');
|
||||||
|
const groupValue = groupValueParts.join('-');
|
||||||
|
|
||||||
|
const changes: Partial<Task> = {
|
||||||
|
order: newIndex,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update group-specific field
|
||||||
|
if (groupType === 'status') {
|
||||||
|
changes.status = groupValue as Task['status'];
|
||||||
|
} else if (groupType === 'priority') {
|
||||||
|
changes.priority = groupValue as Task['priority'];
|
||||||
|
} else if (groupType === 'phase') {
|
||||||
|
changes.phase = groupValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
tasksAdapter.updateOne(state, { id: taskId, changes });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// Loading states
|
// Loading states
|
||||||
setLoading: (state, action: PayloadAction<boolean>) => {
|
setLoading: (state, action: PayloadAction<boolean>) => {
|
||||||
state.loading = action.payload;
|
state.loading = action.payload;
|
||||||
@@ -98,6 +323,54 @@ const taskManagementSlice = createSlice({
|
|||||||
state.error = action.payload;
|
state.error = action.payload;
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Filter actions
|
||||||
|
setSelectedPriorities: (state, action: PayloadAction<string[]>) => {
|
||||||
|
state.selectedPriorities = action.payload;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extraReducers: (builder) => {
|
||||||
|
builder
|
||||||
|
.addCase(fetchTasks.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchTasks.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = null;
|
||||||
|
tasksAdapter.setAll(state, action.payload);
|
||||||
|
})
|
||||||
|
.addCase(fetchTasks.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string || 'Failed to fetch tasks';
|
||||||
|
})
|
||||||
|
.addCase(fetchTasksV3.pending, (state) => {
|
||||||
|
state.loading = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchTasksV3.fulfilled, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = null;
|
||||||
|
// Tasks are already processed by backend, minimal setup needed
|
||||||
|
tasksAdapter.setAll(state, action.payload.tasks);
|
||||||
|
state.groups = action.payload.groups;
|
||||||
|
state.grouping = action.payload.grouping;
|
||||||
|
})
|
||||||
|
.addCase(fetchTasksV3.rejected, (state, action) => {
|
||||||
|
state.loading = false;
|
||||||
|
state.error = action.payload as string || 'Failed to fetch tasks';
|
||||||
|
})
|
||||||
|
.addCase(refreshTaskProgress.pending, (state) => {
|
||||||
|
// Don't set loading to true for refresh to avoid UI blocking
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(refreshTaskProgress.fulfilled, (state) => {
|
||||||
|
state.error = null;
|
||||||
|
// Progress refresh completed successfully
|
||||||
|
})
|
||||||
|
.addCase(refreshTaskProgress.rejected, (state, action) => {
|
||||||
|
state.error = action.payload as string || 'Failed to refresh task progress';
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -110,16 +383,20 @@ export const {
|
|||||||
bulkDeleteTasks,
|
bulkDeleteTasks,
|
||||||
reorderTasks,
|
reorderTasks,
|
||||||
moveTaskToGroup,
|
moveTaskToGroup,
|
||||||
|
optimisticTaskMove,
|
||||||
setLoading,
|
setLoading,
|
||||||
setError,
|
setError,
|
||||||
|
setSelectedPriorities,
|
||||||
} = taskManagementSlice.actions;
|
} = taskManagementSlice.actions;
|
||||||
|
|
||||||
|
export default taskManagementSlice.reducer;
|
||||||
|
|
||||||
// Selectors
|
// Selectors
|
||||||
export const taskManagementSelectors = tasksAdapter.getSelectors<RootState>(
|
export const taskManagementSelectors = tasksAdapter.getSelectors<RootState>(
|
||||||
(state) => state.taskManagement
|
(state) => state.taskManagement
|
||||||
);
|
);
|
||||||
|
|
||||||
// Additional selectors
|
// Enhanced selectors for better performance
|
||||||
export const selectTasksByStatus = (state: RootState, status: string) =>
|
export const selectTasksByStatus = (state: RootState, status: string) =>
|
||||||
taskManagementSelectors.selectAll(state).filter(task => task.status === status);
|
taskManagementSelectors.selectAll(state).filter(task => task.status === status);
|
||||||
|
|
||||||
@@ -132,4 +409,6 @@ export const selectTasksByPhase = (state: RootState, phase: string) =>
|
|||||||
export const selectTasksLoading = (state: RootState) => state.taskManagement.loading;
|
export const selectTasksLoading = (state: RootState) => state.taskManagement.loading;
|
||||||
export const selectTasksError = (state: RootState) => state.taskManagement.error;
|
export const selectTasksError = (state: RootState) => state.taskManagement.error;
|
||||||
|
|
||||||
export default taskManagementSlice.reducer;
|
// V3 API selectors - no processing needed, data is pre-processed by backend
|
||||||
|
export const selectTaskGroupsV3 = (state: RootState) => state.taskManagement.groups;
|
||||||
|
export const selectCurrentGroupingV3 = (state: RootState) => state.taskManagement.grouping;
|
||||||
@@ -80,6 +80,9 @@ interface ITaskState {
|
|||||||
convertToSubtaskDrawerOpen: boolean;
|
convertToSubtaskDrawerOpen: boolean;
|
||||||
customColumns: ITaskListColumn[];
|
customColumns: ITaskListColumn[];
|
||||||
customColumnValues: Record<string, Record<string, any>>;
|
customColumnValues: Record<string, Record<string, any>>;
|
||||||
|
allTasks: IProjectTask[];
|
||||||
|
grouping: string;
|
||||||
|
totalTasks: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: ITaskState = {
|
const initialState: ITaskState = {
|
||||||
@@ -105,6 +108,9 @@ const initialState: ITaskState = {
|
|||||||
convertToSubtaskDrawerOpen: false,
|
convertToSubtaskDrawerOpen: false,
|
||||||
customColumns: [],
|
customColumns: [],
|
||||||
customColumnValues: {},
|
customColumnValues: {},
|
||||||
|
allTasks: [],
|
||||||
|
grouping: '',
|
||||||
|
totalTasks: 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const COLUMN_KEYS = {
|
export const COLUMN_KEYS = {
|
||||||
@@ -165,7 +171,7 @@ export const fetchTaskGroups = createAsyncThunk(
|
|||||||
priorities: taskReducer.priorities.join(' '),
|
priorities: taskReducer.priorities.join(' '),
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await tasksApiService.getTaskList(config);
|
const response = await tasksApiService.getTaskListV3(config);
|
||||||
return response.body;
|
return response.body;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Fetch Task Groups', error);
|
logger.error('Fetch Task Groups', error);
|
||||||
@@ -234,9 +240,9 @@ export const fetchSubTasks = createAsyncThunk(
|
|||||||
parent_task: taskId,
|
parent_task: taskId,
|
||||||
};
|
};
|
||||||
try {
|
try {
|
||||||
const response = await tasksApiService.getTaskList(config);
|
const response = await tasksApiService.getTaskListV3(config);
|
||||||
// Only expand if we actually fetched subtasks
|
// Only expand if we actually fetched subtasks
|
||||||
if (response.body.length > 0) {
|
if (response.body && response.body.groups && response.body.groups.length > 0) {
|
||||||
dispatch(toggleTaskRowExpansion(taskId));
|
dispatch(toggleTaskRowExpansion(taskId));
|
||||||
}
|
}
|
||||||
return response.body;
|
return response.body;
|
||||||
@@ -1026,7 +1032,10 @@ const taskSlice = createSlice({
|
|||||||
})
|
})
|
||||||
.addCase(fetchTaskGroups.fulfilled, (state, action) => {
|
.addCase(fetchTaskGroups.fulfilled, (state, action) => {
|
||||||
state.loadingGroups = false;
|
state.loadingGroups = false;
|
||||||
state.taskGroups = action.payload;
|
state.taskGroups = action.payload && action.payload.groups ? action.payload.groups : [];
|
||||||
|
state.allTasks = action.payload && action.payload.allTasks ? action.payload.allTasks : [];
|
||||||
|
state.grouping = action.payload && action.payload.grouping ? action.payload.grouping : '';
|
||||||
|
state.totalTasks = action.payload && action.payload.totalTasks ? action.payload.totalTasks : 0;
|
||||||
})
|
})
|
||||||
.addCase(fetchTaskGroups.rejected, (state, action) => {
|
.addCase(fetchTaskGroups.rejected, (state, action) => {
|
||||||
state.loadingGroups = false;
|
state.loadingGroups = false;
|
||||||
@@ -1035,14 +1044,16 @@ const taskSlice = createSlice({
|
|||||||
.addCase(fetchSubTasks.pending, state => {
|
.addCase(fetchSubTasks.pending, state => {
|
||||||
state.error = null;
|
state.error = null;
|
||||||
})
|
})
|
||||||
.addCase(fetchSubTasks.fulfilled, (state, action: PayloadAction<IProjectTask[]>) => {
|
.addCase(fetchSubTasks.fulfilled, (state, action) => {
|
||||||
if (action.payload.length > 0) {
|
if (action.payload && action.payload.groups && action.payload.groups.length > 0) {
|
||||||
const taskId = action.payload[0].parent_task_id;
|
// Assuming subtasks are in the first group for this context
|
||||||
|
const subtasks = action.payload.groups[0].tasks;
|
||||||
|
const taskId = subtasks.length > 0 ? subtasks[0].parent_task_id : null;
|
||||||
if (taskId) {
|
if (taskId) {
|
||||||
for (const group of state.taskGroups) {
|
for (const group of state.taskGroups) {
|
||||||
const task = group.tasks.find(t => t.id === taskId);
|
const task = group.tasks.find(t => t.id === taskId);
|
||||||
if (task) {
|
if (task) {
|
||||||
task.sub_tasks = action.payload;
|
task.sub_tasks = subtasks;
|
||||||
task.show_sub_tasks = true;
|
task.show_sub_tasks = true;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
|
@import url("https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap");
|
||||||
@import url("./styles/customOverrides.css");
|
@import url("./styles/customOverrides.css");
|
||||||
|
@import url("./styles/task-management.css");
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
|
|||||||
@@ -1,54 +1,49 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Flex } from 'antd';
|
import { Flex } from 'antd';
|
||||||
import TaskListFilters from './taskListFilters/TaskListFilters';
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice';
|
||||||
import { fetchTaskGroups } from '@/features/tasks/tasks.slice';
|
import { fetchTasksV3 } from '@/features/task-management/task-management.slice';
|
||||||
import { ITaskListConfigV2 } from '@/types/tasks/taskList.types';
|
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||||
import TanStackTable from '../task-list/task-list-custom';
|
import TaskListBoard from '@/components/task-management/task-list-board';
|
||||||
import TaskListCustom from '../task-list/task-list-custom';
|
|
||||||
import TaskListTableWrapper from '../task-list/task-list-table-wrapper/task-list-table-wrapper';
|
|
||||||
|
|
||||||
const ProjectViewTaskList = () => {
|
const ProjectViewTaskList = () => {
|
||||||
// sample data from task reducer
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { taskGroups, loadingGroups } = useAppSelector(state => state.taskReducer);
|
|
||||||
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
|
|
||||||
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
const projectId = useAppSelector(state => state.projectReducer.projectId);
|
||||||
|
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
const config: ITaskListConfigV2 = {
|
// Use the optimized V3 API for faster loading
|
||||||
id: projectId,
|
dispatch(fetchTasksV3(projectId));
|
||||||
field: 'id',
|
|
||||||
order: 'desc',
|
|
||||||
search: '',
|
|
||||||
statuses: '',
|
|
||||||
members: '',
|
|
||||||
projects: '',
|
|
||||||
isSubtasksInclude: true,
|
|
||||||
};
|
|
||||||
dispatch(fetchTaskGroups(config));
|
|
||||||
}
|
}
|
||||||
if (!statusCategories.length) {
|
if (!statusCategories.length) {
|
||||||
dispatch(fetchStatusesCategories());
|
dispatch(fetchStatusesCategories());
|
||||||
}
|
}
|
||||||
}, [dispatch, projectId]);
|
}, [dispatch, projectId]);
|
||||||
|
|
||||||
|
// Cleanup effect - reset values when component is destroyed
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// Clear any selected tasks when component unmounts
|
||||||
|
dispatch(deselectAll());
|
||||||
|
};
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
if (!projectId) {
|
||||||
|
return (
|
||||||
|
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||||
|
<div>No project selected</div>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
<Flex vertical gap={16} style={{ overflowX: 'hidden' }}>
|
||||||
<TaskListFilters position="list" />
|
<TaskListBoard
|
||||||
|
projectId={projectId}
|
||||||
{taskGroups.map(group => (
|
className="task-list-board"
|
||||||
<TaskListTableWrapper
|
/>
|
||||||
key={group.id}
|
|
||||||
taskList={group}
|
|
||||||
name={group.name || ''}
|
|
||||||
color={group.color_code || ''}
|
|
||||||
groupId={group.id || ''}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</Flex>
|
</Flex>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ import { SocketEvents } from '@/shared/socket-events';
|
|||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import TaskListTable from '../task-list-table/task-list-table';
|
import TaskListTable from '../task-list-table/task-list-table';
|
||||||
import Collapsible from '@/components/collapsible/collapsible';
|
import Collapsible from '@/components/collapsible/collapsible';
|
||||||
import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar';
|
|
||||||
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
|
|
||||||
@@ -232,7 +232,7 @@ const TaskGroupList = ({ taskGroups, groupBy }: TaskGroupListProps) => {
|
|||||||
</Flex>
|
</Flex>
|
||||||
</ConfigProvider>
|
</ConfigProvider>
|
||||||
|
|
||||||
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
|
|
||||||
{createPortal(
|
{createPortal(
|
||||||
<TaskTemplateDrawer showDrawer={false} selectedTemplateId={''} onClose={() => {}} />,
|
<TaskTemplateDrawer showDrawer={false} selectedTemplateId={''} onClose={() => {}} />,
|
||||||
document.body,
|
document.body,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { ITaskListGroup } from '@/types/tasks/taskList.types';
|
|||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
|
||||||
import TaskListTableWrapper from './task-list-table/task-list-table-wrapper/task-list-table-wrapper';
|
import TaskListTableWrapper from './task-list-table/task-list-table-wrapper/task-list-table-wrapper';
|
||||||
import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar';
|
|
||||||
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||||
|
|
||||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
@@ -69,7 +69,7 @@ const TaskGroupWrapperOptimized = ({ taskGroups, groupBy }: TaskGroupWrapperOpti
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
|
|
||||||
|
|
||||||
{createPortal(
|
{createPortal(
|
||||||
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ import {
|
|||||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||||
|
|
||||||
import TaskListTableWrapper from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper';
|
import TaskListTableWrapper from '@/pages/projects/projectView/taskList/task-list-table/task-list-table-wrapper/task-list-table-wrapper';
|
||||||
import TaskListBulkActionsBar from '@/components/taskListCommon/task-list-bulk-actions-bar/task-list-bulk-actions-bar';
|
|
||||||
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||||
|
|
||||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
@@ -686,7 +686,7 @@ const TaskGroupWrapper = ({ taskGroups, groupBy }: TaskGroupWrapperProps) => {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{createPortal(<TaskListBulkActionsBar />, document.body, 'bulk-action-container')}
|
|
||||||
|
|
||||||
{createPortal(
|
{createPortal(
|
||||||
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
<TaskTemplateDrawer showDrawer={false} selectedTemplateId="" onClose={() => {}} />,
|
||||||
|
|||||||
@@ -213,389 +213,283 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Dark mode support */
|
/* Dark mode support using class-based selectors */
|
||||||
[data-theme="dark"] .task-list-board {
|
.dark .task-list-board {
|
||||||
background-color: #141414;
|
background-color: #141414;
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
.dark .task-group {
|
||||||
.task-list-board {
|
|
||||||
background-color: #141414;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Task Groups */
|
|
||||||
.task-group {
|
|
||||||
background-color: #1f1f1f;
|
|
||||||
border-color: #303030;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-group.drag-over {
|
|
||||||
border-color: #1890ff !important;
|
|
||||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3);
|
|
||||||
background-color: rgba(24, 144, 255, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-group .group-header {
|
|
||||||
background: #262626;
|
|
||||||
border-bottom-color: #303030;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-group .group-header:hover {
|
|
||||||
background: #2f2f2f;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Task Rows */
|
|
||||||
.task-row {
|
|
||||||
background-color: #1f1f1f;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
border-color: #303030;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-row:hover {
|
|
||||||
background-color: #262626 !important;
|
|
||||||
border-left-color: #595959;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-row.selected {
|
|
||||||
background-color: rgba(24, 144, 255, 0.15) !important;
|
|
||||||
border-left-color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-row .drag-handle {
|
|
||||||
color: rgba(255, 255, 255, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-row .drag-handle:hover {
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Progress bars */
|
|
||||||
.ant-progress-bg {
|
|
||||||
background-color: #303030;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Text colors */
|
|
||||||
.task-row .ant-typography {
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-row .text-gray-500 {
|
|
||||||
color: rgba(255, 255, 255, 0.45) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-row .text-gray-600 {
|
|
||||||
color: rgba(255, 255, 255, 0.65) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-row .text-gray-400 {
|
|
||||||
color: rgba(255, 255, 255, 0.45) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Completed task styling */
|
|
||||||
.task-row .line-through {
|
|
||||||
color: rgba(255, 255, 255, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Bulk Action Bar */
|
|
||||||
.bulk-action-bar {
|
|
||||||
background: rgba(24, 144, 255, 0.15);
|
|
||||||
border-color: rgba(24, 144, 255, 0.3);
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Cards and containers */
|
|
||||||
.ant-card {
|
|
||||||
background-color: #1f1f1f;
|
|
||||||
border-color: #303030;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-card-head {
|
|
||||||
background-color: #262626;
|
|
||||||
border-bottom-color: #303030;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-card-body {
|
|
||||||
background-color: #1f1f1f;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Buttons */
|
|
||||||
.ant-btn {
|
|
||||||
border-color: #303030;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-btn:hover {
|
|
||||||
border-color: #595959;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-btn-primary {
|
|
||||||
background-color: #1890ff;
|
|
||||||
border-color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-btn-primary:hover {
|
|
||||||
background-color: #40a9ff;
|
|
||||||
border-color: #40a9ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dropdowns and menus */
|
|
||||||
.ant-dropdown-menu {
|
|
||||||
background-color: #1f1f1f;
|
|
||||||
border-color: #303030;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-dropdown-menu-item {
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-dropdown-menu-item:hover {
|
|
||||||
background-color: #262626;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Select components */
|
|
||||||
.ant-select-selector {
|
|
||||||
background-color: #1f1f1f !important;
|
|
||||||
border-color: #303030 !important;
|
|
||||||
color: rgba(255, 255, 255, 0.85) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-select-arrow {
|
|
||||||
color: rgba(255, 255, 255, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Checkboxes */
|
|
||||||
.ant-checkbox-wrapper {
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-checkbox-inner {
|
|
||||||
background-color: #1f1f1f;
|
|
||||||
border-color: #303030;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-checkbox-checked .ant-checkbox-inner {
|
|
||||||
background-color: #1890ff;
|
|
||||||
border-color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tags and labels */
|
|
||||||
.ant-tag {
|
|
||||||
background-color: #262626;
|
|
||||||
border-color: #303030;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Avatars */
|
|
||||||
.ant-avatar {
|
|
||||||
background-color: #595959;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tooltips */
|
|
||||||
.ant-tooltip-inner {
|
|
||||||
background-color: #262626;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-tooltip-arrow-content {
|
|
||||||
background-color: #262626;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Popconfirm */
|
|
||||||
.ant-popover-inner {
|
|
||||||
background-color: #1f1f1f;
|
|
||||||
color: rgba(255, 255, 255, 0.85);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-popover-arrow-content {
|
|
||||||
background-color: #1f1f1f;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Subtasks */
|
|
||||||
.task-subtasks {
|
|
||||||
border-left-color: #303030;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-subtasks .task-row {
|
|
||||||
background-color: #141414;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-subtasks .task-row:hover {
|
|
||||||
background-color: #1f1f1f !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Scrollbars */
|
|
||||||
.task-groups-container::-webkit-scrollbar-track {
|
|
||||||
background: #141414;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-groups-container::-webkit-scrollbar-thumb {
|
|
||||||
background: #595959;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-groups-container::-webkit-scrollbar-thumb:hover {
|
|
||||||
background: #777777;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading states */
|
|
||||||
.ant-spin-dot-item {
|
|
||||||
background-color: #1890ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Empty states */
|
|
||||||
.ant-empty {
|
|
||||||
color: rgba(255, 255, 255, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ant-empty-description {
|
|
||||||
color: rgba(255, 255, 255, 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Focus styles for dark mode */
|
|
||||||
.task-row:focus-within {
|
|
||||||
outline-color: #40a9ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.drag-handle:focus {
|
|
||||||
outline-color: #40a9ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Border colors */
|
|
||||||
.border-gray-100 {
|
|
||||||
border-color: #303030 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-gray-200 {
|
|
||||||
border-color: #404040 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.border-gray-300 {
|
|
||||||
border-color: #595959 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Background utilities */
|
|
||||||
.bg-gray-50 {
|
|
||||||
background-color: #141414 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-gray-100 {
|
|
||||||
background-color: #1f1f1f !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-white {
|
|
||||||
background-color: #1f1f1f !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Due date colors in dark mode */
|
|
||||||
.text-red-500 {
|
|
||||||
color: #ff7875 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.text-orange-500 {
|
|
||||||
color: #ffa940 !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Group progress bar in dark mode */
|
|
||||||
.task-group .group-header .bg-gray-200 {
|
|
||||||
background-color: #303030 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Specific dark mode styles using data-theme attribute */
|
|
||||||
[data-theme="dark"] .task-group {
|
|
||||||
background-color: #1f1f1f;
|
background-color: #1f1f1f;
|
||||||
border-color: #303030;
|
border-color: #303030;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-group.drag-over {
|
.dark .task-group.drag-over {
|
||||||
border-color: #1890ff !important;
|
border-color: #1890ff !important;
|
||||||
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3);
|
box-shadow: 0 0 0 2px rgba(24, 144, 255, 0.3);
|
||||||
background-color: rgba(24, 144, 255, 0.1);
|
background-color: rgba(24, 144, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-group .group-header {
|
.dark .task-group .group-header {
|
||||||
background: #262626;
|
background: #262626;
|
||||||
border-bottom-color: #303030;
|
border-bottom-color: #303030;
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-group .group-header:hover {
|
.dark .task-group .group-header:hover {
|
||||||
background: #2f2f2f;
|
background: #2f2f2f;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-row {
|
.dark .task-row {
|
||||||
background-color: #1f1f1f;
|
background-color: #1f1f1f;
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
border-color: #303030;
|
border-color: #303030;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-row:hover {
|
.dark .task-row:hover {
|
||||||
background-color: #262626 !important;
|
background-color: #262626 !important;
|
||||||
border-left-color: #595959;
|
border-left-color: #595959;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-row.selected {
|
.dark .task-row.selected {
|
||||||
background-color: rgba(24, 144, 255, 0.15) !important;
|
background-color: rgba(24, 144, 255, 0.15) !important;
|
||||||
border-left-color: #1890ff;
|
border-left-color: #1890ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-row .drag-handle {
|
.dark .task-row .drag-handle {
|
||||||
color: rgba(255, 255, 255, 0.45);
|
color: rgba(255, 255, 255, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-row .drag-handle:hover {
|
.dark .task-row .drag-handle:hover {
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .bulk-action-bar {
|
.dark .bulk-action-bar {
|
||||||
background: rgba(24, 144, 255, 0.15);
|
background: rgba(24, 144, 255, 0.15);
|
||||||
border-color: rgba(24, 144, 255, 0.3);
|
border-color: rgba(24, 144, 255, 0.3);
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-row .ant-typography {
|
.dark .task-row .ant-typography {
|
||||||
color: rgba(255, 255, 255, 0.85);
|
color: rgba(255, 255, 255, 0.85);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-row .text-gray-500 {
|
.dark .task-row .text-gray-500 {
|
||||||
color: rgba(255, 255, 255, 0.45) !important;
|
color: rgba(255, 255, 255, 0.45) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-row .text-gray-600 {
|
.dark .task-row .text-gray-600 {
|
||||||
color: rgba(255, 255, 255, 0.65) !important;
|
color: rgba(255, 255, 255, 0.65) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-row .text-gray-400 {
|
.dark .task-row .text-gray-400 {
|
||||||
color: rgba(255, 255, 255, 0.45) !important;
|
color: rgba(255, 255, 255, 0.45) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-row .line-through {
|
.dark .task-row .line-through {
|
||||||
color: rgba(255, 255, 255, 0.45);
|
color: rgba(255, 255, 255, 0.45);
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-subtasks {
|
.dark .ant-card {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
border-color: #303030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-card-head {
|
||||||
|
background-color: #262626;
|
||||||
|
border-bottom-color: #303030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-card-body {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-btn {
|
||||||
|
background-color: #262626;
|
||||||
|
border-color: #404040;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-btn:hover {
|
||||||
|
background-color: #2f2f2f;
|
||||||
|
border-color: #505050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-btn-primary {
|
||||||
|
background-color: #1890ff;
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-btn-primary:hover {
|
||||||
|
background-color: #40a9ff;
|
||||||
|
border-color: #40a9ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-dropdown-menu {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
border-color: #303030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-dropdown-menu-item {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-dropdown-menu-item:hover {
|
||||||
|
background-color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-select-selector {
|
||||||
|
background-color: #262626 !important;
|
||||||
|
border-color: #404040 !important;
|
||||||
|
color: rgba(255, 255, 255, 0.85) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-select-arrow {
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-checkbox-wrapper {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-checkbox-inner {
|
||||||
|
background-color: #262626;
|
||||||
|
border-color: #404040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-checkbox-checked .ant-checkbox-inner {
|
||||||
|
background-color: #1890ff;
|
||||||
|
border-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-tag {
|
||||||
|
background-color: #262626;
|
||||||
|
border-color: #404040;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-avatar {
|
||||||
|
background-color: #404040;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-tooltip-inner {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-tooltip-arrow-content {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-popover-inner {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-popover-arrow-content {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .task-subtasks {
|
||||||
border-left-color: #303030;
|
border-left-color: #303030;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-subtasks .task-row {
|
.dark .task-subtasks .task-row {
|
||||||
background-color: #141414;
|
background-color: #141414;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .task-subtasks .task-row:hover {
|
.dark .task-subtasks .task-row:hover {
|
||||||
|
background-color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .task-groups-container::-webkit-scrollbar-track {
|
||||||
|
background-color: #262626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .task-groups-container::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #404040;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .task-groups-container::-webkit-scrollbar-thumb:hover {
|
||||||
|
background-color: #505050;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-spin-dot-item {
|
||||||
|
background-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-empty {
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .ant-empty-description {
|
||||||
|
color: rgba(255, 255, 255, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .task-row:focus-within {
|
||||||
|
outline-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .drag-handle:focus {
|
||||||
|
outline-color: #1890ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .border-gray-100 {
|
||||||
|
border-color: #262626 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .border-gray-200 {
|
||||||
|
border-color: #303030 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .border-gray-300 {
|
||||||
|
border-color: #404040 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-gray-50 {
|
||||||
|
background-color: #141414 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-gray-100 {
|
||||||
|
background-color: #1a1a1a !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .bg-white {
|
||||||
background-color: #1f1f1f !important;
|
background-color: #1f1f1f !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .text-red-500 {
|
.dark .text-red-500 {
|
||||||
color: #ff7875 !important;
|
color: #ff7875 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
[data-theme="dark"] .text-orange-500 {
|
.dark .text-orange-500 {
|
||||||
color: #ffa940 !important;
|
color: #ffa940 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .task-group .group-header .bg-gray-200 {
|
||||||
|
background-color: #262626 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* System preference fallback */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.task-list-board:not(.light) {
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-group:not(.light) {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row:not(.light) {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
color: rgba(255, 255, 255, 0.85);
|
||||||
|
border-color: #303030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-row:not(.light):hover {
|
||||||
|
background-color: #262626 !important;
|
||||||
|
border-left-color: #595959;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
|
import { InlineMember } from './teamMembers/inlineMember.types';
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
|
task_key: string;
|
||||||
title: string;
|
title: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
status: 'todo' | 'doing' | 'done';
|
status: 'todo' | 'doing' | 'done';
|
||||||
@@ -7,7 +10,8 @@ export interface Task {
|
|||||||
phase: string; // Custom phases like 'planning', 'development', 'testing', 'deployment'
|
phase: string; // Custom phases like 'planning', 'development', 'testing', 'deployment'
|
||||||
progress: number; // 0-100
|
progress: number; // 0-100
|
||||||
assignees: string[];
|
assignees: string[];
|
||||||
labels: string[];
|
assignee_names?: InlineMember[];
|
||||||
|
labels: Label[];
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
timeTracking: {
|
timeTracking: {
|
||||||
estimated?: number;
|
estimated?: number;
|
||||||
@@ -56,6 +60,8 @@ export interface Label {
|
|||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
color: string;
|
color: string;
|
||||||
|
end?: boolean;
|
||||||
|
names?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Redux State Interfaces
|
// Redux State Interfaces
|
||||||
@@ -64,6 +70,9 @@ export interface TaskManagementState {
|
|||||||
ids: string[];
|
ids: string[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
|
groups: TaskGroup[]; // Pre-processed groups from V3 API
|
||||||
|
grouping: string | null; // Current grouping from V3 API
|
||||||
|
selectedPriorities: string[]; // Selected priority filters
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskGroupsState {
|
export interface TaskGroupsState {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export default defineConfig(({ command, mode }) => {
|
|||||||
|
|
||||||
// **Development Server**
|
// **Development Server**
|
||||||
server: {
|
server: {
|
||||||
port: 3000,
|
port: 5173,
|
||||||
open: true,
|
open: true,
|
||||||
hmr: {
|
hmr: {
|
||||||
overlay: false,
|
overlay: false,
|
||||||
@@ -108,9 +108,6 @@ export default defineConfig(({ command, mode }) => {
|
|||||||
|
|
||||||
// **Preserve modules to avoid context issues**
|
// **Preserve modules to avoid context issues**
|
||||||
preserveEntrySignatures: 'strict',
|
preserveEntrySignatures: 'strict',
|
||||||
|
|
||||||
// **Ensure proper module interop**
|
|
||||||
interop: 'auto',
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// **Experimental features for better performance**
|
// **Experimental features for better performance**
|
||||||
|
|||||||
Reference in New Issue
Block a user