Merge branch 'release/v2.0.4' of https://github.com/Worklenz/worklenz into test/row-kanban-board-v1
This commit is contained in:
@@ -997,11 +997,9 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
const shouldRefreshProgress = req.query.refresh_progress === "true";
|
const shouldRefreshProgress = req.query.refresh_progress === "true";
|
||||||
|
|
||||||
if (shouldRefreshProgress && req.params.id) {
|
if (shouldRefreshProgress && req.params.id) {
|
||||||
console.log(`[PERFORMANCE] Starting progress refresh for project ${req.params.id}`);
|
|
||||||
const progressStartTime = performance.now();
|
const progressStartTime = performance.now();
|
||||||
await this.refreshProjectTaskProgressValues(req.params.id);
|
await this.refreshProjectTaskProgressValues(req.params.id);
|
||||||
const progressEndTime = performance.now();
|
const progressEndTime = performance.now();
|
||||||
console.log(`[PERFORMANCE] Progress refresh completed in ${(progressEndTime - progressStartTime).toFixed(2)}ms`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const queryStartTime = performance.now();
|
const queryStartTime = performance.now();
|
||||||
@@ -1011,13 +1009,11 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
const result = await db.query(q, params);
|
const result = await db.query(q, params);
|
||||||
const tasks = [...result.rows];
|
const tasks = [...result.rows];
|
||||||
const queryEndTime = performance.now();
|
const queryEndTime = performance.now();
|
||||||
console.log(`[PERFORMANCE] Database query completed in ${(queryEndTime - queryStartTime).toFixed(2)}ms for ${tasks.length} tasks`);
|
|
||||||
|
|
||||||
// Get groups metadata dynamically from database
|
// Get groups metadata dynamically from database
|
||||||
const groupsStartTime = performance.now();
|
const groupsStartTime = performance.now();
|
||||||
const groups = await this.getGroups(groupBy, req.params.id);
|
const groups = await this.getGroups(groupBy, req.params.id);
|
||||||
const groupsEndTime = performance.now();
|
const groupsEndTime = performance.now();
|
||||||
console.log(`[PERFORMANCE] Groups fetched in ${(groupsEndTime - groupsStartTime).toFixed(2)}ms`);
|
|
||||||
|
|
||||||
// Create priority value to name mapping
|
// Create priority value to name mapping
|
||||||
const priorityMap: Record<string, string> = {
|
const priorityMap: Record<string, string> = {
|
||||||
@@ -1094,10 +1090,17 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
originalPriorityId: task.priority,
|
originalPriorityId: task.priority,
|
||||||
statusColor: task.status_color,
|
statusColor: task.status_color,
|
||||||
priorityColor: task.priority_color,
|
priorityColor: task.priority_color,
|
||||||
|
// Add subtask count
|
||||||
|
sub_tasks_count: task.sub_tasks_count || 0,
|
||||||
|
// Add indicator fields for frontend icons
|
||||||
|
comments_count: task.comments_count || 0,
|
||||||
|
has_subscribers: !!task.has_subscribers,
|
||||||
|
attachments_count: task.attachments_count || 0,
|
||||||
|
has_dependencies: !!task.has_dependencies,
|
||||||
|
schedule_id: task.schedule_id || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const transformEndTime = performance.now();
|
const transformEndTime = performance.now();
|
||||||
console.log(`[PERFORMANCE] Task transformation completed in ${(transformEndTime - transformStartTime).toFixed(2)}ms`);
|
|
||||||
|
|
||||||
// Create groups based on dynamic data from database
|
// Create groups based on dynamic data from database
|
||||||
const groupingStartTime = performance.now();
|
const groupingStartTime = performance.now();
|
||||||
@@ -1164,11 +1167,9 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
.filter(group => group && (group.tasks.length > 0 || req.query.include_empty === "true"));
|
.filter(group => group && (group.tasks.length > 0 || req.query.include_empty === "true"));
|
||||||
|
|
||||||
const groupingEndTime = performance.now();
|
const groupingEndTime = performance.now();
|
||||||
console.log(`[PERFORMANCE] Task grouping completed in ${(groupingEndTime - groupingStartTime).toFixed(2)}ms`);
|
|
||||||
|
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
const totalTime = endTime - startTime;
|
const totalTime = endTime - startTime;
|
||||||
console.log(`[PERFORMANCE] Total getTasksV3 request completed in ${totalTime.toFixed(2)}ms for project ${req.params.id}`);
|
|
||||||
|
|
||||||
// Log warning if request is taking too long
|
// Log warning if request is taking too long
|
||||||
if (totalTime > 1000) {
|
if (totalTime > 1000) {
|
||||||
@@ -1235,9 +1236,8 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
projectId: req.params.id
|
projectId: req.params.id
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
} else {
|
|
||||||
return res.status(400).send(new ServerResponse(false, null, "Project ID is required"));
|
|
||||||
}
|
}
|
||||||
|
return res.status(400).send(new ServerResponse(false, null, "Project ID is required"));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error refreshing task progress:", error);
|
console.error("Error refreshing task progress:", error);
|
||||||
return res.status(500).send(new ServerResponse(false, null, "Failed to refresh task progress"));
|
return res.status(500).send(new ServerResponse(false, null, "Failed to refresh task progress"));
|
||||||
|
|||||||
@@ -12,160 +12,130 @@ import { assignMemberIfNot } from "./on-quick-assign-or-remove";
|
|||||||
interface ChangeRequest {
|
interface ChangeRequest {
|
||||||
from_index: number; // from sort_order
|
from_index: number; // from sort_order
|
||||||
to_index: number; // to sort_order
|
to_index: number; // to sort_order
|
||||||
to_last_index: boolean;
|
project_id: string;
|
||||||
from_group: string;
|
from_group: string;
|
||||||
to_group: string;
|
to_group: string;
|
||||||
group_by: string;
|
group_by: string;
|
||||||
project_id: string;
|
to_last_index: boolean;
|
||||||
task: any;
|
task: {
|
||||||
|
id: string;
|
||||||
|
project_id: string;
|
||||||
|
status: string;
|
||||||
|
priority: string;
|
||||||
|
};
|
||||||
team_id: string;
|
team_id: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// PERFORMANCE OPTIMIZATION: Connection pooling for better database performance
|
interface Config {
|
||||||
const dbPool = {
|
from_index: number;
|
||||||
query: async (text: string, params?: any[]) => {
|
to_index: number;
|
||||||
return await db.query(text, params);
|
task_id: string;
|
||||||
|
from_group: string | null;
|
||||||
|
to_group: string | null;
|
||||||
|
project_id: string;
|
||||||
|
group_by: string;
|
||||||
|
to_last_index: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyStatusChange(socket: Socket, config: Config) {
|
||||||
|
const userId = getLoggedInUserIdFromSocket(socket);
|
||||||
|
if (userId && config.to_group) {
|
||||||
|
void TasksController.notifyStatusChange(userId, config.task_id, config.to_group);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
|
|
||||||
// PERFORMANCE OPTIMIZATION: Cache for dependency checks to reduce database queries
|
async function emitSortOrderChange(data: ChangeRequest, socket: Socket) {
|
||||||
const dependencyCache = new Map<string, { result: boolean; timestamp: number }>();
|
const q = `
|
||||||
const CACHE_TTL = 5000; // 5 seconds cache
|
SELECT id, sort_order, completed_at
|
||||||
|
FROM tasks
|
||||||
|
WHERE project_id = $1
|
||||||
|
ORDER BY sort_order;
|
||||||
|
`;
|
||||||
|
const tasks = await db.query(q, [data.project_id]);
|
||||||
|
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), tasks.rows);
|
||||||
|
}
|
||||||
|
|
||||||
const clearExpiredCache = () => {
|
function updateUnmappedStatus(config: Config) {
|
||||||
const now = Date.now();
|
if (config.to_group === UNMAPPED)
|
||||||
for (const [key, value] of dependencyCache.entries()) {
|
config.to_group = null;
|
||||||
if (now - value.timestamp > CACHE_TTL) {
|
if (config.from_group === UNMAPPED)
|
||||||
dependencyCache.delete(key);
|
config.from_group = null;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Clear expired cache entries every 10 seconds
|
export async function on_task_sort_order_change(_io: Server, socket: Socket, data: ChangeRequest) {
|
||||||
setInterval(clearExpiredCache, 10000);
|
|
||||||
|
|
||||||
const onTaskSortOrderChange = async (io: Server, socket: Socket, data: ChangeRequest) => {
|
|
||||||
try {
|
try {
|
||||||
const userId = getLoggedInUserIdFromSocket(socket);
|
const q = `SELECT handle_task_list_sort_order_change($1);`;
|
||||||
if (!userId) {
|
|
||||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { error: "User not authenticated" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const {
|
const config: Config = {
|
||||||
from_index,
|
from_index: data.from_index,
|
||||||
to_index,
|
to_index: data.to_index,
|
||||||
to_last_index,
|
task_id: data.task.id,
|
||||||
from_group,
|
from_group: data.from_group,
|
||||||
to_group,
|
to_group: data.to_group,
|
||||||
group_by,
|
project_id: data.project_id,
|
||||||
project_id,
|
group_by: data.group_by,
|
||||||
task,
|
to_last_index: Boolean(data.to_last_index)
|
||||||
team_id
|
|
||||||
} = data;
|
|
||||||
|
|
||||||
// PERFORMANCE OPTIMIZATION: Validate input data early to avoid expensive operations
|
|
||||||
if (!project_id || !task?.id || !team_id) {
|
|
||||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { error: "Missing required data" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PERFORMANCE OPTIMIZATION: Use cached dependency check if available
|
|
||||||
const cacheKey = `${project_id}-${userId}-${team_id}`;
|
|
||||||
const cachedDependency = dependencyCache.get(cacheKey);
|
|
||||||
|
|
||||||
let hasAccess = false;
|
|
||||||
if (cachedDependency && (Date.now() - cachedDependency.timestamp) < CACHE_TTL) {
|
|
||||||
hasAccess = cachedDependency.result;
|
|
||||||
} else {
|
|
||||||
// PERFORMANCE OPTIMIZATION: Optimized dependency check query
|
|
||||||
const dependencyResult = await dbPool.query(`
|
|
||||||
SELECT EXISTS(
|
|
||||||
SELECT 1 FROM project_members pm
|
|
||||||
INNER JOIN projects p ON p.id = pm.project_id
|
|
||||||
INNER JOIN team_members tm ON pm.team_member_id = tm.id
|
|
||||||
WHERE pm.project_id = $1
|
|
||||||
AND tm.user_id = $2
|
|
||||||
AND p.team_id = $3
|
|
||||||
) as has_access
|
|
||||||
`, [project_id, userId, team_id]);
|
|
||||||
|
|
||||||
hasAccess = dependencyResult.rows[0]?.has_access || false;
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
dependencyCache.set(cacheKey, { result: hasAccess, timestamp: Date.now() });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasAccess) {
|
|
||||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { error: "Access denied" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// PERFORMANCE OPTIMIZATION: Execute database operation directly
|
|
||||||
await dbPool.query(`SELECT handle_task_list_sort_order_change($1)`, [JSON.stringify({
|
|
||||||
project_id,
|
|
||||||
task_id: task.id,
|
|
||||||
from_index,
|
|
||||||
to_index,
|
|
||||||
to_last_index,
|
|
||||||
from_group,
|
|
||||||
to_group,
|
|
||||||
group_by
|
|
||||||
})]);
|
|
||||||
|
|
||||||
// PERFORMANCE OPTIMIZATION: Optimized project updates notification
|
|
||||||
const projectUpdateData = {
|
|
||||||
project_id,
|
|
||||||
team_id,
|
|
||||||
user_id: userId,
|
|
||||||
update_type: "task_sort_order_change",
|
|
||||||
task_id: task.id,
|
|
||||||
from_group,
|
|
||||||
to_group,
|
|
||||||
group_by
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Emit to all users in the project room
|
if ((config.group_by === GroupBy.STATUS) && config.to_group) {
|
||||||
io.to(`project_${project_id}`).emit("project_updates", projectUpdateData);
|
const canContinue = await TasksControllerV2.checkForCompletedDependencies(config.task_id, config?.to_group);
|
||||||
|
if (!canContinue) {
|
||||||
// PERFORMANCE OPTIMIZATION: Optimized activity logging
|
return socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
|
||||||
const activityLogData = {
|
completed_deps: canContinue
|
||||||
task_id: task.id,
|
});
|
||||||
socket,
|
|
||||||
new_value: to_group,
|
|
||||||
old_value: from_group
|
|
||||||
};
|
|
||||||
|
|
||||||
// Log activity asynchronously to avoid blocking the response
|
|
||||||
setImmediate(async () => {
|
|
||||||
try {
|
|
||||||
if (group_by === "phase") {
|
|
||||||
await logPhaseChange(activityLogData);
|
|
||||||
} else if (group_by === "status") {
|
|
||||||
await logStatusChange(activityLogData);
|
|
||||||
} else if (group_by === "priority") {
|
|
||||||
await logPriorityChange(activityLogData);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
log_error(error);
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Send success response
|
notifyStatusChange(socket, config);
|
||||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
|
}
|
||||||
success: true,
|
|
||||||
task_id: task.id,
|
|
||||||
from_group,
|
|
||||||
to_group,
|
|
||||||
group_by
|
|
||||||
});
|
|
||||||
|
|
||||||
|
if (config.group_by === GroupBy.PHASE) {
|
||||||
|
updateUnmappedStatus(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.query(q, [JSON.stringify(config)]);
|
||||||
|
await emitSortOrderChange(data, socket);
|
||||||
|
|
||||||
|
if (config.group_by === GroupBy.STATUS) {
|
||||||
|
const userId = getLoggedInUserIdFromSocket(socket);
|
||||||
|
const isAlreadyAssigned = await TasksControllerV2.checkUserAssignedToTask(data.task.id, userId as string, data.team_id);
|
||||||
|
|
||||||
|
if (!isAlreadyAssigned) {
|
||||||
|
await assignMemberIfNot(data.task.id, userId as string, data.team_id, _io, socket);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.group_by === GroupBy.PHASE) {
|
||||||
|
void logPhaseChange({
|
||||||
|
task_id: data.task.id,
|
||||||
|
socket,
|
||||||
|
new_value: data.to_group,
|
||||||
|
old_value: data.from_group
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.group_by === GroupBy.STATUS) {
|
||||||
|
void logStatusChange({
|
||||||
|
task_id: data.task.id,
|
||||||
|
socket,
|
||||||
|
new_value: data.to_group,
|
||||||
|
old_value: data.from_group
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.group_by === GroupBy.PRIORITY) {
|
||||||
|
void logPriorityChange({
|
||||||
|
task_id: data.task.id,
|
||||||
|
socket,
|
||||||
|
new_value: data.to_group,
|
||||||
|
old_value: data.from_group
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void notifyProjectUpdates(socket, config.task_id);
|
||||||
|
return;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log_error(error);
|
log_error(error);
|
||||||
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), {
|
|
||||||
error: "Internal server error"
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
export default onTaskSortOrderChange;
|
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), []);
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ import { on_task_description_change } from "./commands/on-task-description-chang
|
|||||||
import { on_get_task_progress } from "./commands/on-get-task-progress";
|
import { on_get_task_progress } from "./commands/on-get-task-progress";
|
||||||
import { on_task_timer_start } from "./commands/on-task-timer-start";
|
import { on_task_timer_start } from "./commands/on-task-timer-start";
|
||||||
import { on_task_timer_stop } from "./commands/on-task-timer-stop";
|
import { on_task_timer_stop } from "./commands/on-task-timer-stop";
|
||||||
import on_task_sort_order_change from "./commands/on-task-sort-order-change";
|
import { on_task_sort_order_change } from "./commands/on-task-sort-order-change";
|
||||||
import { on_join_project_room as on_join_or_leave_project_room } from "./commands/on-join-or-leave-project-room";
|
import { on_join_project_room as on_join_or_leave_project_room } from "./commands/on-join-or-leave-project-room";
|
||||||
import { on_task_subscriber_change } from "./commands/on-task-subscriber-change";
|
import { on_task_subscriber_change } from "./commands/on-task-subscriber-change";
|
||||||
import { on_project_subscriber_change } from "./commands/on-project-subscriber-change";
|
import { on_project_subscriber_change } from "./commands/on-project-subscriber-change";
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
"createTaskTemplate": "Krijo Shabllon Detyre",
|
"createTaskTemplate": "Krijo Shabllon Detyre",
|
||||||
"apply": "Apliko",
|
"apply": "Apliko",
|
||||||
"createLabel": "+ Krijo Etiketë",
|
"createLabel": "+ Krijo Etiketë",
|
||||||
|
"searchOrCreateLabel": "Kërko ose krijo etiketë...",
|
||||||
"hitEnterToCreate": "Shtyp Enter për të krijuar",
|
"hitEnterToCreate": "Shtyp Enter për të krijuar",
|
||||||
|
"labelExists": "Etiketa ekziston tashmë",
|
||||||
"pendingInvitation": "Ftesë në Pritje",
|
"pendingInvitation": "Ftesë në Pritje",
|
||||||
"noMatchingLabels": "Asnjë etiketë që përputhet",
|
"noMatchingLabels": "Asnjë etiketë që përputhet",
|
||||||
"noLabels": "Asnjë etiketë"
|
"noLabels": "Asnjë etiketë"
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
"createTaskTemplate": "Aufgabenvorlage erstellen",
|
"createTaskTemplate": "Aufgabenvorlage erstellen",
|
||||||
"apply": "Anwenden",
|
"apply": "Anwenden",
|
||||||
"createLabel": "+ Label erstellen",
|
"createLabel": "+ Label erstellen",
|
||||||
|
"searchOrCreateLabel": "Label suchen oder erstellen...",
|
||||||
"hitEnterToCreate": "Enter drücken zum Erstellen",
|
"hitEnterToCreate": "Enter drücken zum Erstellen",
|
||||||
|
"labelExists": "Label existiert bereits",
|
||||||
"pendingInvitation": "Einladung ausstehend",
|
"pendingInvitation": "Einladung ausstehend",
|
||||||
"noMatchingLabels": "Keine passenden Labels",
|
"noMatchingLabels": "Keine passenden Labels",
|
||||||
"noLabels": "Keine Labels",
|
"noLabels": "Keine Labels",
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
"cancelText": "Cancel",
|
"cancelText": "Cancel",
|
||||||
"saveText": "Save",
|
"saveText": "Save",
|
||||||
"templateNameText": "Template Name",
|
"templateNameText": "Template Name",
|
||||||
|
"templateNameRequired": "Template name is required",
|
||||||
"selectedTasks": "Selected Tasks",
|
"selectedTasks": "Selected Tasks",
|
||||||
"removeTask": "Remove",
|
"removeTask": "Remove",
|
||||||
"cancelButton": "Cancel",
|
"cancelButton": "Cancel",
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
"createTaskTemplate": "Create Task Template",
|
"createTaskTemplate": "Create Task Template",
|
||||||
"apply": "Apply",
|
"apply": "Apply",
|
||||||
"createLabel": "+ Create Label",
|
"createLabel": "+ Create Label",
|
||||||
|
"searchOrCreateLabel": "Search or create label...",
|
||||||
"hitEnterToCreate": "Press Enter to create",
|
"hitEnterToCreate": "Press Enter to create",
|
||||||
|
"labelExists": "Label already exists",
|
||||||
"pendingInvitation": "Pending Invitation",
|
"pendingInvitation": "Pending Invitation",
|
||||||
"noMatchingLabels": "No matching labels",
|
"noMatchingLabels": "No matching labels",
|
||||||
"noLabels": "No labels",
|
"noLabels": "No labels",
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
"createTaskTemplate": "Crear plantilla de tarea",
|
"createTaskTemplate": "Crear plantilla de tarea",
|
||||||
"apply": "Aplicar",
|
"apply": "Aplicar",
|
||||||
"createLabel": "+ Crear etiqueta",
|
"createLabel": "+ Crear etiqueta",
|
||||||
|
"searchOrCreateLabel": "Buscar o crear etiqueta...",
|
||||||
"hitEnterToCreate": "Presione Enter para crear",
|
"hitEnterToCreate": "Presione Enter para crear",
|
||||||
|
"labelExists": "La etiqueta ya existe",
|
||||||
"pendingInvitation": "Invitación Pendiente",
|
"pendingInvitation": "Invitación Pendiente",
|
||||||
"noMatchingLabels": "No hay etiquetas coincidentes",
|
"noMatchingLabels": "No hay etiquetas coincidentes",
|
||||||
"noLabels": "Sin etiquetas",
|
"noLabels": "Sin etiquetas",
|
||||||
|
|||||||
@@ -17,7 +17,9 @@
|
|||||||
"createTaskTemplate": "Criar Modelo de Tarefa",
|
"createTaskTemplate": "Criar Modelo de Tarefa",
|
||||||
"apply": "Aplicar",
|
"apply": "Aplicar",
|
||||||
"createLabel": "+ Criar etiqueta",
|
"createLabel": "+ Criar etiqueta",
|
||||||
|
"searchOrCreateLabel": "Pesquisar ou criar etiqueta...",
|
||||||
"hitEnterToCreate": "Pressione Enter para criar",
|
"hitEnterToCreate": "Pressione Enter para criar",
|
||||||
|
"labelExists": "A etiqueta já existe",
|
||||||
"pendingInvitation": "Convite Pendente",
|
"pendingInvitation": "Convite Pendente",
|
||||||
"noMatchingLabels": "Nenhuma etiqueta correspondente",
|
"noMatchingLabels": "Nenhuma etiqueta correspondente",
|
||||||
"noLabels": "Sem etiquetas",
|
"noLabels": "Sem etiquetas",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import React, { Suspense, useEffect, memo, useMemo, useCallback } from 'react';
|
import React, { Suspense, useEffect, memo, useMemo, useCallback } from 'react';
|
||||||
import { RouterProvider } from 'react-router-dom';
|
import { RouterProvider } from 'react-router-dom';
|
||||||
import i18next from 'i18next';
|
import i18next from 'i18next';
|
||||||
|
import { ensureTranslationsLoaded } from './i18n';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import ThemeWrapper from './features/theme/ThemeWrapper';
|
import ThemeWrapper from './features/theme/ThemeWrapper';
|
||||||
@@ -56,15 +57,25 @@ const App: React.FC = memo(() => {
|
|||||||
handleLanguageChange(language || Language.EN);
|
handleLanguageChange(language || Language.EN);
|
||||||
}, [language, handleLanguageChange]);
|
}, [language, handleLanguageChange]);
|
||||||
|
|
||||||
// Initialize CSRF token on app startup - memoize to prevent re-initialization
|
// Initialize CSRF token and translations on app startup
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let isMounted = true;
|
let isMounted = true;
|
||||||
|
|
||||||
initializeCsrfToken().catch(error => {
|
const initializeApp = async () => {
|
||||||
if (isMounted) {
|
try {
|
||||||
logger.error('Failed to initialize CSRF token:', error);
|
// Initialize CSRF token
|
||||||
|
await initializeCsrfToken();
|
||||||
|
|
||||||
|
// Preload essential translations
|
||||||
|
await ensureTranslationsLoaded();
|
||||||
|
} catch (error) {
|
||||||
|
if (isMounted) {
|
||||||
|
logger.error('Failed to initialize app:', error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
initializeApp();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
isMounted = false;
|
isMounted = false;
|
||||||
|
|||||||
@@ -64,11 +64,9 @@ apiClient.interceptors.request.use(
|
|||||||
|
|
||||||
// Ensure we have a CSRF token before making requests
|
// Ensure we have a CSRF token before making requests
|
||||||
if (!csrfToken) {
|
if (!csrfToken) {
|
||||||
console.log('[API CLIENT] No CSRF token, fetching...');
|
|
||||||
const tokenStart = performance.now();
|
const tokenStart = performance.now();
|
||||||
await refreshCsrfToken();
|
await refreshCsrfToken();
|
||||||
const tokenEnd = performance.now();
|
const tokenEnd = performance.now();
|
||||||
console.log(`[API CLIENT] CSRF token fetch took ${(tokenEnd - tokenStart).toFixed(2)}ms`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (csrfToken) {
|
if (csrfToken) {
|
||||||
@@ -78,7 +76,6 @@ apiClient.interceptors.request.use(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const requestEnd = performance.now();
|
const requestEnd = performance.now();
|
||||||
console.log(`[API CLIENT] Request interceptor took ${(requestEnd - requestStart).toFixed(2)}ms`);
|
|
||||||
|
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -159,4 +159,24 @@ export const tasksApiService = {
|
|||||||
const response = await apiClient.get(`${rootUrl}/progress-status/${projectId}`);
|
const response = await apiClient.get(`${rootUrl}/progress-status/${projectId}`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// API method to reorder tasks
|
||||||
|
reorderTasks: async (params: { taskIds: string[]; newOrder: number[]; projectId: string }): Promise<IServerResponse<{ done: boolean }>> => {
|
||||||
|
const response = await apiClient.post(`${rootUrl}/reorder`, {
|
||||||
|
task_ids: params.taskIds,
|
||||||
|
new_order: params.newOrder,
|
||||||
|
project_id: params.projectId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// API method to update task group (status, priority, phase)
|
||||||
|
updateTaskGroup: async (params: { taskId: string; groupType: 'status' | 'priority' | 'phase'; groupValue: string; projectId: string }): Promise<IServerResponse<{ done: boolean }>> => {
|
||||||
|
const response = await apiClient.put(`${rootUrl}/${params.taskId}/group`, {
|
||||||
|
group_type: params.groupType,
|
||||||
|
group_value: params.groupValue,
|
||||||
|
project_id: params.projectId,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
/**
|
|
||||||
* Centralized Ant Design imports for Task Management components
|
|
||||||
*
|
|
||||||
* This file provides:
|
|
||||||
* - Tree-shaking optimization by importing only used components
|
|
||||||
* - Type safety with proper TypeScript types
|
|
||||||
* - Performance optimization through selective imports
|
|
||||||
* - Consistent component versions across task management
|
|
||||||
* - Easy maintenance and updates
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Core Components
|
|
||||||
export {
|
|
||||||
Button,
|
|
||||||
Input,
|
|
||||||
Select,
|
|
||||||
Typography,
|
|
||||||
Card,
|
|
||||||
Spin,
|
|
||||||
Empty,
|
|
||||||
Space,
|
|
||||||
Tooltip,
|
|
||||||
Badge,
|
|
||||||
Popconfirm,
|
|
||||||
message,
|
|
||||||
Checkbox,
|
|
||||||
Dropdown,
|
|
||||||
Menu
|
|
||||||
} from 'antd/es';
|
|
||||||
|
|
||||||
// Date & Time Components
|
|
||||||
export {
|
|
||||||
DatePicker,
|
|
||||||
TimePicker
|
|
||||||
} from 'antd/es';
|
|
||||||
|
|
||||||
// Form Components (if needed for task management)
|
|
||||||
export {
|
|
||||||
Form,
|
|
||||||
InputNumber
|
|
||||||
} from 'antd/es';
|
|
||||||
|
|
||||||
// Layout Components
|
|
||||||
export {
|
|
||||||
Row,
|
|
||||||
Col,
|
|
||||||
Divider,
|
|
||||||
Flex
|
|
||||||
} from 'antd/es';
|
|
||||||
|
|
||||||
// Icon Components (commonly used in task management)
|
|
||||||
export {
|
|
||||||
EditOutlined,
|
|
||||||
DeleteOutlined,
|
|
||||||
PlusOutlined,
|
|
||||||
MoreOutlined,
|
|
||||||
CheckOutlined,
|
|
||||||
CloseOutlined,
|
|
||||||
CalendarOutlined,
|
|
||||||
ClockCircleOutlined,
|
|
||||||
UserOutlined,
|
|
||||||
TeamOutlined,
|
|
||||||
TagOutlined,
|
|
||||||
FlagOutlined,
|
|
||||||
BarsOutlined,
|
|
||||||
TableOutlined,
|
|
||||||
AppstoreOutlined,
|
|
||||||
FilterOutlined,
|
|
||||||
SortAscendingOutlined,
|
|
||||||
SortDescendingOutlined,
|
|
||||||
SearchOutlined,
|
|
||||||
ReloadOutlined,
|
|
||||||
SettingOutlined,
|
|
||||||
EyeOutlined,
|
|
||||||
EyeInvisibleOutlined,
|
|
||||||
CopyOutlined,
|
|
||||||
ExportOutlined,
|
|
||||||
ImportOutlined,
|
|
||||||
DownOutlined,
|
|
||||||
RightOutlined,
|
|
||||||
LeftOutlined,
|
|
||||||
UpOutlined,
|
|
||||||
DragOutlined,
|
|
||||||
HolderOutlined,
|
|
||||||
MessageOutlined,
|
|
||||||
PaperClipOutlined,
|
|
||||||
GroupOutlined,
|
|
||||||
InboxOutlined,
|
|
||||||
TagsOutlined,
|
|
||||||
UsergroupAddOutlined,
|
|
||||||
UserAddOutlined,
|
|
||||||
RetweetOutlined
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
|
|
||||||
// TypeScript Types
|
|
||||||
export type {
|
|
||||||
ButtonProps,
|
|
||||||
InputProps,
|
|
||||||
InputRef,
|
|
||||||
SelectProps,
|
|
||||||
TypographyProps,
|
|
||||||
CardProps,
|
|
||||||
SpinProps,
|
|
||||||
EmptyProps,
|
|
||||||
SpaceProps,
|
|
||||||
TooltipProps,
|
|
||||||
BadgeProps,
|
|
||||||
PopconfirmProps,
|
|
||||||
CheckboxProps,
|
|
||||||
CheckboxChangeEvent,
|
|
||||||
DropdownProps,
|
|
||||||
MenuProps,
|
|
||||||
DatePickerProps,
|
|
||||||
TimePickerProps,
|
|
||||||
FormProps,
|
|
||||||
FormInstance,
|
|
||||||
InputNumberProps,
|
|
||||||
RowProps,
|
|
||||||
ColProps,
|
|
||||||
DividerProps,
|
|
||||||
FlexProps
|
|
||||||
} from 'antd/es';
|
|
||||||
|
|
||||||
// Dayjs (used with DatePicker)
|
|
||||||
export { default as dayjs } from 'dayjs';
|
|
||||||
export type { Dayjs } from 'dayjs';
|
|
||||||
|
|
||||||
// Re-export commonly used Ant Design utilities
|
|
||||||
export {
|
|
||||||
ConfigProvider,
|
|
||||||
theme
|
|
||||||
} from 'antd';
|
|
||||||
|
|
||||||
// Custom hooks for task management (if any Ant Design specific hooks are needed)
|
|
||||||
export const useAntdBreakpoint = () => {
|
|
||||||
// You can add custom breakpoint logic here if needed
|
|
||||||
return {
|
|
||||||
xs: window.innerWidth < 576,
|
|
||||||
sm: window.innerWidth >= 576 && window.innerWidth < 768,
|
|
||||||
md: window.innerWidth >= 768 && window.innerWidth < 992,
|
|
||||||
lg: window.innerWidth >= 992 && window.innerWidth < 1200,
|
|
||||||
xl: window.innerWidth >= 1200 && window.innerWidth < 1600,
|
|
||||||
xxl: window.innerWidth >= 1600,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Import message separately to avoid circular dependency
|
|
||||||
import { message as antdMessage } from 'antd';
|
|
||||||
|
|
||||||
// Performance optimized message utility
|
|
||||||
export const taskMessage = {
|
|
||||||
success: (content: string) => antdMessage.success(content),
|
|
||||||
error: (content: string) => antdMessage.error(content),
|
|
||||||
warning: (content: string) => antdMessage.warning(content),
|
|
||||||
info: (content: string) => antdMessage.info(content),
|
|
||||||
loading: (content: string) => antdMessage.loading(content),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Commonly used Ant Design configurations for task management
|
|
||||||
export const taskManagementAntdConfig = {
|
|
||||||
// DatePicker default props for consistency
|
|
||||||
datePickerDefaults: {
|
|
||||||
format: 'MMM DD, YYYY',
|
|
||||||
placeholder: 'Set Date',
|
|
||||||
suffixIcon: null,
|
|
||||||
size: 'small' as const,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Button default props for task actions
|
|
||||||
taskButtonDefaults: {
|
|
||||||
size: 'small' as const,
|
|
||||||
type: 'text' as const,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Input default props for task editing
|
|
||||||
taskInputDefaults: {
|
|
||||||
size: 'small' as const,
|
|
||||||
variant: 'borderless' as const,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Select default props for dropdowns
|
|
||||||
taskSelectDefaults: {
|
|
||||||
size: 'small' as const,
|
|
||||||
variant: 'borderless' as const,
|
|
||||||
showSearch: true,
|
|
||||||
optionFilterProp: 'label' as const,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Tooltip default props
|
|
||||||
tooltipDefaults: {
|
|
||||||
placement: 'top' as const,
|
|
||||||
mouseEnterDelay: 0.5,
|
|
||||||
mouseLeaveDelay: 0.1,
|
|
||||||
},
|
|
||||||
|
|
||||||
// Dropdown default props
|
|
||||||
dropdownDefaults: {
|
|
||||||
trigger: ['click'] as const,
|
|
||||||
placement: 'bottomLeft' as const,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Theme tokens specifically for task management
|
|
||||||
export const taskManagementTheme = {
|
|
||||||
light: {
|
|
||||||
colorBgContainer: '#ffffff',
|
|
||||||
colorBorder: '#e5e7eb',
|
|
||||||
colorText: '#374151',
|
|
||||||
colorTextSecondary: '#6b7280',
|
|
||||||
colorPrimary: '#3b82f6',
|
|
||||||
colorSuccess: '#10b981',
|
|
||||||
colorWarning: '#f59e0b',
|
|
||||||
colorError: '#ef4444',
|
|
||||||
colorBgHover: '#f9fafb',
|
|
||||||
colorBgSelected: '#eff6ff',
|
|
||||||
},
|
|
||||||
dark: {
|
|
||||||
colorBgContainer: '#1f2937',
|
|
||||||
colorBorder: '#374151',
|
|
||||||
colorText: '#f9fafb',
|
|
||||||
colorTextSecondary: '#d1d5db',
|
|
||||||
colorPrimary: '#60a5fa',
|
|
||||||
colorSuccess: '#34d399',
|
|
||||||
colorWarning: '#fbbf24',
|
|
||||||
colorError: '#f87171',
|
|
||||||
colorBgHover: '#374151',
|
|
||||||
colorBgSelected: '#1e40af',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Export default configuration object
|
|
||||||
export default {
|
|
||||||
config: taskManagementAntdConfig,
|
|
||||||
theme: taskManagementTheme,
|
|
||||||
message: taskMessage,
|
|
||||||
useBreakpoint: useAntdBreakpoint,
|
|
||||||
};
|
|
||||||
@@ -1,265 +0,0 @@
|
|||||||
import React, { useState, useCallback, Suspense } from 'react';
|
|
||||||
import { Card, Typography, Space, Button, Divider } from 'antd';
|
|
||||||
import {
|
|
||||||
UserAddOutlined,
|
|
||||||
CalendarOutlined,
|
|
||||||
FlagOutlined,
|
|
||||||
TagOutlined,
|
|
||||||
LoadingOutlined
|
|
||||||
} from '@ant-design/icons';
|
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
|
||||||
|
|
||||||
// Simulate heavy components that would normally load immediately
|
|
||||||
const HeavyAssigneeSelector = React.lazy(() =>
|
|
||||||
new Promise<{ default: React.ComponentType }>((resolve) =>
|
|
||||||
setTimeout(() => resolve({
|
|
||||||
default: () => (
|
|
||||||
<div className="p-4 border rounded-sm bg-blue-50">
|
|
||||||
<Text strong>🚀 Heavy Assignee Selector Loaded!</Text>
|
|
||||||
<br />
|
|
||||||
<Text type="secondary">This component contains:</Text>
|
|
||||||
<ul className="mt-2 text-sm">
|
|
||||||
<li>Team member search logic</li>
|
|
||||||
<li>Avatar rendering</li>
|
|
||||||
<li>Permission checking</li>
|
|
||||||
<li>Socket connections</li>
|
|
||||||
<li>Optimistic updates</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}), 1000) // Simulate 1s load time
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const HeavyDatePicker = React.lazy(() =>
|
|
||||||
new Promise<{ default: React.ComponentType }>((resolve) =>
|
|
||||||
setTimeout(() => resolve({
|
|
||||||
default: () => (
|
|
||||||
<div className="p-4 border rounded-sm bg-green-50">
|
|
||||||
<Text strong>📅 Heavy Date Picker Loaded!</Text>
|
|
||||||
<br />
|
|
||||||
<Text type="secondary">This component contains:</Text>
|
|
||||||
<ul className="mt-2 text-sm">
|
|
||||||
<li>Calendar rendering logic</li>
|
|
||||||
<li>Date validation</li>
|
|
||||||
<li>Timezone handling</li>
|
|
||||||
<li>Locale support</li>
|
|
||||||
<li>Accessibility features</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}), 800) // Simulate 0.8s load time
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const HeavyPrioritySelector = React.lazy(() =>
|
|
||||||
new Promise<{ default: React.ComponentType }>((resolve) =>
|
|
||||||
setTimeout(() => resolve({
|
|
||||||
default: () => (
|
|
||||||
<div className="p-4 border rounded-sm bg-orange-50">
|
|
||||||
<Text strong>🔥 Heavy Priority Selector Loaded!</Text>
|
|
||||||
<br />
|
|
||||||
<Text type="secondary">This component contains:</Text>
|
|
||||||
<ul className="mt-2 text-sm">
|
|
||||||
<li>Priority level logic</li>
|
|
||||||
<li>Color calculations</li>
|
|
||||||
<li>Business rules</li>
|
|
||||||
<li>Validation</li>
|
|
||||||
<li>State management</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}), 600) // Simulate 0.6s load time
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
const HeavyLabelsSelector = React.lazy(() =>
|
|
||||||
new Promise<{ default: React.ComponentType }>((resolve) =>
|
|
||||||
setTimeout(() => resolve({
|
|
||||||
default: () => (
|
|
||||||
<div className="p-4 border rounded-sm bg-purple-50">
|
|
||||||
<Text strong>🏷️ Heavy Labels Selector Loaded!</Text>
|
|
||||||
<br />
|
|
||||||
<Text type="secondary">This component contains:</Text>
|
|
||||||
<ul className="mt-2 text-sm">
|
|
||||||
<li>Label management</li>
|
|
||||||
<li>Color picker</li>
|
|
||||||
<li>Search functionality</li>
|
|
||||||
<li>CRUD operations</li>
|
|
||||||
<li>Drag & drop</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}), 700) // Simulate 0.7s load time
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Lightweight placeholder buttons (what loads immediately)
|
|
||||||
const PlaceholderButton: React.FC<{
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
onClick: () => void;
|
|
||||||
loaded?: boolean;
|
|
||||||
}> = ({ icon, label, onClick, loaded = false }) => (
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
icon={loaded ? <LoadingOutlined spin /> : icon}
|
|
||||||
onClick={onClick}
|
|
||||||
className={`${loaded ? 'border-blue-500 bg-blue-50' : ''}`}
|
|
||||||
>
|
|
||||||
{loaded ? 'Loading...' : label}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
|
|
||||||
const AsanaStyleLazyDemo: React.FC = () => {
|
|
||||||
const [loadedComponents, setLoadedComponents] = useState<{
|
|
||||||
assignee: boolean;
|
|
||||||
date: boolean;
|
|
||||||
priority: boolean;
|
|
||||||
labels: boolean;
|
|
||||||
}>({
|
|
||||||
assignee: false,
|
|
||||||
date: false,
|
|
||||||
priority: false,
|
|
||||||
labels: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const [showComponents, setShowComponents] = useState<{
|
|
||||||
assignee: boolean;
|
|
||||||
date: boolean;
|
|
||||||
priority: boolean;
|
|
||||||
labels: boolean;
|
|
||||||
}>({
|
|
||||||
assignee: false,
|
|
||||||
date: false,
|
|
||||||
priority: false,
|
|
||||||
labels: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleLoad = useCallback((component: keyof typeof loadedComponents) => {
|
|
||||||
setLoadedComponents(prev => ({ ...prev, [component]: true }));
|
|
||||||
setTimeout(() => {
|
|
||||||
setShowComponents(prev => ({ ...prev, [component]: true }));
|
|
||||||
}, 100);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const resetDemo = useCallback(() => {
|
|
||||||
setLoadedComponents({
|
|
||||||
assignee: false,
|
|
||||||
date: false,
|
|
||||||
priority: false,
|
|
||||||
labels: false,
|
|
||||||
});
|
|
||||||
setShowComponents({
|
|
||||||
assignee: false,
|
|
||||||
date: false,
|
|
||||||
priority: false,
|
|
||||||
labels: false,
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card className="max-w-4xl mx-auto">
|
|
||||||
<Title level={3}>🎯 Asana-Style Lazy Loading Demo</Title>
|
|
||||||
|
|
||||||
<div className="mb-4 p-4 bg-gray-50 rounded-sm">
|
|
||||||
<Text strong>Performance Benefits:</Text>
|
|
||||||
<ul className="mt-2 text-sm">
|
|
||||||
<li>✅ <strong>Faster Initial Load:</strong> Only lightweight placeholders load initially</li>
|
|
||||||
<li>✅ <strong>Reduced Bundle Size:</strong> Heavy components split into separate chunks</li>
|
|
||||||
<li>✅ <strong>Better UX:</strong> Instant visual feedback, components load on demand</li>
|
|
||||||
<li>✅ <strong>Memory Efficient:</strong> Components only consume memory when needed</li>
|
|
||||||
<li>✅ <strong>Network Optimized:</strong> Parallel loading of components as user interacts</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<Text strong>Task Management Components (Click to Load):</Text>
|
|
||||||
<div className="mt-2 flex gap-2 flex-wrap">
|
|
||||||
<PlaceholderButton
|
|
||||||
icon={<UserAddOutlined />}
|
|
||||||
label="Add Assignee"
|
|
||||||
onClick={() => handleLoad('assignee')}
|
|
||||||
loaded={loadedComponents.assignee && !showComponents.assignee}
|
|
||||||
/>
|
|
||||||
<PlaceholderButton
|
|
||||||
icon={<CalendarOutlined />}
|
|
||||||
label="Set Date"
|
|
||||||
onClick={() => handleLoad('date')}
|
|
||||||
loaded={loadedComponents.date && !showComponents.date}
|
|
||||||
/>
|
|
||||||
<PlaceholderButton
|
|
||||||
icon={<FlagOutlined />}
|
|
||||||
label="Set Priority"
|
|
||||||
onClick={() => handleLoad('priority')}
|
|
||||||
loaded={loadedComponents.priority && !showComponents.priority}
|
|
||||||
/>
|
|
||||||
<PlaceholderButton
|
|
||||||
icon={<TagOutlined />}
|
|
||||||
label="Add Labels"
|
|
||||||
onClick={() => handleLoad('labels')}
|
|
||||||
loaded={loadedComponents.labels && !showComponents.labels}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button onClick={resetDemo} size="small">
|
|
||||||
Reset Demo
|
|
||||||
</Button>
|
|
||||||
<Text type="secondary" className="self-center">
|
|
||||||
Components loaded: {Object.values(showComponents).filter(Boolean).length}/4
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{showComponents.assignee && (
|
|
||||||
<Suspense fallback={<div className="p-4 border rounded-sm bg-gray-100">Loading assignee selector...</div>}>
|
|
||||||
<HeavyAssigneeSelector />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showComponents.date && (
|
|
||||||
<Suspense fallback={<div className="p-4 border rounded-sm bg-gray-100">Loading date picker...</div>}>
|
|
||||||
<HeavyDatePicker />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showComponents.priority && (
|
|
||||||
<Suspense fallback={<div className="p-4 border rounded-sm bg-gray-100">Loading priority selector...</div>}>
|
|
||||||
<HeavyPrioritySelector />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{showComponents.labels && (
|
|
||||||
<Suspense fallback={<div className="p-4 border rounded-sm bg-gray-100">Loading labels selector...</div>}>
|
|
||||||
<HeavyLabelsSelector />
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Divider />
|
|
||||||
|
|
||||||
<div className="text-sm text-gray-600">
|
|
||||||
<Text strong>How it works:</Text>
|
|
||||||
<ol className="mt-2 space-y-1">
|
|
||||||
<li>1. Page loads instantly with lightweight placeholder buttons</li>
|
|
||||||
<li>2. User clicks a button to interact with a feature</li>
|
|
||||||
<li>3. Heavy component starts loading in the background</li>
|
|
||||||
<li>4. Loading state shows immediate feedback</li>
|
|
||||||
<li>5. Full component renders when ready</li>
|
|
||||||
<li>6. Subsequent interactions are instant (component cached)</li>
|
|
||||||
</ol>
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AsanaStyleLazyDemo;
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { UserAddOutlined } from '@ant-design/icons';
|
|
||||||
import { RootState } from '@/app/store';
|
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
|
||||||
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
|
||||||
import { useSocket } from '@/socket/socketContext';
|
|
||||||
import { SocketEvents } from '@/shared/socket-events';
|
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
|
||||||
import { Avatar, Button, Checkbox } from '@/components';
|
|
||||||
import { sortTeamMembers } from '@/utils/sort-team-members';
|
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|
||||||
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
|
||||||
import { ILocalSession } from '@/types/auth/session.types';
|
|
||||||
import { Socket } from 'socket.io-client';
|
|
||||||
import { DefaultEventsMap } from '@socket.io/component-emitter';
|
|
||||||
import { ThunkDispatch } from '@reduxjs/toolkit';
|
|
||||||
import { Dispatch } from 'redux';
|
|
||||||
|
|
||||||
interface AssigneeDropdownContentProps {
|
|
||||||
task: IProjectTask;
|
|
||||||
groupId?: string | null;
|
|
||||||
isDarkMode?: boolean;
|
|
||||||
projectId: string | null;
|
|
||||||
currentSession: ILocalSession | null;
|
|
||||||
socket: Socket<DefaultEventsMap, DefaultEventsMap> | null;
|
|
||||||
dispatch: ThunkDispatch<any, any, any> & Dispatch<any>;
|
|
||||||
isOpen: boolean;
|
|
||||||
onClose: () => void;
|
|
||||||
position: { top: number; left: number };
|
|
||||||
}
|
|
||||||
|
|
||||||
const AssigneeDropdownContent: React.FC<AssigneeDropdownContentProps> = ({
|
|
||||||
task,
|
|
||||||
groupId = null,
|
|
||||||
isDarkMode = false,
|
|
||||||
projectId,
|
|
||||||
currentSession,
|
|
||||||
socket,
|
|
||||||
dispatch,
|
|
||||||
isOpen,
|
|
||||||
onClose,
|
|
||||||
position,
|
|
||||||
}) => {
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
|
||||||
const [teamMembers, setTeamMembers] = useState<ITeamMembersViewModel>({ data: [], total: 0 });
|
|
||||||
const [optimisticAssignees, setOptimisticAssignees] = useState<string[]>([]);
|
|
||||||
const [pendingChanges, setPendingChanges] = useState<Set<string>>(new Set());
|
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
|
||||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const members = useSelector((state: RootState) => state.teamMembersReducer.teamMembers);
|
|
||||||
|
|
||||||
const filteredMembers = useMemo(() => {
|
|
||||||
return teamMembers?.data?.filter(member =>
|
|
||||||
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
|
||||||
);
|
|
||||||
}, [teamMembers, searchQuery]);
|
|
||||||
|
|
||||||
// Initialize team members data when component mounts
|
|
||||||
useEffect(() => {
|
|
||||||
if (isOpen) {
|
|
||||||
const assignees = task?.assignees?.map(assignee => assignee.team_member_id);
|
|
||||||
const membersData = (members?.data || []).map(member => ({
|
|
||||||
...member,
|
|
||||||
selected: assignees?.includes(member.id),
|
|
||||||
}));
|
|
||||||
const sortedMembers = sortTeamMembers(membersData);
|
|
||||||
setTeamMembers({ data: sortedMembers });
|
|
||||||
|
|
||||||
// Focus search input after opening
|
|
||||||
setTimeout(() => {
|
|
||||||
searchInputRef.current?.focus();
|
|
||||||
}, 0);
|
|
||||||
}
|
|
||||||
}, [isOpen, members, task]);
|
|
||||||
|
|
||||||
const handleMemberToggle = useCallback((memberId: string, checked: boolean) => {
|
|
||||||
if (!memberId || !projectId || !task?.id || !currentSession?.id) return;
|
|
||||||
|
|
||||||
// Add to pending changes for visual feedback
|
|
||||||
setPendingChanges(prev => new Set(prev).add(memberId));
|
|
||||||
|
|
||||||
// OPTIMISTIC UPDATE: Update local state immediately for instant UI feedback
|
|
||||||
const currentAssignees = task?.assignees?.map(a => a.team_member_id) || [];
|
|
||||||
let newAssigneeIds: string[];
|
|
||||||
|
|
||||||
if (checked) {
|
|
||||||
// Adding assignee
|
|
||||||
newAssigneeIds = [...currentAssignees, memberId];
|
|
||||||
} else {
|
|
||||||
// Removing assignee
|
|
||||||
newAssigneeIds = currentAssignees.filter(id => id !== memberId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update optimistic state for immediate UI feedback in dropdown
|
|
||||||
setOptimisticAssignees(newAssigneeIds);
|
|
||||||
|
|
||||||
// Update local team members state for dropdown UI
|
|
||||||
setTeamMembers(prev => ({
|
|
||||||
...prev,
|
|
||||||
data: (prev.data || []).map(member =>
|
|
||||||
member.id === memberId
|
|
||||||
? { ...member, selected: checked }
|
|
||||||
: member
|
|
||||||
)
|
|
||||||
}));
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
team_member_id: memberId,
|
|
||||||
project_id: projectId,
|
|
||||||
task_id: task.id,
|
|
||||||
reporter_id: currentSession.id,
|
|
||||||
mode: checked ? 0 : 1,
|
|
||||||
parent_task: task.parent_task_id,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Emit socket event - the socket handler will update Redux with proper types
|
|
||||||
socket?.emit(SocketEvents.QUICK_ASSIGNEES_UPDATE.toString(), JSON.stringify(body));
|
|
||||||
|
|
||||||
// Remove from pending changes after a short delay (optimistic)
|
|
||||||
setTimeout(() => {
|
|
||||||
setPendingChanges(prev => {
|
|
||||||
const newSet = new Set(prev);
|
|
||||||
newSet.delete(memberId);
|
|
||||||
return newSet;
|
|
||||||
});
|
|
||||||
}, 500); // Remove pending state after 500ms
|
|
||||||
}, [task, projectId, currentSession, socket]);
|
|
||||||
|
|
||||||
const checkMemberSelected = useCallback((memberId: string) => {
|
|
||||||
if (!memberId) return false;
|
|
||||||
// Use optimistic assignees if available, otherwise fall back to task assignees
|
|
||||||
const assignees = optimisticAssignees.length > 0
|
|
||||||
? optimisticAssignees
|
|
||||||
: task?.assignees?.map(assignee => assignee.team_member_id) || [];
|
|
||||||
return assignees.includes(memberId);
|
|
||||||
}, [optimisticAssignees, task]);
|
|
||||||
|
|
||||||
const handleInviteProjectMemberDrawer = useCallback(() => {
|
|
||||||
onClose(); // Close the assignee dropdown first
|
|
||||||
dispatch(toggleProjectMemberDrawer()); // Then open the invite drawer
|
|
||||||
}, [onClose, dispatch]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
ref={dropdownRef}
|
|
||||||
className={`
|
|
||||||
fixed z-9999 w-72 rounded-md shadow-lg border
|
|
||||||
${isDarkMode
|
|
||||||
? 'bg-gray-800 border-gray-600'
|
|
||||||
: 'bg-white border-gray-200'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
style={{
|
|
||||||
top: position.top,
|
|
||||||
left: position.left,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Header */}
|
|
||||||
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
|
||||||
<input
|
|
||||||
ref={searchInputRef}
|
|
||||||
type="text"
|
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
placeholder="Search members..."
|
|
||||||
className={`
|
|
||||||
w-full px-2 py-1 text-xs rounded border
|
|
||||||
${isDarkMode
|
|
||||||
? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500'
|
|
||||||
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500'
|
|
||||||
}
|
|
||||||
focus:outline-none focus:ring-1 focus:ring-blue-500
|
|
||||||
`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Members List */}
|
|
||||||
<div className="max-h-64 overflow-y-auto">
|
|
||||||
{filteredMembers && filteredMembers.length > 0 ? (
|
|
||||||
filteredMembers.map((member) => (
|
|
||||||
<div
|
|
||||||
key={member.id}
|
|
||||||
className={`
|
|
||||||
flex items-center gap-2 p-2 cursor-pointer transition-colors relative
|
|
||||||
${member.pending_invitation
|
|
||||||
? 'opacity-50 cursor-not-allowed'
|
|
||||||
: isDarkMode
|
|
||||||
? 'hover:bg-gray-700'
|
|
||||||
: 'hover:bg-gray-50'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
onClick={() => {
|
|
||||||
if (!member.pending_invitation) {
|
|
||||||
const isSelected = checkMemberSelected(member.id || '');
|
|
||||||
handleMemberToggle(member.id || '', !isSelected);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
<Checkbox
|
|
||||||
checked={checkMemberSelected(member.id || '')}
|
|
||||||
onChange={(checked) => handleMemberToggle(member.id || '', checked)}
|
|
||||||
disabled={member.pending_invitation || pendingChanges.has(member.id || '')}
|
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
/>
|
|
||||||
{pendingChanges.has(member.id || '') && (
|
|
||||||
<div className={`absolute inset-0 flex items-center justify-center ${
|
|
||||||
isDarkMode ? 'bg-gray-800/50' : 'bg-white/50'
|
|
||||||
}`}>
|
|
||||||
<div className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${
|
|
||||||
isDarkMode ? 'border-blue-400' : 'border-blue-600'
|
|
||||||
}`} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Avatar
|
|
||||||
src={member.avatar_url}
|
|
||||||
name={member.name || ''}
|
|
||||||
size={24}
|
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}>
|
|
||||||
{member.name}
|
|
||||||
</div>
|
|
||||||
<div className={`text-xs truncate ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
|
||||||
{member.email}
|
|
||||||
{member.pending_invitation && (
|
|
||||||
<span className="text-red-400 ml-1">(Pending)</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="p-4 text-center">
|
|
||||||
<div className={`text-xs ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
|
||||||
No members found
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Footer - Invite button */}
|
|
||||||
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
|
||||||
<Button
|
|
||||||
icon={<UserAddOutlined />}
|
|
||||||
type="text"
|
|
||||||
onClick={handleInviteProjectMemberDrawer}
|
|
||||||
className={`
|
|
||||||
w-full text-left justify-start
|
|
||||||
${isDarkMode
|
|
||||||
? 'text-blue-400 hover:bg-gray-700'
|
|
||||||
: 'text-blue-600 hover:bg-blue-50'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
style={{ fontSize: '12px' }}
|
|
||||||
>
|
|
||||||
Invite team member
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AssigneeDropdownContent;
|
|
||||||
@@ -1,592 +0,0 @@
|
|||||||
import React, { useEffect, useRef, useState } from 'react';
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Typography,
|
|
||||||
Dropdown,
|
|
||||||
Menu,
|
|
||||||
Popconfirm,
|
|
||||||
Tooltip,
|
|
||||||
Badge,
|
|
||||||
DeleteOutlined,
|
|
||||||
CloseOutlined,
|
|
||||||
MoreOutlined,
|
|
||||||
RetweetOutlined,
|
|
||||||
UserAddOutlined,
|
|
||||||
InboxOutlined,
|
|
||||||
TagsOutlined,
|
|
||||||
UsergroupAddOutlined,
|
|
||||||
type CheckboxChangeEvent,
|
|
||||||
type InputRef
|
|
||||||
} from './antd-imports';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { IGroupBy, fetchTaskGroups } from '@/features/tasks/tasks.slice';
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|
||||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
|
||||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
|
||||||
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
|
|
||||||
import {
|
|
||||||
evt_project_task_list_bulk_archive,
|
|
||||||
evt_project_task_list_bulk_assign_me,
|
|
||||||
evt_project_task_list_bulk_assign_members,
|
|
||||||
evt_project_task_list_bulk_change_phase,
|
|
||||||
evt_project_task_list_bulk_change_priority,
|
|
||||||
evt_project_task_list_bulk_change_status,
|
|
||||||
evt_project_task_list_bulk_delete,
|
|
||||||
evt_project_task_list_bulk_update_labels,
|
|
||||||
} from '@/shared/worklenz-analytics-events';
|
|
||||||
import {
|
|
||||||
IBulkTasksLabelsRequest,
|
|
||||||
IBulkTasksPhaseChangeRequest,
|
|
||||||
IBulkTasksPriorityChangeRequest,
|
|
||||||
IBulkTasksStatusChangeRequest,
|
|
||||||
} from '@/types/tasks/bulk-action-bar.types';
|
|
||||||
import { ITaskStatus } from '@/types/tasks/taskStatus.types';
|
|
||||||
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
|
||||||
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
|
||||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
|
||||||
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
|
||||||
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
|
||||||
import { ITaskAssignee } from '@/types/tasks/task.types';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
|
||||||
import AssigneesDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/AssigneesDropdown';
|
|
||||||
import LabelsDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown';
|
|
||||||
import { sortTeamMembers } from '@/utils/sort-team-members';
|
|
||||||
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
|
||||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
|
||||||
import alertService from '@/services/alerts/alertService';
|
|
||||||
import logger from '@/utils/errorLogger';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
|
|
||||||
interface BulkActionBarProps {
|
|
||||||
selectedTaskIds: string[];
|
|
||||||
totalSelected: number;
|
|
||||||
currentGrouping: IGroupBy;
|
|
||||||
projectId: string;
|
|
||||||
onClearSelection?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BulkActionBarContent: React.FC<BulkActionBarProps> = ({
|
|
||||||
selectedTaskIds,
|
|
||||||
totalSelected,
|
|
||||||
currentGrouping,
|
|
||||||
projectId,
|
|
||||||
onClearSelection,
|
|
||||||
}) => {
|
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const { t } = useTranslation('tasks/task-table-bulk-actions');
|
|
||||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
|
||||||
|
|
||||||
// Add permission hooks
|
|
||||||
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
|
||||||
|
|
||||||
// loading state
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [updatingLabels, setUpdatingLabels] = useState(false);
|
|
||||||
const [updatingAssignToMe, setUpdatingAssignToMe] = useState(false);
|
|
||||||
const [updatingAssignees, setUpdatingAssignees] = useState(false);
|
|
||||||
const [updatingArchive, setUpdatingArchive] = useState(false);
|
|
||||||
const [updatingDelete, setUpdatingDelete] = useState(false);
|
|
||||||
|
|
||||||
// Selectors
|
|
||||||
const { selectedTaskIdsList } = useAppSelector(state => state.bulkActionReducer);
|
|
||||||
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
|
||||||
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
|
|
||||||
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
|
|
||||||
const labelsList = useAppSelector(state => state.taskLabelsReducer.labels);
|
|
||||||
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
|
|
||||||
const archived = useAppSelector(state => state.taskReducer.archived);
|
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
|
||||||
|
|
||||||
const labelsInputRef = useRef<InputRef>(null);
|
|
||||||
const [createLabelText, setCreateLabelText] = useState<string>('');
|
|
||||||
const [teamMembersSorted, setTeamMembersSorted] = useState<ITeamMembersViewModel>({
|
|
||||||
data: [],
|
|
||||||
total: 0,
|
|
||||||
});
|
|
||||||
const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false);
|
|
||||||
const [showDrawer, setShowDrawer] = useState(false);
|
|
||||||
const [selectedLabels, setSelectedLabels] = useState<ITaskLabel[]>([]);
|
|
||||||
|
|
||||||
// Handlers
|
|
||||||
const handleChangeStatus = async (status: ITaskStatus) => {
|
|
||||||
if (!status.id || !projectId) return;
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
const body: IBulkTasksStatusChangeRequest = {
|
|
||||||
tasks: selectedTaskIds,
|
|
||||||
status_id: status.id,
|
|
||||||
};
|
|
||||||
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
|
|
||||||
if (res.done) {
|
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
|
|
||||||
dispatch(deselectAll());
|
|
||||||
dispatch(fetchTaskGroups(projectId));
|
|
||||||
onClearSelection?.();
|
|
||||||
}
|
|
||||||
for (const it of selectedTaskIds) {
|
|
||||||
const canContinue = await checkTaskDependencyStatus(it, status.id);
|
|
||||||
if (!canContinue) {
|
|
||||||
if (selectedTaskIds.length > 1) {
|
|
||||||
alertService.warning(
|
|
||||||
'Incomplete Dependencies!',
|
|
||||||
'Some tasks were not updated. Please ensure all dependent tasks are completed before proceeding.'
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
alertService.error(
|
|
||||||
'Task is not completed',
|
|
||||||
'Please complete the task dependencies before proceeding'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error changing status:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChangePriority = async (priority: ITaskPriority) => {
|
|
||||||
if (!priority.id || !projectId) return;
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const body: IBulkTasksPriorityChangeRequest = {
|
|
||||||
tasks: selectedTaskIds,
|
|
||||||
priority_id: priority.id,
|
|
||||||
};
|
|
||||||
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
|
|
||||||
if (res.done) {
|
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
|
|
||||||
dispatch(deselectAll());
|
|
||||||
dispatch(fetchTaskGroups(projectId));
|
|
||||||
onClearSelection?.();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error changing priority:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChangePhase = async (phase: ITaskPhase) => {
|
|
||||||
if (!phase.id || !projectId) return;
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
const body: IBulkTasksPhaseChangeRequest = {
|
|
||||||
tasks: selectedTaskIds,
|
|
||||||
phase_id: phase.id,
|
|
||||||
};
|
|
||||||
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
|
|
||||||
if (res.done) {
|
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
|
|
||||||
dispatch(deselectAll());
|
|
||||||
dispatch(fetchTaskGroups(projectId));
|
|
||||||
onClearSelection?.();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error changing phase:', error);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleAssignToMe = async () => {
|
|
||||||
if (!projectId) return;
|
|
||||||
try {
|
|
||||||
setUpdatingAssignToMe(true);
|
|
||||||
const body = {
|
|
||||||
tasks: selectedTaskIds,
|
|
||||||
project_id: projectId,
|
|
||||||
};
|
|
||||||
const res = await taskListBulkActionsApiService.assignToMe(body);
|
|
||||||
if (res.done) {
|
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_assign_me);
|
|
||||||
dispatch(deselectAll());
|
|
||||||
dispatch(fetchTaskGroups(projectId));
|
|
||||||
onClearSelection?.();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error assigning to me:', error);
|
|
||||||
} finally {
|
|
||||||
setUpdatingAssignToMe(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleArchive = async () => {
|
|
||||||
if (!projectId) return;
|
|
||||||
try {
|
|
||||||
setUpdatingArchive(true);
|
|
||||||
const body = {
|
|
||||||
tasks: selectedTaskIds,
|
|
||||||
project_id: projectId,
|
|
||||||
};
|
|
||||||
const res = await taskListBulkActionsApiService.archiveTasks(body, archived);
|
|
||||||
if (res.done) {
|
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_archive);
|
|
||||||
dispatch(deselectAll());
|
|
||||||
dispatch(fetchTaskGroups(projectId));
|
|
||||||
onClearSelection?.();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error archiving tasks:', error);
|
|
||||||
} finally {
|
|
||||||
setUpdatingArchive(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleChangeAssignees = async (selectedAssignees: ITeamMemberViewModel[]) => {
|
|
||||||
if (!projectId) return;
|
|
||||||
try {
|
|
||||||
setUpdatingAssignees(true);
|
|
||||||
const body = {
|
|
||||||
tasks: selectedTaskIds,
|
|
||||||
project_id: projectId,
|
|
||||||
members: selectedAssignees.map(member => ({
|
|
||||||
id: member.id,
|
|
||||||
name: member.name || member.email || 'Unknown', // Fix: Ensure name is always a string
|
|
||||||
email: member.email || '',
|
|
||||||
avatar_url: member.avatar_url,
|
|
||||||
team_member_id: member.id,
|
|
||||||
project_member_id: member.id,
|
|
||||||
})) as ITaskAssignee[],
|
|
||||||
};
|
|
||||||
const res = await taskListBulkActionsApiService.assignTasks(body);
|
|
||||||
if (res.done) {
|
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
|
|
||||||
dispatch(deselectAll());
|
|
||||||
dispatch(fetchTaskGroups(projectId));
|
|
||||||
onClearSelection?.();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error assigning tasks:', error);
|
|
||||||
} finally {
|
|
||||||
setUpdatingAssignees(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDelete = async () => {
|
|
||||||
if (!projectId) return;
|
|
||||||
try {
|
|
||||||
setUpdatingDelete(true);
|
|
||||||
const body = {
|
|
||||||
tasks: selectedTaskIds,
|
|
||||||
project_id: projectId,
|
|
||||||
};
|
|
||||||
const res = await taskListBulkActionsApiService.deleteTasks(body, projectId);
|
|
||||||
if (res.done) {
|
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_delete);
|
|
||||||
dispatch(deselectAll());
|
|
||||||
dispatch(fetchTaskGroups(projectId));
|
|
||||||
onClearSelection?.();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error deleting tasks:', error);
|
|
||||||
} finally {
|
|
||||||
setUpdatingDelete(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Menu Generators
|
|
||||||
const getChangeOptionsMenu = () => [
|
|
||||||
{
|
|
||||||
key: '1',
|
|
||||||
label: t('status'),
|
|
||||||
children: statusList.map(status => ({
|
|
||||||
key: status.id,
|
|
||||||
onClick: () => handleChangeStatus(status),
|
|
||||||
label: <Badge color={status.color_code} text={status.name} />,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '2',
|
|
||||||
label: t('priority'),
|
|
||||||
children: priorityList.map(priority => ({
|
|
||||||
key: priority.id,
|
|
||||||
onClick: () => handleChangePriority(priority),
|
|
||||||
label: <Badge color={priority.color_code} text={priority.name} />,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: '3',
|
|
||||||
label: t('phase'),
|
|
||||||
children: phaseList.map(phase => ({
|
|
||||||
key: phase.id,
|
|
||||||
onClick: () => handleChangePhase(phase),
|
|
||||||
label: <Badge color={phase.color_code} text={phase.name} />,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (members?.data && assigneeDropdownOpen) {
|
|
||||||
let sortedMembers = sortTeamMembers(members.data);
|
|
||||||
setTeamMembersSorted({ data: sortedMembers, total: members.total });
|
|
||||||
}
|
|
||||||
}, [assigneeDropdownOpen, members?.data]);
|
|
||||||
|
|
||||||
const getAssigneesMenu = () => {
|
|
||||||
return (
|
|
||||||
<AssigneesDropdown
|
|
||||||
members={teamMembersSorted?.data || []}
|
|
||||||
themeMode={themeMode}
|
|
||||||
onApply={handleChangeAssignees}
|
|
||||||
onClose={() => setAssigneeDropdownOpen(false)}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleLabelChange = (e: CheckboxChangeEvent, label: ITaskLabel) => {
|
|
||||||
if (e.target.checked) {
|
|
||||||
setSelectedLabels(prev => [...prev, label]);
|
|
||||||
} else {
|
|
||||||
setSelectedLabels(prev => prev.filter(l => l.id !== label.id));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyLabels = async () => {
|
|
||||||
if (!projectId) return;
|
|
||||||
try {
|
|
||||||
setUpdatingLabels(true);
|
|
||||||
const body: IBulkTasksLabelsRequest = {
|
|
||||||
tasks: selectedTaskIds,
|
|
||||||
labels: selectedLabels,
|
|
||||||
text:
|
|
||||||
selectedLabels.length > 0
|
|
||||||
? null
|
|
||||||
: createLabelText.trim() !== ''
|
|
||||||
? createLabelText.trim()
|
|
||||||
: null,
|
|
||||||
};
|
|
||||||
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
|
|
||||||
if (res.done) {
|
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
|
|
||||||
dispatch(deselectAll());
|
|
||||||
dispatch(fetchTaskGroups(projectId));
|
|
||||||
dispatch(fetchLabels()); // Fallback: refetch all labels
|
|
||||||
setCreateLabelText('');
|
|
||||||
setSelectedLabels([]);
|
|
||||||
onClearSelection?.();
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error updating labels:', error);
|
|
||||||
} finally {
|
|
||||||
setUpdatingLabels(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const labelsDropdownContent = (
|
|
||||||
<LabelsDropdown
|
|
||||||
labelsList={labelsList}
|
|
||||||
themeMode={themeMode}
|
|
||||||
createLabelText={createLabelText}
|
|
||||||
selectedLabels={selectedLabels}
|
|
||||||
labelsInputRef={labelsInputRef as React.RefObject<InputRef>}
|
|
||||||
onLabelChange={handleLabelChange}
|
|
||||||
onCreateLabelTextChange={value => setCreateLabelText(value)}
|
|
||||||
onApply={applyLabels}
|
|
||||||
t={t}
|
|
||||||
loading={updatingLabels}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const onAssigneeDropdownOpenChange = (open: boolean) => {
|
|
||||||
setAssigneeDropdownOpen(open);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buttonStyle = {
|
|
||||||
background: 'transparent',
|
|
||||||
color: '#fff',
|
|
||||||
border: 'none',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
padding: '4px 8px',
|
|
||||||
height: '32px',
|
|
||||||
fontSize: '16px',
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
style={{
|
|
||||||
position: 'fixed',
|
|
||||||
bottom: '30px',
|
|
||||||
left: '50%',
|
|
||||||
transform: 'translateX(-50%)',
|
|
||||||
zIndex: 1000,
|
|
||||||
background: '#252628',
|
|
||||||
borderRadius: '25px',
|
|
||||||
padding: '8px 16px',
|
|
||||||
boxShadow: '0 0 0 1px #434343, 0 4px 12px 0 rgba(0, 0, 0, 0.15)',
|
|
||||||
display: 'flex',
|
|
||||||
alignItems: 'center',
|
|
||||||
gap: '8px',
|
|
||||||
minWidth: 'fit-content',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Text style={{ color: '#fff', fontSize: '14px', fontWeight: 500, marginRight: '8px' }}>
|
|
||||||
{totalSelected} task{totalSelected > 1 ? 's' : ''} selected
|
|
||||||
</Text>
|
|
||||||
|
|
||||||
{/* Status/Priority/Phase Change */}
|
|
||||||
<Tooltip title="Change Status/Priority/Phase">
|
|
||||||
<Dropdown menu={{ items: getChangeOptionsMenu() }} trigger={['click']}>
|
|
||||||
<Button
|
|
||||||
icon={<RetweetOutlined />}
|
|
||||||
style={buttonStyle}
|
|
||||||
size="small"
|
|
||||||
loading={loading}
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Labels */}
|
|
||||||
<Tooltip title="Add Labels">
|
|
||||||
<Dropdown
|
|
||||||
dropdownRender={() => labelsDropdownContent}
|
|
||||||
placement="top"
|
|
||||||
arrow
|
|
||||||
trigger={['click']}
|
|
||||||
onOpenChange={value => {
|
|
||||||
if (!value) {
|
|
||||||
setSelectedLabels([]);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon={<TagsOutlined />}
|
|
||||||
style={buttonStyle}
|
|
||||||
size="small"
|
|
||||||
loading={updatingLabels}
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Assign to Me */}
|
|
||||||
<Tooltip title="Assign to Me">
|
|
||||||
<Button
|
|
||||||
icon={<UserAddOutlined />}
|
|
||||||
style={buttonStyle}
|
|
||||||
size="small"
|
|
||||||
onClick={handleAssignToMe}
|
|
||||||
loading={updatingAssignToMe}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Assign Members */}
|
|
||||||
<Tooltip title="Assign Members">
|
|
||||||
<Dropdown
|
|
||||||
dropdownRender={getAssigneesMenu}
|
|
||||||
open={assigneeDropdownOpen}
|
|
||||||
onOpenChange={onAssigneeDropdownOpenChange}
|
|
||||||
placement="top"
|
|
||||||
arrow
|
|
||||||
trigger={['click']}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon={<UsergroupAddOutlined />}
|
|
||||||
style={buttonStyle}
|
|
||||||
size="small"
|
|
||||||
loading={updatingAssignees}
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Archive */}
|
|
||||||
<Tooltip title={archived ? 'Unarchive' : 'Archive'}>
|
|
||||||
<Button
|
|
||||||
icon={<InboxOutlined />}
|
|
||||||
style={buttonStyle}
|
|
||||||
size="small"
|
|
||||||
onClick={handleArchive}
|
|
||||||
loading={updatingArchive}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Delete */}
|
|
||||||
<Tooltip title="Delete">
|
|
||||||
<Popconfirm
|
|
||||||
title={`Delete ${totalSelected} task${totalSelected > 1 ? 's' : ''}?`}
|
|
||||||
description="This action cannot be undone."
|
|
||||||
onConfirm={handleDelete}
|
|
||||||
okText="Delete"
|
|
||||||
cancelText="Cancel"
|
|
||||||
okType="danger"
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon={<DeleteOutlined />}
|
|
||||||
style={buttonStyle}
|
|
||||||
size="small"
|
|
||||||
loading={updatingDelete}
|
|
||||||
/>
|
|
||||||
</Popconfirm>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* More Actions - Only for Owner/Admin */}
|
|
||||||
{isOwnerOrAdmin && (
|
|
||||||
<Tooltip title="More Actions">
|
|
||||||
<Dropdown
|
|
||||||
trigger={['click']}
|
|
||||||
menu={{
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
key: 'createTemplate',
|
|
||||||
label: 'Create task template',
|
|
||||||
onClick: () => setShowDrawer(true),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
icon={<MoreOutlined />}
|
|
||||||
style={buttonStyle}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Dropdown>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Clear Selection */}
|
|
||||||
<Tooltip title="Clear Selection">
|
|
||||||
<Button
|
|
||||||
icon={<CloseOutlined />}
|
|
||||||
onClick={onClearSelection}
|
|
||||||
style={buttonStyle}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{/* Task Template Drawer */}
|
|
||||||
{createPortal(
|
|
||||||
<TaskTemplateDrawer
|
|
||||||
showDrawer={showDrawer}
|
|
||||||
selectedTemplateId={null}
|
|
||||||
onClose={() => {
|
|
||||||
setShowDrawer(false);
|
|
||||||
dispatch(deselectAll());
|
|
||||||
onClearSelection?.();
|
|
||||||
}}
|
|
||||||
/>,
|
|
||||||
document.body,
|
|
||||||
'create-task-template'
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const BulkActionBar: React.FC<BulkActionBarProps> = (props) => {
|
|
||||||
// Render the bulk action bar through a portal to avoid suspense issues
|
|
||||||
return createPortal(
|
|
||||||
<BulkActionBarContent {...props} />,
|
|
||||||
document.body,
|
|
||||||
'bulk-action-bar'
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default BulkActionBar;
|
|
||||||
@@ -1,149 +1,40 @@
|
|||||||
/* DRAG AND DROP PERFORMANCE OPTIMIZATIONS */
|
/* MINIMAL DRAG AND DROP CSS - SHOW ONLY TASK NAME */
|
||||||
|
|
||||||
/* Force GPU acceleration for all drag operations */
|
/* Basic drag handle styling */
|
||||||
[data-dnd-draggable],
|
|
||||||
[data-dnd-drag-handle],
|
|
||||||
[data-dnd-overlay] {
|
|
||||||
transform: translateZ(0);
|
|
||||||
will-change: transform;
|
|
||||||
backface-visibility: hidden;
|
|
||||||
perspective: 1000px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optimize drag handle for instant response */
|
|
||||||
.drag-handle-optimized {
|
.drag-handle-optimized {
|
||||||
cursor: grab;
|
cursor: grab;
|
||||||
user-select: none;
|
opacity: 0.6;
|
||||||
touch-action: none;
|
transition: opacity 0.2s ease;
|
||||||
-webkit-user-select: none;
|
}
|
||||||
-webkit-touch-callout: none;
|
|
||||||
-webkit-tap-highlight-color: transparent;
|
.drag-handle-optimized:hover {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drag-handle-optimized:active {
|
.drag-handle-optimized:active {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Disable all transitions during drag for instant response */
|
/* Simple drag overlay - just show task name */
|
||||||
[data-dnd-dragging="true"] *,
|
|
||||||
[data-dnd-dragging="true"] {
|
|
||||||
transition: none !important;
|
|
||||||
animation: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optimize drag overlay for smooth movement */
|
|
||||||
[data-dnd-overlay] {
|
[data-dnd-overlay] {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
position: fixed !important;
|
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
transform: translateZ(0);
|
|
||||||
will-change: transform;
|
|
||||||
backface-visibility: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Reduce layout thrashing during drag */
|
/* Dark mode support for drag overlay */
|
||||||
.task-row-dragging {
|
.dark [data-dnd-overlay],
|
||||||
contain: layout style paint;
|
[data-theme="dark"] [data-dnd-overlay] {
|
||||||
will-change: transform;
|
background: #1f1f1f;
|
||||||
transform: translateZ(0);
|
border-color: #404040;
|
||||||
|
color: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Optimize virtualized lists during drag */
|
/* Hide drag handle during drag */
|
||||||
.react-window-list {
|
[data-dnd-dragging="true"] .drag-handle-optimized {
|
||||||
contain: layout style;
|
opacity: 0;
|
||||||
will-change: scroll-position;
|
|
||||||
}
|
|
||||||
|
|
||||||
.react-window-list-item {
|
|
||||||
contain: layout style;
|
|
||||||
will-change: transform;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Disable hover effects during drag */
|
|
||||||
[data-dnd-dragging="true"] .task-row:hover {
|
|
||||||
background-color: inherit !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optimize cursor changes */
|
|
||||||
.task-row {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-row[data-dnd-dragging="true"] {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Performance optimizations for large lists */
|
|
||||||
.virtualized-task-container {
|
|
||||||
contain: layout style paint;
|
|
||||||
will-change: scroll-position;
|
|
||||||
transform: translateZ(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reduce repaints during scroll */
|
|
||||||
.task-groups-container {
|
|
||||||
contain: layout style;
|
|
||||||
will-change: scroll-position;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optimize sortable context */
|
|
||||||
[data-dnd-sortable-context] {
|
|
||||||
contain: layout style;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Disable animations during drag operations */
|
|
||||||
[data-dnd-context] [data-dnd-dragging="true"] * {
|
|
||||||
transition: none !important;
|
|
||||||
animation: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optimize drop indicators */
|
|
||||||
.drop-indicator {
|
|
||||||
contain: layout style;
|
|
||||||
will-change: opacity;
|
|
||||||
transition: opacity 0.1s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Performance optimizations for touch devices */
|
|
||||||
@media (pointer: coarse) {
|
|
||||||
.drag-handle-optimized {
|
|
||||||
min-height: 44px;
|
|
||||||
min-width: 44px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode optimizations */
|
|
||||||
.dark [data-dnd-dragging="true"],
|
|
||||||
[data-theme="dark"] [data-dnd-dragging="true"] {
|
|
||||||
background-color: rgba(255, 255, 255, 0.05) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Reduce memory usage during drag */
|
|
||||||
[data-dnd-dragging="true"] img,
|
|
||||||
[data-dnd-dragging="true"] svg {
|
|
||||||
contain: layout style paint;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optimize for high DPI displays */
|
|
||||||
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi) {
|
|
||||||
[data-dnd-overlay] {
|
|
||||||
transform: translateZ(0) scale(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Disable text selection during drag */
|
|
||||||
[data-dnd-dragging="true"] {
|
|
||||||
-webkit-user-select: none;
|
|
||||||
-moz-user-select: none;
|
|
||||||
-ms-user-select: none;
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Optimize for reduced motion preferences */
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
|
||||||
[data-dnd-overlay],
|
|
||||||
[data-dnd-dragging="true"] {
|
|
||||||
transition: none !important;
|
|
||||||
animation: none !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { Select, Typography } from 'antd';
|
|
||||||
import { IGroupBy } from '@/features/tasks/tasks.slice';
|
|
||||||
import { IGroupByOption } from '@/types/tasks/taskList.types';
|
|
||||||
|
|
||||||
const { Text } = Typography;
|
|
||||||
const { Option } = Select;
|
|
||||||
|
|
||||||
interface GroupingSelectorProps {
|
|
||||||
currentGrouping: IGroupBy;
|
|
||||||
onChange: (groupBy: IGroupBy) => void;
|
|
||||||
options: IGroupByOption[];
|
|
||||||
disabled?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const GroupingSelector: React.FC<GroupingSelectorProps> = ({
|
|
||||||
currentGrouping,
|
|
||||||
onChange,
|
|
||||||
options,
|
|
||||||
disabled = false,
|
|
||||||
}) => {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Text className="text-sm text-gray-600">Group by:</Text>
|
|
||||||
<Select
|
|
||||||
value={currentGrouping}
|
|
||||||
onChange={onChange}
|
|
||||||
disabled={disabled}
|
|
||||||
size="small"
|
|
||||||
style={{ minWidth: 100 }}
|
|
||||||
className="capitalize"
|
|
||||||
>
|
|
||||||
{options.map((option) => (
|
|
||||||
<Option key={option.value} value={option.value} className="capitalize">
|
|
||||||
{option.label}
|
|
||||||
</Option>
|
|
||||||
))}
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default GroupingSelector;
|
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
InboxOutlined,
|
InboxOutlined,
|
||||||
CheckOutlined,
|
CheckOutlined,
|
||||||
} from './antd-imports';
|
} from '@/shared/antd-imports';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
@@ -56,6 +56,12 @@ import {
|
|||||||
setBoardLabels,
|
setBoardLabels,
|
||||||
} from '@/features/board/board-slice';
|
} from '@/features/board/board-slice';
|
||||||
|
|
||||||
|
// Import ConfigPhaseButton and CreateStatusButton components
|
||||||
|
import ConfigPhaseButton from '@/features/projects/singleProject/phase/ConfigPhaseButton';
|
||||||
|
import CreateStatusButton from '@/components/project-task-filters/create-status-button/create-status-button';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import useIsProjectManager from '@/hooks/useIsProjectManager';
|
||||||
|
|
||||||
// Performance constants
|
// Performance constants
|
||||||
const FILTER_DEBOUNCE_DELAY = 300; // ms
|
const FILTER_DEBOUNCE_DELAY = 300; // ms
|
||||||
const SEARCH_DEBOUNCE_DELAY = 500; // ms
|
const SEARCH_DEBOUNCE_DELAY = 500; // ms
|
||||||
@@ -324,6 +330,10 @@ const FilterDropdown: React.FC<{
|
|||||||
isDarkMode: boolean;
|
isDarkMode: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
}> = ({ section, onSelectionChange, isOpen, onToggle, themeClasses, isDarkMode, className = '' }) => {
|
}> = ({ section, onSelectionChange, isOpen, onToggle, themeClasses, isDarkMode, className = '' }) => {
|
||||||
|
// Add permission checks for groupBy section
|
||||||
|
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||||
|
const isProjectManager = useIsProjectManager();
|
||||||
|
const canConfigure = isOwnerOrAdmin || isProjectManager;
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [filteredOptions, setFilteredOptions] = useState(section.options);
|
const [filteredOptions, setFilteredOptions] = useState(section.options);
|
||||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -412,6 +422,14 @@ const FilterDropdown: React.FC<{
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Configuration Buttons for GroupBy section */}
|
||||||
|
{section.id === 'groupBy' && canConfigure && (
|
||||||
|
<div className="inline-flex items-center gap-1 ml-2">
|
||||||
|
{section.selectedValues[0] === 'phase' && <ConfigPhaseButton />}
|
||||||
|
{section.selectedValues[0] === 'status' && <CreateStatusButton />}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Dropdown Panel */}
|
{/* Dropdown Panel */}
|
||||||
{isOpen && (
|
{isOpen && (
|
||||||
<div className={`absolute top-full left-0 z-50 mt-1 w-64 ${themeClasses.dropdownBg} rounded-md shadow-sm border ${themeClasses.dropdownBorder}`}>
|
<div className={`absolute top-full left-0 z-50 mt-1 w-64 ${themeClasses.dropdownBg} rounded-md shadow-sm border ${themeClasses.dropdownBorder}`}>
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
import React, { useState, useCallback, Suspense } from 'react';
|
|
||||||
import { CalendarOutlined } from '@ant-design/icons';
|
|
||||||
import { formatDate } from '@/utils/date-time';
|
|
||||||
|
|
||||||
// Lazy load the DatePicker component only when needed
|
|
||||||
const LazyDatePicker = React.lazy(() =>
|
|
||||||
import('antd/es/date-picker').then(module => ({ default: module.default }))
|
|
||||||
);
|
|
||||||
|
|
||||||
interface LazyDatePickerProps {
|
|
||||||
value?: string | null;
|
|
||||||
onChange?: (date: string | null) => void;
|
|
||||||
placeholder?: string;
|
|
||||||
isDarkMode?: boolean;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lightweight loading placeholder
|
|
||||||
const DateLoadingPlaceholder: React.FC<{ isDarkMode: boolean; value?: string | null; placeholder?: string }> = ({
|
|
||||||
isDarkMode,
|
|
||||||
value,
|
|
||||||
placeholder
|
|
||||||
}) => (
|
|
||||||
<div
|
|
||||||
className={`
|
|
||||||
flex items-center gap-1 px-2 py-1 text-xs rounded border cursor-pointer
|
|
||||||
transition-colors duration-200 animate-pulse min-w-[80px]
|
|
||||||
${isDarkMode
|
|
||||||
? 'border-gray-600 bg-gray-800 text-gray-400'
|
|
||||||
: 'border-gray-300 bg-gray-100 text-gray-600'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
>
|
|
||||||
<CalendarOutlined className="text-xs" />
|
|
||||||
<span>{value ? formatDate(value) : (placeholder || 'Select date')}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
const LazyDatePickerWrapper: React.FC<LazyDatePickerProps> = ({
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
placeholder = 'Select date',
|
|
||||||
isDarkMode = false,
|
|
||||||
className = ''
|
|
||||||
}) => {
|
|
||||||
const [hasLoadedOnce, setHasLoadedOnce] = useState(false);
|
|
||||||
|
|
||||||
const handleInteraction = useCallback((e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
|
|
||||||
if (!hasLoadedOnce) {
|
|
||||||
setHasLoadedOnce(true);
|
|
||||||
}
|
|
||||||
}, [hasLoadedOnce]);
|
|
||||||
|
|
||||||
// If not loaded yet, show a simple placeholder
|
|
||||||
if (!hasLoadedOnce) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
onClick={handleInteraction}
|
|
||||||
onMouseEnter={handleInteraction} // Preload on hover
|
|
||||||
className={`
|
|
||||||
flex items-center gap-1 px-2 py-1 text-xs rounded border cursor-pointer
|
|
||||||
transition-colors duration-200 min-w-[80px] ${className}
|
|
||||||
${isDarkMode
|
|
||||||
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
|
|
||||||
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
|
||||||
}
|
|
||||||
`}
|
|
||||||
title="Select date"
|
|
||||||
>
|
|
||||||
<CalendarOutlined className="text-xs" />
|
|
||||||
<span>{value ? formatDate(value) : placeholder}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Once loaded, show the full DatePicker
|
|
||||||
return (
|
|
||||||
<Suspense
|
|
||||||
fallback={
|
|
||||||
<DateLoadingPlaceholder
|
|
||||||
isDarkMode={isDarkMode}
|
|
||||||
value={value}
|
|
||||||
placeholder={placeholder}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<LazyDatePicker
|
|
||||||
value={value ? new Date(value) : null}
|
|
||||||
onChange={(date) => onChange?.(date ? date.toISOString() : null)}
|
|
||||||
placeholder={placeholder}
|
|
||||||
className={className}
|
|
||||||
size="small"
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LazyDatePickerWrapper;
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useCallback, useState, useEffect } from 'react';
|
import React, { useMemo, useCallback, useState, useEffect, useRef } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
@@ -19,12 +19,23 @@ import {
|
|||||||
TagsOutlined,
|
TagsOutlined,
|
||||||
UsergroupAddOutlined,
|
UsergroupAddOutlined,
|
||||||
FlagOutlined,
|
FlagOutlined,
|
||||||
BulbOutlined
|
BulbOutlined,
|
||||||
|
MoreOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { selectTasks } from '@/features/projects/bulkActions/bulkActionSlice';
|
||||||
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
|
import { useBulkActionTranslations } from '@/hooks/useTranslationPreloader';
|
||||||
|
import LabelsDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/LabelsDropdown';
|
||||||
|
import AssigneesDropdown from '@/components/taskListCommon/task-list-bulk-actions-bar/components/AssigneesDropdown';
|
||||||
|
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||||
|
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
||||||
|
import { InputRef } from 'antd/es/input';
|
||||||
|
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||||
|
import TaskTemplateDrawer from '@/components/task-templates/task-template-drawer';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -138,13 +149,17 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
onBulkExport,
|
onBulkExport,
|
||||||
onBulkSetDueDate,
|
onBulkSetDueDate,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation('tasks/task-table-bulk-actions');
|
const { t, ready, isLoading } = useBulkActionTranslations();
|
||||||
|
const dispatch = useDispatch();
|
||||||
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
const isDarkMode = useSelector((state: RootState) => state.themeReducer?.mode === 'dark');
|
||||||
|
|
||||||
// Get data from Redux store
|
// Get data from Redux store
|
||||||
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
const statusList = useAppSelector(state => state.taskStatusReducer.status);
|
||||||
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
|
const priorityList = useAppSelector(state => state.priorityReducer.priorities);
|
||||||
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
|
const phaseList = useAppSelector(state => state.phaseReducer.phaseList);
|
||||||
|
const labelsList = useAppSelector(state => state.taskLabelsReducer.labels);
|
||||||
|
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
|
||||||
|
const tasks = useAppSelector(state => state.taskManagement.entities);
|
||||||
|
|
||||||
// Performance state management
|
// Performance state management
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
const [isVisible, setIsVisible] = useState(false);
|
||||||
@@ -162,6 +177,20 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
dueDate: false,
|
dueDate: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Labels dropdown state
|
||||||
|
const [selectedLabels, setSelectedLabels] = useState<ITaskLabel[]>([]);
|
||||||
|
const [createLabelText, setCreateLabelText] = useState<string>('');
|
||||||
|
const labelsInputRef = useRef<InputRef>(null);
|
||||||
|
|
||||||
|
// Assignees dropdown state
|
||||||
|
const [assigneeDropdownOpen, setAssigneeDropdownOpen] = useState(false);
|
||||||
|
|
||||||
|
// Task template state
|
||||||
|
const [showDrawer, setShowDrawer] = useState(false);
|
||||||
|
|
||||||
|
// Auth service for permissions
|
||||||
|
const isOwnerOrAdmin = useAuthService().isOwnerOrAdmin();
|
||||||
|
|
||||||
// Smooth entrance animation
|
// Smooth entrance animation
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (totalSelected > 0) {
|
if (totalSelected > 0) {
|
||||||
@@ -200,6 +229,8 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
})), [phaseList]
|
})), [phaseList]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Menu click handlers
|
// Menu click handlers
|
||||||
const handleStatusMenuClick = useCallback((e: any) => {
|
const handleStatusMenuClick = useCallback((e: any) => {
|
||||||
onBulkStatusChange?.(e.key);
|
onBulkStatusChange?.(e.key);
|
||||||
@@ -213,6 +244,126 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
onBulkPhaseChange?.(e.key);
|
onBulkPhaseChange?.(e.key);
|
||||||
}, [onBulkPhaseChange]);
|
}, [onBulkPhaseChange]);
|
||||||
|
|
||||||
|
const handleLabelsMenuClick = useCallback((e: any) => {
|
||||||
|
onBulkAddLabels?.([e.key]);
|
||||||
|
}, [onBulkAddLabels]);
|
||||||
|
|
||||||
|
// Labels dropdown handlers
|
||||||
|
const handleLabelChange = useCallback((e: CheckboxChangeEvent, label: ITaskLabel) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
setSelectedLabels(prev => [...prev, label]);
|
||||||
|
} else {
|
||||||
|
setSelectedLabels(prev => prev.filter(l => l.id !== label.id));
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleApplyLabels = useCallback(async () => {
|
||||||
|
if (!projectId) return;
|
||||||
|
try {
|
||||||
|
updateLoadingState('labels', true);
|
||||||
|
const body = {
|
||||||
|
tasks: selectedTaskIds,
|
||||||
|
labels: selectedLabels,
|
||||||
|
text: selectedLabels.length > 0 ? null : createLabelText.trim() !== '' ? createLabelText.trim() : null,
|
||||||
|
};
|
||||||
|
await onBulkAddLabels?.(selectedLabels.map(l => l.id).filter((id): id is string => id !== undefined));
|
||||||
|
setCreateLabelText('');
|
||||||
|
setSelectedLabels([]);
|
||||||
|
} catch (error) {
|
||||||
|
// Error handling is done in the parent component
|
||||||
|
} finally {
|
||||||
|
updateLoadingState('labels', false);
|
||||||
|
}
|
||||||
|
}, [selectedLabels, createLabelText, selectedTaskIds, projectId, onBulkAddLabels, updateLoadingState]);
|
||||||
|
|
||||||
|
// Assignees dropdown handlers
|
||||||
|
const handleChangeAssignees = useCallback(async (selectedAssignees: ITeamMemberViewModel[]) => {
|
||||||
|
if (!projectId) return;
|
||||||
|
try {
|
||||||
|
updateLoadingState('assignMembers', true);
|
||||||
|
await onBulkAssignMembers?.(selectedAssignees.map(m => m.id).filter((id): id is string => id !== undefined));
|
||||||
|
} catch (error) {
|
||||||
|
// Error handling is done in the parent component
|
||||||
|
} finally {
|
||||||
|
updateLoadingState('assignMembers', false);
|
||||||
|
}
|
||||||
|
}, [projectId, onBulkAssignMembers, updateLoadingState]);
|
||||||
|
|
||||||
|
const onAssigneeDropdownOpenChange = useCallback((open: boolean) => {
|
||||||
|
setAssigneeDropdownOpen(open);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Get selected task objects for template creation
|
||||||
|
const selectedTaskObjects = useMemo(() => {
|
||||||
|
return Object.values(tasks).filter((task: any) => selectedTaskIds.includes(task.id));
|
||||||
|
}, [tasks, selectedTaskIds]);
|
||||||
|
|
||||||
|
// Update Redux state when opening template drawer
|
||||||
|
const handleOpenTemplateDrawer = useCallback(() => {
|
||||||
|
// Convert Task objects to IProjectTask format for template creation
|
||||||
|
const projectTasks: IProjectTask[] = selectedTaskObjects.map((task: any) => ({
|
||||||
|
id: task.id,
|
||||||
|
name: task.title, // Always use title as the name
|
||||||
|
task_key: task.task_key,
|
||||||
|
status: task.status,
|
||||||
|
status_id: task.status,
|
||||||
|
priority: task.priority,
|
||||||
|
phase_id: task.phase,
|
||||||
|
phase_name: task.phase,
|
||||||
|
description: task.description,
|
||||||
|
start_date: task.startDate,
|
||||||
|
end_date: task.dueDate,
|
||||||
|
total_hours: task.timeTracking?.estimated || 0,
|
||||||
|
total_minutes: task.timeTracking?.logged || 0,
|
||||||
|
progress: task.progress,
|
||||||
|
sub_tasks_count: task.sub_tasks_count || 0,
|
||||||
|
assignees: task.assignees?.map((assigneeId: string) => ({
|
||||||
|
id: assigneeId,
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
avatar_url: '',
|
||||||
|
team_member_id: assigneeId,
|
||||||
|
project_member_id: assigneeId,
|
||||||
|
})) || [],
|
||||||
|
labels: task.labels || [],
|
||||||
|
manual_progress: false,
|
||||||
|
created_at: task.createdAt,
|
||||||
|
updated_at: task.updatedAt,
|
||||||
|
sort_order: task.order,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update the bulkActionReducer with selected tasks
|
||||||
|
dispatch(selectTasks(projectTasks));
|
||||||
|
setShowDrawer(true);
|
||||||
|
}, [selectedTaskObjects, dispatch]);
|
||||||
|
|
||||||
|
// Labels dropdown content
|
||||||
|
const labelsDropdownContent = useMemo(() => (
|
||||||
|
<LabelsDropdown
|
||||||
|
labelsList={labelsList || []}
|
||||||
|
themeMode={isDarkMode ? 'dark' : 'light'}
|
||||||
|
createLabelText={createLabelText}
|
||||||
|
selectedLabels={selectedLabels}
|
||||||
|
labelsInputRef={labelsInputRef as React.RefObject<InputRef>}
|
||||||
|
onLabelChange={handleLabelChange}
|
||||||
|
onCreateLabelTextChange={setCreateLabelText}
|
||||||
|
onApply={handleApplyLabels}
|
||||||
|
t={t}
|
||||||
|
loading={loadingStates.labels}
|
||||||
|
/>
|
||||||
|
), [labelsList, isDarkMode, createLabelText, selectedLabels, handleLabelChange, handleApplyLabels, t, loadingStates.labels]);
|
||||||
|
|
||||||
|
// Assignees dropdown content
|
||||||
|
const assigneesDropdownContent = useMemo(() => (
|
||||||
|
<AssigneesDropdown
|
||||||
|
members={members?.data || []}
|
||||||
|
themeMode={isDarkMode ? 'dark' : 'light'}
|
||||||
|
onApply={handleChangeAssignees}
|
||||||
|
onClose={() => setAssigneeDropdownOpen(false)}
|
||||||
|
t={t}
|
||||||
|
/>
|
||||||
|
), [members?.data, isDarkMode, handleChangeAssignees, t]);
|
||||||
|
|
||||||
// Memoized handlers with loading states
|
// Memoized handlers with loading states
|
||||||
const handleStatusChange = useCallback(async () => {
|
const handleStatusChange = useCallback(async () => {
|
||||||
updateLoadingState('status', true);
|
updateLoadingState('status', true);
|
||||||
@@ -324,6 +475,11 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
whiteSpace: 'nowrap' as const,
|
whiteSpace: 'nowrap' as const,
|
||||||
}), [isDarkMode]);
|
}), [isDarkMode]);
|
||||||
|
|
||||||
|
// Don't render until translations are ready to prevent Suspense
|
||||||
|
if (!ready || isLoading) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (!totalSelected || Number(totalSelected) < 1) {
|
if (!totalSelected || Number(totalSelected) < 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -461,13 +617,41 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
||||||
{/* Change Labels */}
|
{/* Change Labels */}
|
||||||
<ActionButton
|
<Tooltip title={t('ADD_LABELS')} placement="top">
|
||||||
icon={<TagsOutlined />}
|
<Dropdown
|
||||||
tooltip={t('ADD_LABELS')}
|
dropdownRender={() => labelsDropdownContent}
|
||||||
onClick={() => onBulkAddLabels?.([])}
|
trigger={['click']}
|
||||||
loading={loadingStates.labels}
|
placement="top"
|
||||||
isDarkMode={isDarkMode}
|
arrow
|
||||||
/>
|
onOpenChange={(open) => {
|
||||||
|
if (!open) {
|
||||||
|
setSelectedLabels([]);
|
||||||
|
setCreateLabelText('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<TagsOutlined />}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
color: isDarkMode ? '#e5e7eb' : '#374151',
|
||||||
|
border: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '6px',
|
||||||
|
height: '32px',
|
||||||
|
width: '32px',
|
||||||
|
fontSize: '14px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'all 0.15s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
loading={loadingStates.labels}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* Assign to Me */}
|
{/* Assign to Me */}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -479,13 +663,37 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Change Assignees */}
|
{/* Change Assignees */}
|
||||||
<ActionButton
|
<Tooltip title={t('ASSIGN_MEMBERS')} placement="top">
|
||||||
icon={<UsergroupAddOutlined />}
|
<Dropdown
|
||||||
tooltip={t('ASSIGN_MEMBERS')}
|
dropdownRender={() => assigneesDropdownContent}
|
||||||
onClick={() => onBulkAssignMembers?.([])}
|
open={assigneeDropdownOpen}
|
||||||
loading={loadingStates.assignMembers}
|
onOpenChange={onAssigneeDropdownOpenChange}
|
||||||
isDarkMode={isDarkMode}
|
trigger={['click']}
|
||||||
/>
|
placement="top"
|
||||||
|
arrow
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<UsergroupAddOutlined />}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
color: isDarkMode ? '#e5e7eb' : '#374151',
|
||||||
|
border: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '6px',
|
||||||
|
height: '32px',
|
||||||
|
width: '32px',
|
||||||
|
fontSize: '14px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'all 0.15s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
loading={loadingStates.assignMembers}
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{/* Archive */}
|
{/* Archive */}
|
||||||
<ActionButton
|
<ActionButton
|
||||||
@@ -515,6 +723,46 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
/>
|
/>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
|
|
||||||
|
{/* More Options (Create Task Template) - Only for owners/admins */}
|
||||||
|
{isOwnerOrAdmin && (
|
||||||
|
<Tooltip title={t('moreOptions')} placement="top">
|
||||||
|
<Dropdown
|
||||||
|
trigger={['click']}
|
||||||
|
menu={{
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
key: '1',
|
||||||
|
label: t('createTaskTemplate'),
|
||||||
|
onClick: handleOpenTemplateDrawer,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
placement="top"
|
||||||
|
arrow
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
icon={<MoreOutlined />}
|
||||||
|
style={{
|
||||||
|
background: 'transparent',
|
||||||
|
color: isDarkMode ? '#e5e7eb' : '#374151',
|
||||||
|
border: 'none',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
padding: '6px',
|
||||||
|
height: '32px',
|
||||||
|
width: '32px',
|
||||||
|
fontSize: '14px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
transition: 'all 0.15s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||||
|
}}
|
||||||
|
size="small"
|
||||||
|
type="text"
|
||||||
|
/>
|
||||||
|
</Dropdown>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
|
||||||
<Divider
|
<Divider
|
||||||
type="vertical"
|
type="vertical"
|
||||||
style={{
|
style={{
|
||||||
@@ -532,6 +780,20 @@ const OptimizedBulkActionBarContent: React.FC<OptimizedBulkActionBarProps> = Rea
|
|||||||
isDarkMode={isDarkMode}
|
isDarkMode={isDarkMode}
|
||||||
/>
|
/>
|
||||||
</Space>
|
</Space>
|
||||||
|
|
||||||
|
{/* Task Template Drawer */}
|
||||||
|
{createPortal(
|
||||||
|
<TaskTemplateDrawer
|
||||||
|
showDrawer={showDrawer}
|
||||||
|
selectedTemplateId={null}
|
||||||
|
onClose={() => {
|
||||||
|
setShowDrawer(false);
|
||||||
|
onClearSelection?.();
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
document.body,
|
||||||
|
'create-task-template'
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -540,7 +802,6 @@ OptimizedBulkActionBarContent.displayName = 'OptimizedBulkActionBarContent';
|
|||||||
|
|
||||||
// Portal wrapper for performance isolation
|
// Portal wrapper for performance isolation
|
||||||
const OptimizedBulkActionBar: React.FC<OptimizedBulkActionBarProps> = React.memo((props) => {
|
const OptimizedBulkActionBar: React.FC<OptimizedBulkActionBarProps> = React.memo((props) => {
|
||||||
console.log('BulkActionBar totalSelected:', props.totalSelected, typeof props.totalSelected);
|
|
||||||
if (!props.totalSelected || Number(props.totalSelected) < 1) {
|
if (!props.totalSelected || Number(props.totalSelected) < 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
RightOutlined,
|
RightOutlined,
|
||||||
DownOutlined
|
DownOutlined
|
||||||
} from './antd-imports';
|
} from '@/shared/antd-imports';
|
||||||
import { TaskGroup as TaskGroupType, Task } from '@/types/task-management.types';
|
import { TaskGroup as TaskGroupType, Task } from '@/types/task-management.types';
|
||||||
import { taskManagementSelectors } from '@/features/task-management/task-management.slice';
|
import { taskManagementSelectors } from '@/features/task-management/task-management.slice';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
@@ -409,7 +409,7 @@ const TaskGroup: React.FC<TaskGroupProps> = React.memo(({
|
|||||||
|
|
||||||
.task-group-header-text {
|
.task-group-header-text {
|
||||||
color: white !important;
|
color: white !important;
|
||||||
font-size: 13px !important;
|
font-size: 14px !important;
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTaskManagementTranslations } from '@/hooks/useTranslationPreloader';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
@@ -21,6 +21,7 @@ import {
|
|||||||
reorderTasks,
|
reorderTasks,
|
||||||
moveTaskToGroup,
|
moveTaskToGroup,
|
||||||
optimisticTaskMove,
|
optimisticTaskMove,
|
||||||
|
reorderTasksInGroup,
|
||||||
setLoading,
|
setLoading,
|
||||||
fetchTasks,
|
fetchTasks,
|
||||||
fetchTasksV3,
|
fetchTasksV3,
|
||||||
@@ -37,8 +38,15 @@ import {
|
|||||||
toggleTaskSelection,
|
toggleTaskSelection,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
} from '@/features/task-management/selection.slice';
|
} from '@/features/task-management/selection.slice';
|
||||||
|
import {
|
||||||
|
selectTaskIds,
|
||||||
|
selectTasks,
|
||||||
|
deselectAll as deselectAllBulk,
|
||||||
|
} from '@/features/projects/bulkActions/bulkActionSlice';
|
||||||
import { Task } from '@/types/task-management.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
import TaskRow from './task-row';
|
import TaskRow from './task-row';
|
||||||
// import BulkActionBar from './bulk-action-bar';
|
// import BulkActionBar from './bulk-action-bar';
|
||||||
import OptimizedBulkActionBar from './optimized-bulk-action-bar';
|
import OptimizedBulkActionBar from './optimized-bulk-action-bar';
|
||||||
@@ -46,7 +54,6 @@ import OptimizedBulkActionBar from './optimized-bulk-action-bar';
|
|||||||
import VirtualizedTaskList from './virtualized-task-list';
|
import VirtualizedTaskList from './virtualized-task-list';
|
||||||
import { AppDispatch } from '@/app/store';
|
import { AppDispatch } from '@/app/store';
|
||||||
import { shallowEqual } from 'react-redux';
|
import { shallowEqual } from 'react-redux';
|
||||||
import { deselectAll } from '@/features/projects/bulkActions/bulkActionSlice';
|
|
||||||
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
|
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
|
||||||
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
import {
|
import {
|
||||||
@@ -70,6 +77,7 @@ import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
|||||||
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||||
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
||||||
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||||
import alertService from '@/services/alerts/alertService';
|
import alertService from '@/services/alerts/alertService';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
@@ -121,7 +129,7 @@ const throttle = <T extends (...args: any[]) => void>(func: T, delay: number): T
|
|||||||
|
|
||||||
const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = '' }) => {
|
const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = '' }) => {
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
const dispatch = useDispatch<AppDispatch>();
|
||||||
const { t } = useTranslation('task-management');
|
const { t, ready, isLoading } = useTaskManagementTranslations();
|
||||||
const { trackMixpanelEvent } = useMixpanelTracking();
|
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||||
const [dragState, setDragState] = useState<DragState>({
|
const [dragState, setDragState] = useState<DragState>({
|
||||||
activeTask: null,
|
activeTask: null,
|
||||||
@@ -136,7 +144,6 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const renderCountRef = useRef(0);
|
const renderCountRef = useRef(0);
|
||||||
const [shouldThrottle, setShouldThrottle] = useState(false);
|
const [shouldThrottle, setShouldThrottle] = useState(false);
|
||||||
|
|
||||||
|
|
||||||
// Refs for performance optimization
|
// Refs for performance optimization
|
||||||
const dragOverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const dragOverTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -144,14 +151,19 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
// Enable real-time socket updates for task changes
|
// Enable real-time socket updates for task changes
|
||||||
useTaskSocketHandlers();
|
useTaskSocketHandlers();
|
||||||
|
|
||||||
|
// Socket connection for drag and drop
|
||||||
|
const { socket, connected } = useSocket();
|
||||||
|
|
||||||
// Redux selectors using V3 API (pre-processed data, minimal loops)
|
// Redux selectors using V3 API (pre-processed data, minimal loops)
|
||||||
const tasks = useSelector(taskManagementSelectors.selectAll);
|
const tasks = useSelector(taskManagementSelectors.selectAll);
|
||||||
const taskGroups = useSelector(selectTaskGroupsV3, shallowEqual);
|
const taskGroups = useSelector(selectTaskGroupsV3, shallowEqual);
|
||||||
const currentGrouping = useSelector(selectCurrentGroupingV3, shallowEqual);
|
const currentGrouping = useSelector(selectCurrentGroupingV3, shallowEqual);
|
||||||
const selectedTaskIds = useSelector(selectSelectedTaskIds);
|
// Use bulk action slice for selected tasks instead of selection slice
|
||||||
|
const selectedTaskIds = useSelector((state: RootState) => state.bulkActionReducer.selectedTaskIdsList);
|
||||||
|
const selectedTasks = useSelector((state: RootState) => state.bulkActionReducer.selectedTasks);
|
||||||
const loading = useSelector((state: RootState) => state.taskManagement.loading, shallowEqual);
|
const loading = useSelector((state: RootState) => state.taskManagement.loading, shallowEqual);
|
||||||
const error = useSelector((state: RootState) => state.taskManagement.error);
|
const error = useSelector((state: RootState) => state.taskManagement.error);
|
||||||
|
|
||||||
// Bulk action selectors
|
// Bulk action selectors
|
||||||
const statusList = useSelector((state: RootState) => state.taskStatusReducer.status);
|
const statusList = useSelector((state: RootState) => state.taskStatusReducer.status);
|
||||||
const priorityList = useSelector((state: RootState) => state.priorityReducer.priorities);
|
const priorityList = useSelector((state: RootState) => state.priorityReducer.priorities);
|
||||||
@@ -221,7 +233,6 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
// Memoized calculations - optimized
|
// Memoized calculations - optimized
|
||||||
const totalTasks = useMemo(() => {
|
const totalTasks = useMemo(() => {
|
||||||
const total = taskGroups.reduce((sum, g) => sum + g.taskIds.length, 0);
|
const total = taskGroups.reduce((sum, g) => sum + g.taskIds.length, 0);
|
||||||
console.log(`[TASK-LIST-BOARD] Total tasks in groups: ${total}, Total tasks in store: ${tasks.length}, Groups: ${taskGroups.length}`);
|
|
||||||
return total;
|
return total;
|
||||||
}, [taskGroups, tasks.length]);
|
}, [taskGroups, tasks.length]);
|
||||||
|
|
||||||
@@ -235,8 +246,12 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
[dispatch]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add isDragging state
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
const handleDragStart = useCallback(
|
const handleDragStart = useCallback(
|
||||||
(event: DragStartEvent) => {
|
(event: DragStartEvent) => {
|
||||||
|
setIsDragging(true);
|
||||||
const { active } = event;
|
const { active } = event;
|
||||||
const taskId = active.id as string;
|
const taskId = active.id as string;
|
||||||
|
|
||||||
@@ -245,13 +260,12 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
let activeGroupId: string | null = null;
|
let activeGroupId: string | null = null;
|
||||||
|
|
||||||
if (activeTask) {
|
if (activeTask) {
|
||||||
// Determine group ID based on current grouping
|
// Find which group contains this task by looking through all groups
|
||||||
if (currentGrouping === 'status') {
|
for (const group of taskGroups) {
|
||||||
activeGroupId = `status-${activeTask.status}`;
|
if (group.taskIds.includes(taskId)) {
|
||||||
} else if (currentGrouping === 'priority') {
|
activeGroupId = group.id;
|
||||||
activeGroupId = `priority-${activeTask.priority}`;
|
break;
|
||||||
} else if (currentGrouping === 'phase') {
|
}
|
||||||
activeGroupId = `phase-${activeTask.phase}`;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +274,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
activeGroupId,
|
activeGroupId,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[tasks, currentGrouping]
|
[tasks, currentGrouping, taskGroups]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Throttled drag over handler for smoother performance
|
// Throttled drag over handler for smoother performance
|
||||||
@@ -271,15 +285,14 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
if (!over || !dragState.activeTask) return;
|
if (!over || !dragState.activeTask) return;
|
||||||
|
|
||||||
const activeTaskId = active.id as string;
|
const activeTaskId = active.id as string;
|
||||||
const overContainer = over.id as string;
|
const overId = over.id as string;
|
||||||
|
|
||||||
// PERFORMANCE OPTIMIZATION: Immediate response for instant UX
|
// Check if we're hovering over a task or a group container
|
||||||
// Only update if we're hovering over a different container
|
const targetTask = tasks.find(t => t.id === overId);
|
||||||
const targetTask = tasks.find(t => t.id === overContainer);
|
let targetGroupId = overId;
|
||||||
let targetGroupId = overContainer;
|
|
||||||
|
|
||||||
if (targetTask) {
|
if (targetTask) {
|
||||||
// PERFORMANCE OPTIMIZATION: Use switch instead of multiple if statements
|
// We're hovering over a task, determine its group
|
||||||
switch (currentGrouping) {
|
switch (currentGrouping) {
|
||||||
case 'status':
|
case 'status':
|
||||||
targetGroupId = `status-${targetTask.status}`;
|
targetGroupId = `status-${targetTask.status}`;
|
||||||
@@ -292,29 +305,13 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetGroupId !== dragState.activeGroupId) {
|
|
||||||
// PERFORMANCE OPTIMIZATION: Use findIndex for better performance
|
|
||||||
const targetGroupIndex = taskGroups.findIndex(g => g.id === targetGroupId);
|
|
||||||
if (targetGroupIndex !== -1) {
|
|
||||||
const targetGroup = taskGroups[targetGroupIndex];
|
|
||||||
dispatch(
|
|
||||||
optimisticTaskMove({
|
|
||||||
taskId: activeTaskId,
|
|
||||||
newGroupId: targetGroupId,
|
|
||||||
newIndex: targetGroup.taskIds.length,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, 16), // 60fps throttling for smooth performance
|
}, 16), // 60fps throttling for smooth performance
|
||||||
[dragState, tasks, taskGroups, currentGrouping, dispatch]
|
[dragState, tasks, taskGroups, currentGrouping]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
(event: DragEndEvent) => {
|
(event: DragEndEvent) => {
|
||||||
const { active, over } = event;
|
setIsDragging(false);
|
||||||
|
|
||||||
// Clear any pending drag over timeouts
|
// Clear any pending drag over timeouts
|
||||||
if (dragOverTimeoutRef.current) {
|
if (dragOverTimeoutRef.current) {
|
||||||
clearTimeout(dragOverTimeoutRef.current);
|
clearTimeout(dragOverTimeoutRef.current);
|
||||||
@@ -328,36 +325,27 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
activeGroupId: null,
|
activeGroupId: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!over || !currentDragState.activeTask || !currentDragState.activeGroupId) {
|
if (!event.over || !currentDragState.activeTask || !currentDragState.activeGroupId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const { active, over } = event;
|
||||||
const activeTaskId = active.id as string;
|
const activeTaskId = active.id as string;
|
||||||
const overContainer = over.id as string;
|
const overId = over.id as string;
|
||||||
|
|
||||||
// Parse the group ID to get group type and value - optimized
|
// Determine target group and position
|
||||||
const parseGroupId = (groupId: string) => {
|
let targetGroupId = overId;
|
||||||
const [groupType, ...groupValueParts] = groupId.split('-');
|
|
||||||
return {
|
|
||||||
groupType: groupType as 'status' | 'priority' | 'phase',
|
|
||||||
groupValue: groupValueParts.join('-'),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
// Determine target group
|
|
||||||
let targetGroupId = overContainer;
|
|
||||||
let targetIndex = -1;
|
let targetIndex = -1;
|
||||||
|
|
||||||
// Check if dropping on a task or a group
|
// Check if dropping on a task or a group
|
||||||
const targetTask = tasks.find(t => t.id === overContainer);
|
const targetTask = tasks.find(t => t.id === overId);
|
||||||
if (targetTask) {
|
if (targetTask) {
|
||||||
// Dropping on a task, determine its group
|
// Dropping on a task, find which group contains this task
|
||||||
if (currentGrouping === 'status') {
|
for (const group of taskGroups) {
|
||||||
targetGroupId = `status-${targetTask.status}`;
|
if (group.taskIds.includes(targetTask.id)) {
|
||||||
} else if (currentGrouping === 'priority') {
|
targetGroupId = group.id;
|
||||||
targetGroupId = `priority-${targetTask.priority}`;
|
break;
|
||||||
} else if (currentGrouping === 'phase') {
|
}
|
||||||
targetGroupId = `phase-${targetTask.phase}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the index of the target task within its group
|
// Find the index of the target task within its group
|
||||||
@@ -365,23 +353,15 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
if (targetGroup) {
|
if (targetGroup) {
|
||||||
targetIndex = targetGroup.taskIds.indexOf(targetTask.id);
|
targetIndex = targetGroup.taskIds.indexOf(targetTask.id);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Dropping on a group container, add to the end
|
||||||
|
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
||||||
|
if (targetGroup) {
|
||||||
|
targetIndex = targetGroup.taskIds.length;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sourceGroupInfo = parseGroupId(currentDragState.activeGroupId);
|
// Find source and target groups
|
||||||
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 sourceGroup = taskGroups.find(g => g.id === currentDragState.activeGroupId);
|
||||||
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
const targetGroup = taskGroups.find(g => g.id === targetGroupId);
|
||||||
|
|
||||||
@@ -391,34 +371,92 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
|
|
||||||
// Only reorder if actually moving to a different position
|
// Only reorder if actually moving to a different position
|
||||||
if (sourceGroup.id !== targetGroup.id || sourceIndex !== finalTargetIndex) {
|
if (sourceGroup.id !== targetGroup.id || sourceIndex !== finalTargetIndex) {
|
||||||
// Calculate new order values - simplified
|
// Use the new reorderTasksInGroup action that properly handles group arrays
|
||||||
const allTasksInTargetGroup = targetGroup.taskIds.map(
|
|
||||||
(id: string) => tasks.find((t: any) => 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(
|
dispatch(
|
||||||
reorderTasks({
|
reorderTasksInGroup({
|
||||||
taskIds: [activeTaskId, ...allTasksInTargetGroup.map((t: any) => t.id)],
|
taskId: activeTaskId,
|
||||||
newOrder: [currentDragState.activeTask!.order, ...newOrder],
|
fromGroupId: currentDragState.activeGroupId,
|
||||||
|
toGroupId: targetGroupId,
|
||||||
|
fromIndex: sourceIndex,
|
||||||
|
toIndex: finalTargetIndex,
|
||||||
|
groupType: targetGroup.groupType,
|
||||||
|
groupValue: targetGroup.groupValue,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Emit socket event to backend
|
||||||
|
if (connected && socket && currentDragState.activeTask) {
|
||||||
|
const currentSession = JSON.parse(localStorage.getItem('session') || '{}');
|
||||||
|
|
||||||
|
const socketData = {
|
||||||
|
from_index: sourceIndex,
|
||||||
|
to_index: finalTargetIndex,
|
||||||
|
to_last_index: finalTargetIndex >= targetGroup.taskIds.length,
|
||||||
|
from_group: currentDragState.activeGroupId,
|
||||||
|
to_group: targetGroupId,
|
||||||
|
group_by: currentGrouping,
|
||||||
|
project_id: projectId,
|
||||||
|
task: currentDragState.activeTask,
|
||||||
|
team_id: currentSession.team_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), socketData);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[dragState, tasks, taskGroups, currentGrouping, dispatch]
|
[dragState, tasks, taskGroups, currentGrouping, dispatch, connected, socket, projectId]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleSelectTask = useCallback(
|
const handleSelectTask = useCallback(
|
||||||
(taskId: string, selected: boolean) => {
|
(taskId: string, selected: boolean) => {
|
||||||
dispatch(toggleTaskSelection(taskId));
|
if (selected) {
|
||||||
|
// Add task to bulk selection
|
||||||
|
const task = tasks.find(t => t.id === taskId);
|
||||||
|
if (task) {
|
||||||
|
// Convert Task to IProjectTask format for bulk actions
|
||||||
|
const projectTask: IProjectTask = {
|
||||||
|
id: task.id,
|
||||||
|
name: task.title, // Always use title as the name
|
||||||
|
task_key: task.task_key,
|
||||||
|
status: task.status,
|
||||||
|
status_id: task.status,
|
||||||
|
priority: task.priority,
|
||||||
|
phase_id: task.phase,
|
||||||
|
phase_name: task.phase,
|
||||||
|
description: task.description,
|
||||||
|
start_date: task.startDate,
|
||||||
|
end_date: task.dueDate,
|
||||||
|
total_hours: task.timeTracking.estimated || 0,
|
||||||
|
total_minutes: task.timeTracking.logged || 0,
|
||||||
|
progress: task.progress,
|
||||||
|
sub_tasks_count: task.sub_tasks_count || 0,
|
||||||
|
assignees: task.assignees.map(assigneeId => ({
|
||||||
|
id: assigneeId,
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
avatar_url: '',
|
||||||
|
team_member_id: assigneeId,
|
||||||
|
project_member_id: assigneeId,
|
||||||
|
})),
|
||||||
|
labels: task.labels,
|
||||||
|
manual_progress: false, // Default value for Task type
|
||||||
|
created_at: task.createdAt,
|
||||||
|
updated_at: task.updatedAt,
|
||||||
|
sort_order: task.order,
|
||||||
|
};
|
||||||
|
dispatch(selectTasks([...selectedTasks, projectTask]));
|
||||||
|
dispatch(selectTaskIds([...selectedTaskIds, taskId]));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Remove task from bulk selection
|
||||||
|
const updatedTasks = selectedTasks.filter(t => t.id !== taskId);
|
||||||
|
const updatedTaskIds = selectedTaskIds.filter(id => id !== taskId);
|
||||||
|
dispatch(selectTasks(updatedTasks));
|
||||||
|
dispatch(selectTaskIds(updatedTaskIds));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[dispatch]
|
[dispatch, selectedTasks, selectedTaskIds, tasks]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleToggleSubtasks = useCallback((taskId: string) => {
|
const handleToggleSubtasks = useCallback((taskId: string) => {
|
||||||
@@ -443,7 +481,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
|
|
||||||
// Bulk action handlers - implementing real functionality from task-list-bulk-actions-bar
|
// Bulk action handlers - implementing real functionality from task-list-bulk-actions-bar
|
||||||
const handleClearSelection = useCallback(() => {
|
const handleClearSelection = useCallback(() => {
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
|
||||||
@@ -481,7 +519,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
|
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
|
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
dispatch(fetchTasksV3(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
}
|
}
|
||||||
@@ -503,7 +541,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
|
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
|
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
dispatch(fetchTasksV3(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
}
|
}
|
||||||
@@ -525,7 +563,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
|
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
|
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
dispatch(fetchTasksV3(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
}
|
}
|
||||||
@@ -544,7 +582,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const res = await taskListBulkActionsApiService.assignToMe(body);
|
const res = await taskListBulkActionsApiService.assignToMe(body);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_assign_me);
|
trackMixpanelEvent(evt_project_task_list_bulk_assign_me);
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
dispatch(fetchTasksV3(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
}
|
}
|
||||||
@@ -576,7 +614,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const res = await taskListBulkActionsApiService.assignTasks(body);
|
const res = await taskListBulkActionsApiService.assignTasks(body);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
|
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
dispatch(fetchTasksV3(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
}
|
}
|
||||||
@@ -601,7 +639,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
|
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
|
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
dispatch(fetchTasksV3(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
dispatch(fetchLabels());
|
dispatch(fetchLabels());
|
||||||
@@ -621,7 +659,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const res = await taskListBulkActionsApiService.archiveTasks(body, archived);
|
const res = await taskListBulkActionsApiService.archiveTasks(body, archived);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_archive);
|
trackMixpanelEvent(evt_project_task_list_bulk_archive);
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
dispatch(fetchTasksV3(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
}
|
}
|
||||||
@@ -640,7 +678,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const res = await taskListBulkActionsApiService.deleteTasks(body, projectId);
|
const res = await taskListBulkActionsApiService.deleteTasks(body, projectId);
|
||||||
if (res.done) {
|
if (res.done) {
|
||||||
trackMixpanelEvent(evt_project_task_list_bulk_delete);
|
trackMixpanelEvent(evt_project_task_list_bulk_delete);
|
||||||
dispatch(deselectAll());
|
dispatch(deselectAllBulk());
|
||||||
dispatch(clearSelection());
|
dispatch(clearSelection());
|
||||||
dispatch(fetchTasksV3(projectId));
|
dispatch(fetchTasksV3(projectId));
|
||||||
}
|
}
|
||||||
@@ -652,17 +690,14 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
// Additional handlers for new actions
|
// Additional handlers for new actions
|
||||||
const handleBulkDuplicate = useCallback(async () => {
|
const handleBulkDuplicate = useCallback(async () => {
|
||||||
// This would need to be implemented in the API service
|
// This would need to be implemented in the API service
|
||||||
console.log('Bulk duplicate not yet implemented in API:', selectedTaskIds);
|
|
||||||
}, [selectedTaskIds]);
|
}, [selectedTaskIds]);
|
||||||
|
|
||||||
const handleBulkExport = useCallback(async () => {
|
const handleBulkExport = useCallback(async () => {
|
||||||
// This would need to be implemented in the API service
|
// This would need to be implemented in the API service
|
||||||
console.log('Bulk export not yet implemented in API:', selectedTaskIds);
|
|
||||||
}, [selectedTaskIds]);
|
}, [selectedTaskIds]);
|
||||||
|
|
||||||
const handleBulkSetDueDate = useCallback(async (date: string) => {
|
const handleBulkSetDueDate = useCallback(async (date: string) => {
|
||||||
// This would need to be implemented in the API service
|
// This would need to be implemented in the API service
|
||||||
console.log('Bulk set due date not yet implemented in API:', date, selectedTaskIds);
|
|
||||||
}, [selectedTaskIds]);
|
}, [selectedTaskIds]);
|
||||||
|
|
||||||
// Cleanup effect
|
// Cleanup effect
|
||||||
@@ -674,6 +709,17 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Don't render until translations are ready to prevent Suspense
|
||||||
|
if (!ready || isLoading) {
|
||||||
|
return (
|
||||||
|
<Card className={className}>
|
||||||
|
<div className="flex justify-center items-center py-8">
|
||||||
|
<Spin size="large" />
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
return (
|
return (
|
||||||
<Card className={className}>
|
<Card className={className}>
|
||||||
@@ -690,24 +736,19 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
onDragStart={handleDragStart}
|
onDragStart={handleDragStart}
|
||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
|
autoScroll={false}
|
||||||
>
|
>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
{/* Task Filters */}
|
{/* Task Filters */}
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<ImprovedTaskFilters position="list" />
|
<ImprovedTaskFilters position="list" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Performance Analysis - Only show in development */}
|
{/* Performance Analysis - Only show in development */}
|
||||||
{/* {process.env.NODE_ENV === 'development' && (
|
{/* {process.env.NODE_ENV === 'development' && (
|
||||||
<PerformanceAnalysis projectId={projectId} />
|
<PerformanceAnalysis projectId={projectId} />
|
||||||
)} */}
|
)} */}
|
||||||
|
|
||||||
{/* Fixed Height Task Groups Container - Asana Style */}
|
{/* Fixed Height Task Groups Container - Asana Style */}
|
||||||
<div className="task-groups-container-fixed">
|
<div className="task-groups-container-fixed">
|
||||||
<div className="task-groups-scrollable">
|
<div className={`task-groups-scrollable${isDragging ? ' lock-scroll' : ''}`}>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="loading-container">
|
<div className="loading-container">
|
||||||
<div className="flex justify-center items-center py-8">
|
<div className="flex justify-center items-center py-8">
|
||||||
@@ -776,14 +817,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
|
|
||||||
<DragOverlay
|
<DragOverlay
|
||||||
adjustScale={false}
|
adjustScale={false}
|
||||||
dropAnimation={{
|
dropAnimation={null}
|
||||||
duration: 200,
|
|
||||||
easing: 'cubic-bezier(0.18, 0.67, 0.6, 1.22)',
|
|
||||||
}}
|
|
||||||
style={{
|
|
||||||
cursor: 'grabbing',
|
|
||||||
zIndex: 9999,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{dragOverlayContent}
|
{dragOverlayContent}
|
||||||
</DragOverlay>
|
</DragOverlay>
|
||||||
@@ -815,7 +849,6 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
min-height: 400px;
|
min-height: 400px;
|
||||||
max-height: calc(100vh - 120px);
|
max-height: calc(100vh - 120px);
|
||||||
position: relative;
|
position: relative;
|
||||||
border: 1px solid var(--task-border-primary, #e8e8e8);
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: var(--task-bg-primary, white);
|
background: var(--task-bg-primary, white);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -939,7 +972,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
|
|
||||||
.task-group-header-text {
|
.task-group-header-text {
|
||||||
color: white !important;
|
color: white !important;
|
||||||
font-size: 13px !important;
|
font-size: 14px !important;
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
}
|
}
|
||||||
@@ -1279,6 +1312,10 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
.react-window-list-item {
|
.react-window-list-item {
|
||||||
contain: layout style;
|
contain: layout style;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-groups-scrollable.lock-scroll {
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
`}</style>
|
`}</style>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,283 +0,0 @@
|
|||||||
/**
|
|
||||||
* Example: Task Row Component using Centralized Ant Design Imports
|
|
||||||
*
|
|
||||||
* This file demonstrates how to migrate from direct antd imports to the centralized import system.
|
|
||||||
*
|
|
||||||
* BEFORE (Direct imports):
|
|
||||||
* import { Input, Typography, DatePicker } from 'antd';
|
|
||||||
* import type { InputRef } from 'antd';
|
|
||||||
*
|
|
||||||
* AFTER (Centralized imports):
|
|
||||||
* import { Input, Typography, DatePicker, type InputRef, dayjs, taskManagementAntdConfig } from './antd-imports';
|
|
||||||
*/
|
|
||||||
|
|
||||||
import React, { useState, useCallback, useMemo } from 'react';
|
|
||||||
import {
|
|
||||||
Input,
|
|
||||||
Typography,
|
|
||||||
DatePicker,
|
|
||||||
Button,
|
|
||||||
Select,
|
|
||||||
Tooltip,
|
|
||||||
Badge,
|
|
||||||
Space,
|
|
||||||
Checkbox,
|
|
||||||
UserOutlined,
|
|
||||||
CalendarOutlined,
|
|
||||||
ClockCircleOutlined,
|
|
||||||
EditOutlined,
|
|
||||||
MoreOutlined,
|
|
||||||
dayjs,
|
|
||||||
taskManagementAntdConfig,
|
|
||||||
taskMessage,
|
|
||||||
type InputRef,
|
|
||||||
type DatePickerProps,
|
|
||||||
type Dayjs
|
|
||||||
} from './antd-imports';
|
|
||||||
|
|
||||||
// Your existing task type import
|
|
||||||
import { Task } from '@/types/task-management.types';
|
|
||||||
|
|
||||||
interface TaskRowExampleProps {
|
|
||||||
task: Task;
|
|
||||||
projectId: string;
|
|
||||||
isDarkMode?: boolean;
|
|
||||||
onTaskUpdate?: (taskId: string, updates: Partial<Task>) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TaskRowExample: React.FC<TaskRowExampleProps> = ({
|
|
||||||
task,
|
|
||||||
projectId,
|
|
||||||
isDarkMode = false,
|
|
||||||
onTaskUpdate
|
|
||||||
}) => {
|
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
|
||||||
const [editingField, setEditingField] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Use centralized config for consistent DatePicker props
|
|
||||||
const datePickerProps = useMemo(() => ({
|
|
||||||
...taskManagementAntdConfig.datePickerDefaults,
|
|
||||||
className: "w-full bg-transparent border-none shadow-none"
|
|
||||||
}), []);
|
|
||||||
|
|
||||||
// Use centralized config for consistent Button props
|
|
||||||
const buttonProps = useMemo(() => ({
|
|
||||||
...taskManagementAntdConfig.taskButtonDefaults,
|
|
||||||
icon: <EditOutlined />
|
|
||||||
}), []);
|
|
||||||
|
|
||||||
// Handle date changes with centralized message system
|
|
||||||
const handleDateChange = useCallback((date: Dayjs | null, field: 'startDate' | 'dueDate') => {
|
|
||||||
if (onTaskUpdate) {
|
|
||||||
onTaskUpdate(task.id, {
|
|
||||||
[field]: date?.toISOString() || null
|
|
||||||
});
|
|
||||||
taskMessage.success(`${field === 'startDate' ? 'Start' : 'Due'} date updated`);
|
|
||||||
}
|
|
||||||
}, [task.id, onTaskUpdate]);
|
|
||||||
|
|
||||||
// Handle task title edit
|
|
||||||
const handleTitleEdit = useCallback((newTitle: string) => {
|
|
||||||
if (onTaskUpdate && newTitle.trim() !== task.title) {
|
|
||||||
onTaskUpdate(task.id, { title: newTitle.trim() });
|
|
||||||
taskMessage.success('Task title updated');
|
|
||||||
}
|
|
||||||
setIsEditing(false);
|
|
||||||
}, [task.id, task.title, onTaskUpdate]);
|
|
||||||
|
|
||||||
// Memoized date values for performance
|
|
||||||
const startDateValue = useMemo(() =>
|
|
||||||
task.startDate ? dayjs(task.startDate) : undefined,
|
|
||||||
[task.startDate]
|
|
||||||
);
|
|
||||||
|
|
||||||
const dueDateValue = useMemo(() =>
|
|
||||||
task.dueDate ? dayjs(task.dueDate) : undefined,
|
|
||||||
[task.dueDate]
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={`task-row-example ${isDarkMode ? 'dark' : 'light'}`}>
|
|
||||||
<div className="task-row-content">
|
|
||||||
|
|
||||||
{/* Task Selection Checkbox */}
|
|
||||||
<div className="task-cell">
|
|
||||||
<Checkbox
|
|
||||||
onChange={(e) => {
|
|
||||||
// Handle selection logic here
|
|
||||||
console.log('Task selected:', e.target.checked);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Task Title */}
|
|
||||||
<div className="task-cell task-title">
|
|
||||||
{isEditing ? (
|
|
||||||
<Input
|
|
||||||
{...taskManagementAntdConfig.taskInputDefaults}
|
|
||||||
defaultValue={task.title}
|
|
||||||
autoFocus
|
|
||||||
onPressEnter={(e) => handleTitleEdit(e.currentTarget.value)}
|
|
||||||
onBlur={(e) => handleTitleEdit(e.currentTarget.value)}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Space>
|
|
||||||
<Typography.Text
|
|
||||||
className="task-title-text"
|
|
||||||
onClick={() => setIsEditing(true)}
|
|
||||||
>
|
|
||||||
{task.title}
|
|
||||||
</Typography.Text>
|
|
||||||
<Button
|
|
||||||
{...buttonProps}
|
|
||||||
onClick={() => setIsEditing(true)}
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Task Progress */}
|
|
||||||
<div className="task-cell">
|
|
||||||
<Badge
|
|
||||||
count={`${task.progress || 0}%`}
|
|
||||||
color={task.progress === 100 ? 'green' : 'blue'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Task Assignees */}
|
|
||||||
<div className="task-cell">
|
|
||||||
<Space>
|
|
||||||
<UserOutlined />
|
|
||||||
<Typography.Text>
|
|
||||||
{task.assignee_names?.join(', ') || 'Unassigned'}
|
|
||||||
</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Start Date */}
|
|
||||||
<div className="task-cell">
|
|
||||||
<Tooltip
|
|
||||||
{...taskManagementAntdConfig.tooltipDefaults}
|
|
||||||
title="Start Date"
|
|
||||||
>
|
|
||||||
<DatePicker
|
|
||||||
{...datePickerProps}
|
|
||||||
value={startDateValue}
|
|
||||||
onChange={(date) => handleDateChange(date, 'startDate')}
|
|
||||||
placeholder="Start Date"
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Due Date */}
|
|
||||||
<div className="task-cell">
|
|
||||||
<Tooltip
|
|
||||||
{...taskManagementAntdConfig.tooltipDefaults}
|
|
||||||
title="Due Date"
|
|
||||||
>
|
|
||||||
<DatePicker
|
|
||||||
{...datePickerProps}
|
|
||||||
value={dueDateValue}
|
|
||||||
onChange={(date) => handleDateChange(date, 'dueDate')}
|
|
||||||
placeholder="Due Date"
|
|
||||||
disabledDate={(current) =>
|
|
||||||
startDateValue ? current.isBefore(startDateValue, 'day') : false
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Task Status */}
|
|
||||||
<div className="task-cell">
|
|
||||||
<Select
|
|
||||||
{...taskManagementAntdConfig.taskSelectDefaults}
|
|
||||||
value={task.status}
|
|
||||||
placeholder="Status"
|
|
||||||
onChange={(value) => {
|
|
||||||
if (onTaskUpdate) {
|
|
||||||
onTaskUpdate(task.id, { status: value });
|
|
||||||
taskMessage.success('Status updated');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{ label: 'To Do', value: 'todo' },
|
|
||||||
{ label: 'In Progress', value: 'in_progress' },
|
|
||||||
{ label: 'Done', value: 'done' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Task Priority */}
|
|
||||||
<div className="task-cell">
|
|
||||||
<Select
|
|
||||||
{...taskManagementAntdConfig.taskSelectDefaults}
|
|
||||||
value={task.priority}
|
|
||||||
placeholder="Priority"
|
|
||||||
onChange={(value) => {
|
|
||||||
if (onTaskUpdate) {
|
|
||||||
onTaskUpdate(task.id, { priority: value });
|
|
||||||
taskMessage.success('Priority updated');
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
options={[
|
|
||||||
{ label: 'Low', value: 'low' },
|
|
||||||
{ label: 'Medium', value: 'medium' },
|
|
||||||
{ label: 'High', value: 'high' },
|
|
||||||
{ label: 'Critical', value: 'critical' },
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Time Tracking */}
|
|
||||||
<div className="task-cell">
|
|
||||||
<Space>
|
|
||||||
<ClockCircleOutlined />
|
|
||||||
<Typography.Text>
|
|
||||||
{task.timeTracking?.logged ? `${task.timeTracking.logged}h` : '0h'}
|
|
||||||
</Typography.Text>
|
|
||||||
</Space>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Actions */}
|
|
||||||
<div className="task-cell">
|
|
||||||
<Button
|
|
||||||
{...taskManagementAntdConfig.taskButtonDefaults}
|
|
||||||
icon={<MoreOutlined />}
|
|
||||||
onClick={() => {
|
|
||||||
// Handle more actions
|
|
||||||
console.log('More actions clicked');
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TaskRowExample;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Migration Guide:
|
|
||||||
*
|
|
||||||
* 1. Replace direct antd imports with centralized imports:
|
|
||||||
* - Change: import { DatePicker } from 'antd';
|
|
||||||
* - To: import { DatePicker } from './antd-imports';
|
|
||||||
*
|
|
||||||
* 2. Use centralized configurations:
|
|
||||||
* - Apply taskManagementAntdConfig.datePickerDefaults to all DatePickers
|
|
||||||
* - Use taskMessage instead of direct message calls
|
|
||||||
* - Apply consistent styling with taskManagementTheme
|
|
||||||
*
|
|
||||||
* 3. Benefits:
|
|
||||||
* - Better tree-shaking (smaller bundle size)
|
|
||||||
* - Consistent component props across all task management components
|
|
||||||
* - Centralized theme management
|
|
||||||
* - Type safety with proper TypeScript types
|
|
||||||
* - Easy maintenance and updates
|
|
||||||
*
|
|
||||||
* 4. Performance optimizations included:
|
|
||||||
* - Memoized date values to prevent unnecessary dayjs parsing
|
|
||||||
* - Centralized configurations to prevent prop recreation
|
|
||||||
* - Optimized message utilities
|
|
||||||
*/
|
|
||||||
@@ -9,6 +9,87 @@
|
|||||||
border-color: var(--task-border-primary, #e8e8e8);
|
border-color: var(--task-border-primary, #e8e8e8);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Horizontal Scrolling Optimizations */
|
||||||
|
.task-table-fixed-columns {
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--task-bg-primary, #fff);
|
||||||
|
border-right: 2px solid var(--task-border-primary, #e8e8e8);
|
||||||
|
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-scrollable-container {
|
||||||
|
flex: 1;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0; /* Allow flex item to shrink below content size */
|
||||||
|
}
|
||||||
|
|
||||||
|
.task-table-scrollable-columns {
|
||||||
|
display: flex;
|
||||||
|
width: max-content; /* Allow content to determine width */
|
||||||
|
min-width: 100%; /* Ensure it takes at least full width */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support for horizontal scrolling */
|
||||||
|
.dark .task-table-fixed-columns {
|
||||||
|
background: var(--task-bg-primary, #1f1f1f);
|
||||||
|
border-right-color: var(--task-border-primary, #303030);
|
||||||
|
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* HORIZONTAL SCROLLING SETUP
|
||||||
|
*
|
||||||
|
* For proper horizontal scrolling, the parent container should have:
|
||||||
|
* - overflow-x: auto
|
||||||
|
* - width: 100% (or specific width)
|
||||||
|
* - min-width: fit-content (optional, for very wide content)
|
||||||
|
*
|
||||||
|
* Example parent container CSS:
|
||||||
|
* .task-list-container {
|
||||||
|
* overflow-x: auto;
|
||||||
|
* width: 100%;
|
||||||
|
* min-width: fit-content;
|
||||||
|
* }
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Ensure task row works with horizontal scrolling containers */
|
||||||
|
.task-row-optimized {
|
||||||
|
min-width: fit-content;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container styles for horizontal scrolling */
|
||||||
|
.task-row-container {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: fit-content;
|
||||||
|
overflow-x: auto;
|
||||||
|
overflow-y: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* All columns container - no fixed positioning */
|
||||||
|
.task-table-all-columns {
|
||||||
|
display: flex;
|
||||||
|
min-width: fit-content;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure columns maintain their widths */
|
||||||
|
.task-table-all-columns > div {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.task-row-container {
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* SIMPLIFIED HOVER STATE: Remove complex containment and transforms */
|
/* SIMPLIFIED HOVER STATE: Remove complex containment and transforms */
|
||||||
.task-row-optimized:hover {
|
.task-row-optimized:hover {
|
||||||
/* Remove transform that was causing GPU conflicts */
|
/* Remove transform that was causing GPU conflicts */
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Task } from '@/types/task-management.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
import { dayjs } from './antd-imports';
|
import { dayjs } from '@/shared/antd-imports';
|
||||||
|
|
||||||
// Performance constants
|
// Performance constants
|
||||||
export const PERFORMANCE_CONSTANTS = {
|
export const PERFORMANCE_CONSTANTS = {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useMemo, useCallback, useState, useRef, useEffect, lazy } from 'react';
|
import React, { useMemo, useCallback, useState, useRef, useEffect } from 'react';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
@@ -12,28 +12,32 @@ import {
|
|||||||
HolderOutlined,
|
HolderOutlined,
|
||||||
MessageOutlined,
|
MessageOutlined,
|
||||||
PaperClipOutlined,
|
PaperClipOutlined,
|
||||||
ClockCircleOutlined,
|
|
||||||
UserOutlined,
|
UserOutlined,
|
||||||
type InputRef
|
type InputRef,
|
||||||
} from './antd-imports';
|
Tooltip
|
||||||
import { DownOutlined, RightOutlined, ExpandAltOutlined, DoubleRightOutlined, CheckCircleOutlined } from '@ant-design/icons';
|
} from '@/shared/antd-imports';
|
||||||
|
import {
|
||||||
|
RightOutlined,
|
||||||
|
ExpandAltOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
MinusCircleOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
RetweetOutlined,
|
||||||
|
} from '@/shared/antd-imports';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Task } from '@/types/task-management.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import { AssigneeSelector, Avatar, AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, LabelsSelector, Progress, Tooltip } from '@/components';
|
import { AvatarGroup, Button, Checkbox, CustomColordLabel, CustomNumberLabel, Progress } from '@/components';
|
||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
import { SocketEvents } from '@/shared/socket-events';
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
import TaskStatusDropdown from './task-status-dropdown';
|
import TaskTimer from '@/components/taskListCommon/task-timer/task-timer';
|
||||||
import TaskPriorityDropdown from './task-priority-dropdown';
|
import { useTaskTimer } from '@/hooks/useTaskTimer';
|
||||||
import TaskPhaseDropdown from './task-phase-dropdown';
|
|
||||||
import {
|
import {
|
||||||
formatDate as utilFormatDate,
|
formatDate as utilFormatDate,
|
||||||
formatDateTime as utilFormatDateTime,
|
formatDateTime as utilFormatDateTime,
|
||||||
createLabelsAdapter,
|
createLabelsAdapter,
|
||||||
createAssigneeAdapter,
|
createAssigneeAdapter,
|
||||||
PRIORITY_COLORS as UTIL_PRIORITY_COLORS,
|
PRIORITY_COLORS as UTIL_PRIORITY_COLORS,
|
||||||
performanceMonitor,
|
|
||||||
taskPropsEqual
|
|
||||||
} from './task-row-utils';
|
} from './task-row-utils';
|
||||||
import './task-row-optimized.css';
|
import './task-row-optimized.css';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
@@ -53,6 +57,7 @@ interface TaskRowProps {
|
|||||||
columns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
|
columns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
|
||||||
fixedColumns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
|
fixedColumns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
|
||||||
scrollableColumns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
|
scrollableColumns?: Array<{ key: string; label: string; width: number; fixed?: boolean }>;
|
||||||
|
onExpandSubtaskInput?: (taskId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority and status colors - moved outside component to avoid recreation
|
// Priority and status colors - moved outside component to avoid recreation
|
||||||
@@ -70,22 +75,25 @@ const STATUS_COLORS = {
|
|||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Memoized sub-components for maximum performance
|
// Memoized sub-components for maximum performance
|
||||||
const DragHandle = React.memo<{ isDarkMode: boolean; attributes: any; listeners: any }>(({ isDarkMode, attributes, listeners }) => (
|
const DragHandle = React.memo<{ isDarkMode: boolean; attributes: any; listeners: any }>(({ isDarkMode, attributes, listeners }) => {
|
||||||
<div
|
return (
|
||||||
className="drag-handle-optimized flex items-center justify-center w-6 h-6 opacity-60 hover:opacity-100"
|
<div
|
||||||
style={{
|
className="drag-handle-optimized flex items-center justify-center w-6 h-6 opacity-60 hover:opacity-100"
|
||||||
transition: 'opacity 0.1s ease', // Faster transition
|
style={{
|
||||||
}}
|
transition: 'opacity 0.1s ease', // Faster transition
|
||||||
data-dnd-drag-handle="true"
|
cursor: 'grab',
|
||||||
{...attributes}
|
}}
|
||||||
{...listeners}
|
data-dnd-drag-handle="true"
|
||||||
>
|
{...attributes}
|
||||||
<HolderOutlined
|
{...listeners}
|
||||||
className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
>
|
||||||
style={{ pointerEvents: 'none' }} // Prevent icon from interfering
|
<HolderOutlined
|
||||||
/>
|
className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||||
</div>
|
style={{ pointerEvents: 'none' }} // Prevent icon from interfering
|
||||||
));
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const TaskKey = React.memo<{ taskKey: string; isDarkMode: boolean }>(({ taskKey, isDarkMode }) => (
|
const TaskKey = React.memo<{ taskKey: string; isDarkMode: boolean }>(({ taskKey, isDarkMode }) => (
|
||||||
<span
|
<span
|
||||||
@@ -130,34 +138,20 @@ const TaskProgress = React.memo<{ progress: number; isDarkMode: boolean }>(({ pr
|
|||||||
/>
|
/>
|
||||||
));
|
));
|
||||||
|
|
||||||
const TaskPriority = React.memo<{ priority: string; isDarkMode: boolean }>(({ priority, isDarkMode }) => {
|
const TaskTimeTracking = React.memo<{ taskId: string; isDarkMode: boolean }>(({ taskId, isDarkMode }) => {
|
||||||
const color = PRIORITY_COLORS[priority as keyof typeof PRIORITY_COLORS] || '#d9d9d9';
|
const { started, timeString, handleStartTimer, handleStopTimer } = useTaskTimer(
|
||||||
return (
|
taskId,
|
||||||
<div className="flex items-center gap-2">
|
null // The hook will get the timer start time from Redux
|
||||||
<div
|
|
||||||
className="w-2 h-2 rounded-full"
|
|
||||||
style={{ backgroundColor: color }}
|
|
||||||
/>
|
|
||||||
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
|
||||||
{priority}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
});
|
|
||||||
|
|
||||||
const TaskTimeTracking = React.memo<{ timeTracking?: { logged?: number | string }; isDarkMode: boolean }>(({ timeTracking, isDarkMode }) => {
|
|
||||||
if (!timeTracking?.logged || timeTracking.logged === 0) return null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<TaskTimer
|
||||||
<ClockCircleOutlined className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`} />
|
taskId={taskId}
|
||||||
<span className={`text-xs ${isDarkMode ? 'text-gray-300' : 'text-gray-700'}`}>
|
started={started}
|
||||||
{typeof timeTracking.logged === 'number'
|
handleStartTimer={handleStartTimer}
|
||||||
? `${timeTracking.logged}h`
|
handleStopTimer={handleStopTimer}
|
||||||
: timeTracking.logged
|
timeString={timeString}
|
||||||
}
|
/>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -369,6 +363,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
columns,
|
columns,
|
||||||
fixedColumns,
|
fixedColumns,
|
||||||
scrollableColumns,
|
scrollableColumns,
|
||||||
|
onExpandSubtaskInput,
|
||||||
}) => {
|
}) => {
|
||||||
// PERFORMANCE OPTIMIZATION: Frame-rate aware loading
|
// PERFORMANCE OPTIMIZATION: Frame-rate aware loading
|
||||||
const canRenderComplex = useFrameRateOptimizedLoading(index);
|
const canRenderComplex = useFrameRateOptimizedLoading(index);
|
||||||
@@ -511,23 +506,6 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
};
|
};
|
||||||
}, [editTaskName, shouldRenderFull, handleTaskNameSave]);
|
}, [editTaskName, shouldRenderFull, handleTaskNameSave]);
|
||||||
|
|
||||||
// Handle adding new subtask
|
|
||||||
const handleAddSubtask = useCallback(() => {
|
|
||||||
const subtaskName = newSubtaskName?.trim();
|
|
||||||
if (subtaskName && connected) {
|
|
||||||
socket?.emit(
|
|
||||||
SocketEvents.TASK_NAME_CHANGE.toString(), // Using existing event for now
|
|
||||||
JSON.stringify({
|
|
||||||
name: subtaskName,
|
|
||||||
parent_task_id: task.id,
|
|
||||||
project_id: projectId,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
setNewSubtaskName('');
|
|
||||||
setShowAddSubtask(false);
|
|
||||||
}
|
|
||||||
}, [newSubtaskName, connected, socket, task.id, projectId]);
|
|
||||||
|
|
||||||
// Handle canceling add subtask
|
// Handle canceling add subtask
|
||||||
const handleCancelAddSubtask = useCallback(() => {
|
const handleCancelAddSubtask = useCallback(() => {
|
||||||
setNewSubtaskName('');
|
setNewSubtaskName('');
|
||||||
@@ -540,12 +518,8 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
transform: CSS.Transform.toString(transform),
|
transform: CSS.Transform.toString(transform),
|
||||||
transition: isDragging ? 'opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1)' : 'none',
|
opacity: isDragging ? 0.5 : 1,
|
||||||
opacity: isDragging ? 0.3 : 1,
|
|
||||||
zIndex: isDragging ? 1000 : 'auto',
|
zIndex: isDragging ? 1000 : 'auto',
|
||||||
// PERFORMANCE OPTIMIZATION: Force GPU acceleration
|
|
||||||
willChange: 'transform, opacity',
|
|
||||||
filter: isDragging ? 'blur(0.5px)' : 'none',
|
|
||||||
};
|
};
|
||||||
}, [transform, isDragging]);
|
}, [transform, isDragging]);
|
||||||
|
|
||||||
@@ -558,9 +532,55 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
onToggleSubtasks?.(task.id);
|
onToggleSubtasks?.(task.id);
|
||||||
}, [onToggleSubtasks, task.id]);
|
}, [onToggleSubtasks, task.id]);
|
||||||
|
|
||||||
|
// Handle successful subtask creation
|
||||||
|
const handleSubtaskCreated = useCallback((newTask: any) => {
|
||||||
|
if (newTask && newTask.id) {
|
||||||
|
// Update parent task progress
|
||||||
|
socket?.emit(SocketEvents.GET_TASK_PROGRESS.toString(), task.id);
|
||||||
|
|
||||||
|
// Clear form and hide add subtask row
|
||||||
|
setNewSubtaskName('');
|
||||||
|
setShowAddSubtask(false);
|
||||||
|
|
||||||
|
// The global socket handler will automatically add the subtask to the parent task
|
||||||
|
// and update the UI through Redux
|
||||||
|
|
||||||
|
// After creating the first subtask, the task now has subtasks
|
||||||
|
// so we should expand it to show the new subtask
|
||||||
|
if (task.sub_tasks_count === 0 || !task.sub_tasks_count) {
|
||||||
|
// Trigger expansion to show the newly created subtask
|
||||||
|
setTimeout(() => {
|
||||||
|
onToggleSubtasks?.(task.id);
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [socket, task.id, task.sub_tasks_count, onToggleSubtasks]);
|
||||||
|
|
||||||
|
// Handle adding new subtask
|
||||||
|
const handleAddSubtask = useCallback(() => {
|
||||||
|
const subtaskName = newSubtaskName?.trim();
|
||||||
|
if (subtaskName && connected && projectId) {
|
||||||
|
// Get current session for reporter_id and team_id
|
||||||
|
const currentSession = JSON.parse(localStorage.getItem('session') || '{}');
|
||||||
|
|
||||||
|
const requestBody = {
|
||||||
|
project_id: projectId,
|
||||||
|
name: subtaskName,
|
||||||
|
reporter_id: currentSession.id,
|
||||||
|
team_id: currentSession.team_id,
|
||||||
|
parent_task_id: task.id,
|
||||||
|
};
|
||||||
|
|
||||||
|
socket?.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(requestBody));
|
||||||
|
|
||||||
|
// Handle the response
|
||||||
|
socket?.once(SocketEvents.QUICK_TASK.toString(), handleSubtaskCreated);
|
||||||
|
}
|
||||||
|
}, [newSubtaskName, connected, socket, task.id, projectId, handleSubtaskCreated]);
|
||||||
|
|
||||||
// Handle expand/collapse or add subtask
|
// Handle expand/collapse or add subtask
|
||||||
const handleExpandClick = useCallback(() => {
|
const handleExpandClick = useCallback(() => {
|
||||||
// For now, just toggle add subtask row for all tasks
|
// Always show add subtask row when clicking expand icon
|
||||||
setShowAddSubtask(!showAddSubtask);
|
setShowAddSubtask(!showAddSubtask);
|
||||||
if (!showAddSubtask) {
|
if (!showAddSubtask) {
|
||||||
// Focus the input after state update
|
// Focus the input after state update
|
||||||
@@ -672,13 +692,22 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
<div className="flex items-center gap-2 h-5 overflow-hidden">
|
<div className="flex items-center gap-2 h-5 overflow-hidden">
|
||||||
{/* Always reserve space for expand icon */}
|
{/* Always reserve space for expand icon */}
|
||||||
<div style={{ width: 20, display: 'inline-block' }} />
|
<div style={{ width: 20, display: 'inline-block' }} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
||||||
<Typography.Text
|
<Typography.Text
|
||||||
ellipsis={{ tooltip: task.title }}
|
ellipsis={{ tooltip: task.title }}
|
||||||
className={styleClasses.taskName}
|
className={styleClasses.taskName}
|
||||||
>
|
>
|
||||||
{task.title}
|
{task.title}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
|
{(task as any).sub_tasks_count > 0 && (
|
||||||
|
<div
|
||||||
|
className={`subtask-count-badge flex items-center gap-1 px-1 py-0.5 text-xs font-semibold`}
|
||||||
|
style={{ fontSize: '10px', marginLeft: 4, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
||||||
|
>
|
||||||
|
<span>{(task as any).sub_tasks_count}</span>
|
||||||
|
<span style={{ fontSize: '12px', fontWeight: 600, marginLeft: 1 }}>{'»'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -724,12 +753,9 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Full rendering logic (existing code)
|
// Full rendering logic (existing code)
|
||||||
// Fix border logic: if this is a fixed column, only consider it "last" if there are no scrollable columns
|
// Simplified border logic - no fixed columns
|
||||||
// If this is a scrollable column, use the normal logic
|
const isLast = index === totalColumns - 1;
|
||||||
const isActuallyLast = isFixed
|
const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||||
? (index === totalColumns - 1 && (!scrollableColumns || scrollableColumns.length === 0))
|
|
||||||
: (index === totalColumns - 1);
|
|
||||||
const borderClasses = `${isActuallyLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
|
||||||
|
|
||||||
switch (col.key) {
|
switch (col.key) {
|
||||||
case 'drag':
|
case 'drag':
|
||||||
@@ -784,7 +810,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleExpandClick();
|
if (onExpandSubtaskInput) onExpandSubtaskInput(task.id);
|
||||||
}}
|
}}
|
||||||
className={`expand-toggle-btn w-4 h-4 flex items-center justify-center border-none rounded text-xs cursor-pointer transition-all duration-200 ${
|
className={`expand-toggle-btn w-4 h-4 flex items-center justify-center border-none rounded text-xs cursor-pointer transition-all duration-200 ${
|
||||||
isDarkMode
|
isDarkMode
|
||||||
@@ -794,12 +820,12 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
style={{ backgroundColor: 'transparent' }}
|
style={{ backgroundColor: 'transparent' }}
|
||||||
title="Add subtask"
|
title="Add subtask"
|
||||||
>
|
>
|
||||||
{showAddSubtask ? <DownOutlined /> : <RightOutlined />}
|
<RightOutlined style={{ fontSize: 16, color: isDarkMode ? '#b0b3b8' : '#888' }} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Task name and input */}
|
{/* Task name and input */}
|
||||||
<div ref={wrapperRef} className="flex-1 min-w-0">
|
<div ref={wrapperRef} className="flex-1 min-w-0 flex items-center gap-2">
|
||||||
{editTaskName ? (
|
{editTaskName ? (
|
||||||
<Input
|
<Input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
@@ -816,73 +842,59 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
autoFocus
|
autoFocus
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Typography.Text
|
<>
|
||||||
ellipsis={{ tooltip: task.title }}
|
<Typography.Text
|
||||||
onClick={() => setEditTaskName(true)}
|
ellipsis={{ tooltip: task.title }}
|
||||||
className={styleClasses.taskName}
|
onClick={() => setEditTaskName(true)}
|
||||||
style={{ cursor: 'pointer' }}
|
className={styleClasses.taskName}
|
||||||
>
|
style={{ cursor: 'pointer' }}
|
||||||
{task.title}
|
>
|
||||||
</Typography.Text>
|
{task.title}
|
||||||
|
</Typography.Text>
|
||||||
|
{(task as any).sub_tasks_count > 0 && (
|
||||||
|
<div
|
||||||
|
className={`subtask-count-badge flex items-center gap-1 px-1 py-0.5 text-xs font-semibold`}
|
||||||
|
style={{ fontSize: '10px', marginLeft: 4, color: isDarkMode ? '#b0b3b8' : '#888' }}
|
||||||
|
>
|
||||||
|
<span>{(task as any).sub_tasks_count}</span>
|
||||||
|
<span style={{ fontSize: '12px', fontWeight: 600, marginLeft: 1 }}>{'»'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Indicators section */}
|
{/* Indicators section */}
|
||||||
{!editTaskName && (
|
{!editTaskName && (
|
||||||
<div className="task-indicators flex items-center gap-1">
|
<div className="task-indicators flex items-center gap-2">
|
||||||
{/* Subtasks count */}
|
|
||||||
{(task as any).subtasks_count && (task as any).subtasks_count > 0 && (
|
|
||||||
<Tooltip title={`${(task as any).subtasks_count} ${(task as any).subtasks_count !== 1 ? t('subtasks') : t('subtask')}`}>
|
|
||||||
<div
|
|
||||||
className={`indicator-badge subtasks flex items-center gap-1 px-1 py-0.5 rounded text-xs font-semibold cursor-pointer transition-colors duration-200 ${
|
|
||||||
isDarkMode
|
|
||||||
? 'bg-gray-800 border-gray-600 text-gray-400 hover:bg-gray-700'
|
|
||||||
: 'bg-gray-100 border-gray-300 text-gray-600 hover:bg-gray-200'
|
|
||||||
}`}
|
|
||||||
style={{ fontSize: '10px', border: '1px solid' }}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleToggleSubtasks?.();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<span>{(task as any).subtasks_count}</span>
|
|
||||||
<RightOutlined style={{ fontSize: '8px' }} />
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Comments indicator */}
|
{/* Comments indicator */}
|
||||||
{(task as any).comments_count && (task as any).comments_count > 0 && (
|
{(task as any).comments_count > 0 && (
|
||||||
<Tooltip title={`${(task as any).comments_count} ${(task as any).comments_count !== 1 ? t('comments') : t('comment')}`}>
|
<Tooltip title={t('taskManagement.comments', 'Comments')}>
|
||||||
<div
|
<MessageOutlined style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }} />
|
||||||
className={`indicator-badge comments flex items-center gap-1 px-1 py-0.5 rounded text-xs font-semibold ${
|
|
||||||
isDarkMode
|
|
||||||
? 'bg-green-900 border-green-700 text-green-300'
|
|
||||||
: 'bg-green-50 border-green-200 text-green-700'
|
|
||||||
}`}
|
|
||||||
style={{ fontSize: '10px', border: '1px solid' }}
|
|
||||||
>
|
|
||||||
<MessageOutlined style={{ fontSize: '8px' }} />
|
|
||||||
<span>{(task as any).comments_count}</span>
|
|
||||||
</div>
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Attachments indicator */}
|
{/* Attachments indicator */}
|
||||||
{(task as any).attachments_count && (task as any).attachments_count > 0 && (
|
{(task as any).attachments_count > 0 && (
|
||||||
<Tooltip title={`${(task as any).attachments_count} ${(task as any).attachments_count !== 1 ? t('attachments') : t('attachment')}`}>
|
<Tooltip title={t('taskManagement.attachments', 'Attachments')}>
|
||||||
<div
|
<PaperClipOutlined style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }} />
|
||||||
className={`indicator-badge attachments flex items-center gap-1 px-1 py-0.5 rounded text-xs font-semibold ${
|
</Tooltip>
|
||||||
isDarkMode
|
)}
|
||||||
? 'bg-blue-900 border-blue-700 text-blue-300'
|
{/* Dependencies indicator */}
|
||||||
: 'bg-blue-50 border-blue-200 text-blue-700'
|
{(task as any).has_dependencies && (
|
||||||
}`}
|
<Tooltip title={t('taskManagement.dependencies', 'Dependencies')}>
|
||||||
style={{ fontSize: '10px', border: '1px solid' }}
|
<MinusCircleOutlined style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }} />
|
||||||
>
|
</Tooltip>
|
||||||
<PaperClipOutlined style={{ fontSize: '8px' }} />
|
)}
|
||||||
<span>{(task as any).attachments_count}</span>
|
{/* Subscribers indicator */}
|
||||||
</div>
|
{(task as any).has_subscribers && (
|
||||||
|
<Tooltip title={t('taskManagement.subscribers', 'Subscribers')}>
|
||||||
|
<EyeOutlined style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }} />
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
{/* Recurring indicator */}
|
||||||
|
{(task as any).schedule_id && (
|
||||||
|
<Tooltip title={t('taskManagement.recurringTask', 'Recurring Task')}>
|
||||||
|
<RetweetOutlined style={{ fontSize: 14, color: isDarkMode ? '#b0b3b8' : '#888' }} />
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -1117,7 +1129,10 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
case 'timeTracking':
|
case 'timeTracking':
|
||||||
return (
|
return (
|
||||||
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
<div key={col.key} className={`flex items-center px-2 ${borderClasses}`} style={{ width: col.width }}>
|
||||||
<TaskTimeTracking timeTracking={task.timeTracking} isDarkMode={isDarkMode} />
|
<TaskTimeTracking
|
||||||
|
taskId={task.id || ''}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -1205,7 +1220,7 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
}, [
|
}, [
|
||||||
shouldRenderFull, renderMinimalColumn, isDarkMode, task, isSelected, editTaskName, taskName, adapters, groupId, projectId,
|
shouldRenderFull, renderMinimalColumn, isDarkMode, task, isSelected, editTaskName, taskName, adapters, groupId, projectId,
|
||||||
attributes, listeners, handleSelectChange, handleTaskNameSave, handleDateChange,
|
attributes, listeners, handleSelectChange, handleTaskNameSave, handleDateChange,
|
||||||
dateValues, styleClasses
|
dateValues, styleClasses, onExpandSubtaskInput
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Apply global cursor style when dragging
|
// Apply global cursor style when dragging
|
||||||
@@ -1214,58 +1229,27 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
// Compute theme class
|
// Compute theme class
|
||||||
const themeClass = isDarkMode ? 'dark' : '';
|
const themeClass = isDarkMode ? 'dark' : '';
|
||||||
|
|
||||||
if (isDragging) {
|
|
||||||
console.log('TaskRow isDragging:', task.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
// DRAG OVERLAY: Render simplified version when dragging
|
// DRAG OVERLAY: Render simplified version when dragging
|
||||||
if (isDragOverlay) {
|
if (isDragOverlay) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`drag-overlay-simplified ${themeClass}`}
|
className="drag-overlay-simplified"
|
||||||
style={{
|
style={{
|
||||||
padding: '10px 16px',
|
padding: '8px 12px',
|
||||||
backgroundColor: isDarkMode ? 'rgba(42, 42, 42, 0.95)' : 'rgba(255, 255, 255, 0.95)',
|
backgroundColor: isDarkMode ? '#1f1f1f' : 'white',
|
||||||
border: `1px solid ${isDarkMode ? 'rgba(74, 158, 255, 0.8)' : 'rgba(24, 144, 255, 0.8)'}`,
|
border: `1px solid ${isDarkMode ? '#404040' : '#d9d9d9'}`,
|
||||||
borderRadius: '8px',
|
borderRadius: '4px',
|
||||||
boxShadow: isDarkMode
|
boxShadow: '0 2px 8px rgba(0, 0, 0, 0.15)',
|
||||||
? '0 8px 32px rgba(0, 0, 0, 0.4), 0 2px 8px rgba(74, 158, 255, 0.2)'
|
color: isDarkMode ? 'white' : 'black',
|
||||||
: '0 8px 32px rgba(0, 0, 0, 0.12), 0 2px 8px rgba(24, 144, 255, 0.15)',
|
fontSize: '14px',
|
||||||
backdropFilter: 'blur(8px)',
|
fontWeight: '500',
|
||||||
maxWidth: '320px',
|
|
||||||
minWidth: '200px',
|
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
textOverflow: 'ellipsis',
|
textOverflow: 'ellipsis',
|
||||||
transform: 'scale(1.02)',
|
maxWidth: '300px',
|
||||||
transition: 'none',
|
|
||||||
willChange: 'transform',
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
{task.title}
|
||||||
<div
|
|
||||||
className={`drag-handle-icon ${isDarkMode ? 'text-blue-400' : 'text-blue-600'}`}
|
|
||||||
style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
opacity: 0.8,
|
|
||||||
transform: 'translateZ(0)',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<HolderOutlined />
|
|
||||||
</div>
|
|
||||||
<span
|
|
||||||
className={`task-title-drag ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}
|
|
||||||
title={task.title}
|
|
||||||
style={{
|
|
||||||
fontSize: '14px',
|
|
||||||
fontWeight: 500,
|
|
||||||
letterSpacing: '0.01em',
|
|
||||||
lineHeight: '1.4',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{task.title}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1283,154 +1267,32 @@ const TaskRow: React.FC<TaskRowProps> = React.memo(({
|
|||||||
data-task-id={task.id}
|
data-task-id={task.id}
|
||||||
data-group-id={groupId}
|
data-group-id={groupId}
|
||||||
>
|
>
|
||||||
<div className="flex h-10 max-h-10 overflow-visible relative">
|
<div className="task-row-container flex h-10 max-h-10 relative">
|
||||||
{/* Fixed Columns */}
|
{/* All Columns - No Fixed Positioning */}
|
||||||
{fixedColumns && fixedColumns.length > 0 && (
|
<div className="task-table-all-columns flex">
|
||||||
<div
|
{/* Fixed Columns (now scrollable) */}
|
||||||
className="task-table-fixed-columns flex overflow-visible"
|
{(fixedColumns ?? []).length > 0 && (
|
||||||
style={{
|
<>
|
||||||
width: fixedColumns.reduce((sum, col) => sum + col.width, 0),
|
{(fixedColumns ?? []).map((col, index) =>
|
||||||
position: 'sticky',
|
shouldRenderMinimal
|
||||||
left: 0,
|
? renderMinimalColumn(col, false, index, (fixedColumns ?? []).length)
|
||||||
zIndex: 10,
|
: renderColumn(col, false, index, (fixedColumns ?? []).length)
|
||||||
background: isDarkMode ? 'var(--task-bg-primary, #1f1f1f)' : 'var(--task-bg-primary, white)',
|
)}
|
||||||
borderRight: scrollableColumns && scrollableColumns.length > 0 ? '2px solid var(--task-border-primary, #e8e8e8)' : 'none',
|
</>
|
||||||
boxShadow: scrollableColumns && scrollableColumns.length > 0 ? '2px 0 4px rgba(0, 0, 0, 0.1)' : 'none',
|
)}
|
||||||
}}
|
|
||||||
>
|
{/* Scrollable Columns */}
|
||||||
{fixedColumns.map((col, index) =>
|
{(scrollableColumns ?? []).length > 0 && (
|
||||||
shouldRenderMinimal
|
<>
|
||||||
? renderMinimalColumn(col, true, index, fixedColumns.length)
|
{(scrollableColumns ?? []).map((col, index) =>
|
||||||
: renderColumn(col, true, index, fixedColumns.length)
|
shouldRenderMinimal
|
||||||
)}
|
? renderMinimalColumn(col, false, index, (scrollableColumns ?? []).length)
|
||||||
</div>
|
: renderColumn(col, false, index, (scrollableColumns ?? []).length)
|
||||||
)}
|
)}
|
||||||
|
</>
|
||||||
{/* Scrollable Columns */}
|
)}
|
||||||
{scrollableColumns && scrollableColumns.length > 0 && (
|
|
||||||
<div
|
|
||||||
className="task-table-scrollable-columns overflow-visible"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: scrollableColumns.reduce((sum, col) => sum + col.width, 0)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{scrollableColumns.map((col, index) =>
|
|
||||||
shouldRenderMinimal
|
|
||||||
? renderMinimalColumn(col, false, index, scrollableColumns.length)
|
|
||||||
: renderColumn(col, false, index, scrollableColumns.length)
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Add Subtask Row */}
|
|
||||||
{showAddSubtask && (
|
|
||||||
<div className={`add-subtask-row ${showAddSubtask ? 'visible' : ''} ${isDarkMode ? 'dark' : ''}`}>
|
|
||||||
<div className="flex h-10 max-h-10 overflow-visible relative">
|
|
||||||
{/* Fixed Columns for Add Subtask */}
|
|
||||||
{fixedColumns && fixedColumns.length > 0 && (
|
|
||||||
<div
|
|
||||||
className="task-table-fixed-columns flex overflow-visible"
|
|
||||||
style={{
|
|
||||||
width: fixedColumns.reduce((sum, col) => sum + col.width, 0),
|
|
||||||
position: 'sticky',
|
|
||||||
left: 0,
|
|
||||||
zIndex: 10,
|
|
||||||
background: isDarkMode ? 'var(--task-bg-primary, #1f1f1f)' : 'var(--task-bg-primary, white)',
|
|
||||||
borderRight: scrollableColumns && scrollableColumns.length > 0 ? '2px solid var(--task-border-primary, #e8e8e8)' : 'none',
|
|
||||||
boxShadow: scrollableColumns && scrollableColumns.length > 0 ? '2px 0 4px rgba(0, 0, 0, 0.1)' : 'none',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{fixedColumns.map((col, index) => {
|
|
||||||
// Fix border logic for add subtask row: fixed columns should have right border if scrollable columns exist
|
|
||||||
const isActuallyLast = index === fixedColumns.length - 1 && (!scrollableColumns || scrollableColumns.length === 0);
|
|
||||||
const borderClasses = `${isActuallyLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
|
||||||
|
|
||||||
if (col.key === 'task') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={col.key}
|
|
||||||
className={`flex items-center px-2 ${borderClasses}`}
|
|
||||||
style={{ width: col.width }}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0 pl-6">
|
|
||||||
<Input
|
|
||||||
ref={addSubtaskInputRef}
|
|
||||||
placeholder={t('enterSubtaskName')}
|
|
||||||
value={newSubtaskName}
|
|
||||||
onChange={(e) => setNewSubtaskName(e.target.value)}
|
|
||||||
onPressEnter={handleAddSubtask}
|
|
||||||
onBlur={handleCancelAddSubtask}
|
|
||||||
className={`add-subtask-input flex-1 ${
|
|
||||||
isDarkMode
|
|
||||||
? 'bg-gray-700 border-gray-600 text-gray-200'
|
|
||||||
: 'bg-white border-gray-300 text-gray-900'
|
|
||||||
}`}
|
|
||||||
size="small"
|
|
||||||
autoFocus
|
|
||||||
/>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
onClick={handleAddSubtask}
|
|
||||||
disabled={!newSubtaskName.trim()}
|
|
||||||
className="h-6 px-2 text-xs bg-blue-500 text-white hover:bg-blue-600"
|
|
||||||
>
|
|
||||||
{t('add')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="small"
|
|
||||||
onClick={handleCancelAddSubtask}
|
|
||||||
className="h-6 px-2 text-xs"
|
|
||||||
>
|
|
||||||
{t('cancel')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={col.key}
|
|
||||||
className={`flex items-center px-2 ${borderClasses}`}
|
|
||||||
style={{ width: col.width }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Scrollable Columns for Add Subtask */}
|
|
||||||
{scrollableColumns && scrollableColumns.length > 0 && (
|
|
||||||
<div
|
|
||||||
className="task-table-scrollable-columns overflow-visible"
|
|
||||||
style={{
|
|
||||||
display: 'flex',
|
|
||||||
flex: 1,
|
|
||||||
minWidth: scrollableColumns.reduce((sum, col) => sum + col.width, 0)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{scrollableColumns.map((col, index) => {
|
|
||||||
const isLast = index === scrollableColumns.length - 1;
|
|
||||||
const borderClasses = `${isLast ? '' : 'border-r'} border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={col.key}
|
|
||||||
className={`flex items-center px-2 ${borderClasses}`}
|
|
||||||
style={{ width: col.width }}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}, (prevProps, nextProps) => {
|
}, (prevProps, nextProps) => {
|
||||||
|
|||||||
@@ -1,163 +0,0 @@
|
|||||||
import React, { useMemo, useCallback } from 'react';
|
|
||||||
import { FixedSizeList as List } from 'react-window';
|
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
|
||||||
import { useSelector } from 'react-redux';
|
|
||||||
import { taskManagementSelectors } from '@/features/task-management/task-management.slice';
|
|
||||||
import { Task } from '@/types/task-management.types';
|
|
||||||
import TaskRow from './task-row';
|
|
||||||
|
|
||||||
interface VirtualizedTaskGroupProps {
|
|
||||||
group: any;
|
|
||||||
projectId: string;
|
|
||||||
currentGrouping: 'status' | 'priority' | 'phase';
|
|
||||||
selectedTaskIds: string[];
|
|
||||||
onSelectTask: (taskId: string, selected: boolean) => void;
|
|
||||||
onToggleSubtasks: (taskId: string) => void;
|
|
||||||
height: number;
|
|
||||||
width: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const VirtualizedTaskGroup: React.FC<VirtualizedTaskGroupProps> = React.memo(({
|
|
||||||
group,
|
|
||||||
projectId,
|
|
||||||
currentGrouping,
|
|
||||||
selectedTaskIds,
|
|
||||||
onSelectTask,
|
|
||||||
onToggleSubtasks,
|
|
||||||
height,
|
|
||||||
width
|
|
||||||
}) => {
|
|
||||||
const allTasks = useSelector(taskManagementSelectors.selectAll);
|
|
||||||
|
|
||||||
// Get tasks for this group using memoization for performance
|
|
||||||
const groupTasks = useMemo(() => {
|
|
||||||
return group.taskIds
|
|
||||||
.map((taskId: string) => allTasks.find((task: Task) => task.id === taskId))
|
|
||||||
.filter((task: Task | undefined): task is Task => task !== undefined);
|
|
||||||
}, [group.taskIds, allTasks]);
|
|
||||||
|
|
||||||
const TASK_ROW_HEIGHT = 40;
|
|
||||||
const GROUP_HEADER_HEIGHT = 40;
|
|
||||||
const COLUMN_HEADER_HEIGHT = 40;
|
|
||||||
const ADD_TASK_ROW_HEIGHT = 40;
|
|
||||||
|
|
||||||
// Calculate total height for the group
|
|
||||||
const totalHeight = GROUP_HEADER_HEIGHT + COLUMN_HEADER_HEIGHT + (groupTasks.length * TASK_ROW_HEIGHT) + ADD_TASK_ROW_HEIGHT;
|
|
||||||
|
|
||||||
// Row renderer for virtualization
|
|
||||||
const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => {
|
|
||||||
// Header row
|
|
||||||
if (index === 0) {
|
|
||||||
return (
|
|
||||||
<div style={style}>
|
|
||||||
<div className="task-group-header">
|
|
||||||
<div className="task-group-header-row">
|
|
||||||
<div className="task-group-header-content">
|
|
||||||
<span className="task-group-header-text">
|
|
||||||
{group.title} ({groupTasks.length})
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Column headers row
|
|
||||||
if (index === 1) {
|
|
||||||
return (
|
|
||||||
<div style={style}>
|
|
||||||
<div className="task-group-column-headers">
|
|
||||||
<div className="task-group-column-headers-row">
|
|
||||||
<div className="task-table-fixed-columns">
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '40px' }}></div>
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '40px' }}></div>
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '80px' }}>
|
|
||||||
<span className="column-header-text">Key</span>
|
|
||||||
</div>
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '475px' }}>
|
|
||||||
<span className="column-header-text">Task</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="task-table-scrollable-columns">
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '90px' }}>
|
|
||||||
<span className="column-header-text">Progress</span>
|
|
||||||
</div>
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
|
||||||
<span className="column-header-text">Status</span>
|
|
||||||
</div>
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '150px' }}>
|
|
||||||
<span className="column-header-text">Members</span>
|
|
||||||
</div>
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '200px' }}>
|
|
||||||
<span className="column-header-text">Labels</span>
|
|
||||||
</div>
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '100px' }}>
|
|
||||||
<span className="column-header-text">Priority</span>
|
|
||||||
</div>
|
|
||||||
<div className="task-table-cell task-table-header-cell" style={{ width: '120px' }}>
|
|
||||||
<span className="column-header-text">Time Tracking</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Task rows
|
|
||||||
const taskIndex = index - 2;
|
|
||||||
if (taskIndex >= 0 && taskIndex < groupTasks.length) {
|
|
||||||
const task = groupTasks[taskIndex];
|
|
||||||
return (
|
|
||||||
<div style={style}>
|
|
||||||
<TaskRow
|
|
||||||
task={task}
|
|
||||||
projectId={projectId}
|
|
||||||
groupId={group.id}
|
|
||||||
currentGrouping={currentGrouping}
|
|
||||||
isSelected={selectedTaskIds.includes(task.id)}
|
|
||||||
index={taskIndex}
|
|
||||||
onSelect={onSelectTask}
|
|
||||||
onToggleSubtasks={onToggleSubtasks}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add task row (last row)
|
|
||||||
if (taskIndex === groupTasks.length) {
|
|
||||||
return (
|
|
||||||
<div style={style}>
|
|
||||||
<div className="task-group-add-task">
|
|
||||||
<div className="task-table-fixed-columns">
|
|
||||||
<div style={{ width: '380px', padding: '8px 12px' }}>
|
|
||||||
<span className="text-gray-500">+ Add task</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}, [group, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="virtualized-task-group">
|
|
||||||
<SortableContext items={group.taskIds} strategy={verticalListSortingStrategy}>
|
|
||||||
<List
|
|
||||||
height={Math.min(height, totalHeight)}
|
|
||||||
width={width}
|
|
||||||
itemCount={groupTasks.length + 3} // +3 for header, column headers, and add task row
|
|
||||||
itemSize={TASK_ROW_HEIGHT}
|
|
||||||
overscanCount={10} // Increased overscan for smoother scrolling experience
|
|
||||||
>
|
|
||||||
{Row}
|
|
||||||
</List>
|
|
||||||
</SortableContext>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default VirtualizedTaskGroup;
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import React, { useMemo, useCallback, useEffect, useRef } from 'react';
|
import React, { useMemo, useCallback, useEffect, useRef } from 'react';
|
||||||
import { FixedSizeList as List } from 'react-window';
|
import { FixedSizeList as List, FixedSizeList } from 'react-window';
|
||||||
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||||
import { useSelector, useDispatch } from 'react-redux';
|
import { useSelector, useDispatch } from 'react-redux';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Empty, Button } from 'antd';
|
import { Empty, Button, Input } from 'antd';
|
||||||
import { RightOutlined, DownOutlined } from '@ant-design/icons';
|
import { RightOutlined, DownOutlined } from '@ant-design/icons';
|
||||||
import { taskManagementSelectors } from '@/features/task-management/task-management.slice';
|
import { taskManagementSelectors } from '@/features/task-management/task-management.slice';
|
||||||
import { toggleGroupCollapsed } from '@/features/task-management/grouping.slice';
|
import { toggleGroupCollapsed } from '@/features/task-management/grouping.slice';
|
||||||
@@ -13,6 +13,8 @@ import AddTaskListRow from '@/pages/projects/projectView/taskList/task-list-tabl
|
|||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import { TaskListField } from '@/features/task-management/taskListFields.slice';
|
import { TaskListField } from '@/features/task-management/taskListFields.slice';
|
||||||
import { Checkbox } from '@/components';
|
import { Checkbox } from '@/components';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
|
||||||
interface VirtualizedTaskListProps {
|
interface VirtualizedTaskListProps {
|
||||||
group: any;
|
group: any;
|
||||||
@@ -61,6 +63,35 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
|||||||
const RENDER_BATCH_SIZE = 5; // Render max 5 tasks per frame
|
const RENDER_BATCH_SIZE = 5; // Render max 5 tasks per frame
|
||||||
const FRAME_BUDGET_MS = 8;
|
const FRAME_BUDGET_MS = 8;
|
||||||
|
|
||||||
|
const [showAddSubtaskForTaskId, setShowAddSubtaskForTaskId] = React.useState<string | null>(null);
|
||||||
|
const [newSubtaskName, setNewSubtaskName] = React.useState('');
|
||||||
|
const addSubtaskInputRef = React.useRef<any>(null);
|
||||||
|
|
||||||
|
const { socket, connected } = useSocket();
|
||||||
|
|
||||||
|
const handleAddSubtask = (parentTaskId: string) => {
|
||||||
|
if (!newSubtaskName.trim() || !connected || !socket) return;
|
||||||
|
const currentSession = JSON.parse(localStorage.getItem('session') || '{}');
|
||||||
|
const requestBody = {
|
||||||
|
project_id: group.project_id || group.projectId || projectId,
|
||||||
|
name: newSubtaskName.trim(),
|
||||||
|
reporter_id: currentSession.id,
|
||||||
|
team_id: currentSession.team_id,
|
||||||
|
parent_task_id: parentTaskId,
|
||||||
|
};
|
||||||
|
socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(requestBody));
|
||||||
|
// Listen for the response and clear input/collapse row
|
||||||
|
socket.once(SocketEvents.QUICK_TASK.toString(), (response: any) => {
|
||||||
|
setNewSubtaskName('');
|
||||||
|
setShowAddSubtaskForTaskId(null);
|
||||||
|
// Optionally: trigger a refresh or update tasks in parent
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const handleCancelAddSubtask = () => {
|
||||||
|
setNewSubtaskName('');
|
||||||
|
setShowAddSubtaskForTaskId(null);
|
||||||
|
};
|
||||||
|
|
||||||
// Handle collapse/expand toggle
|
// Handle collapse/expand toggle
|
||||||
const handleToggleCollapse = useCallback(() => {
|
const handleToggleCollapse = useCallback(() => {
|
||||||
dispatch(toggleGroupCollapsed(group.id));
|
dispatch(toggleGroupCollapsed(group.id));
|
||||||
@@ -174,7 +205,7 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
|||||||
return group.taskIds
|
return group.taskIds
|
||||||
.map((taskId: string) => tasksById[taskId])
|
.map((taskId: string) => tasksById[taskId])
|
||||||
.filter((task: Task | undefined): task is Task => task !== undefined);
|
.filter((task: Task | undefined): task is Task => task !== undefined);
|
||||||
}, [group.taskIds, tasksById]);
|
}, [group.taskIds, tasksById, group.id]);
|
||||||
|
|
||||||
// Calculate selection state for the group checkbox
|
// Calculate selection state for the group checkbox
|
||||||
const selectionState = useMemo(() => {
|
const selectionState = useMemo(() => {
|
||||||
@@ -297,42 +328,19 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
|||||||
return 20; // Very large lists: 20 items overscan for smooth scrolling
|
return 20; // Very large lists: 20 items overscan for smooth scrolling
|
||||||
}, [groupTasks.length]);
|
}, [groupTasks.length]);
|
||||||
|
|
||||||
// PERFORMANCE OPTIMIZATION: Memoize row renderer with better dependency management
|
// Build displayRows array
|
||||||
const Row = useCallback(({ index, style }: { index: number; style: React.CSSProperties }) => {
|
const displayRows: Array<{ type: 'task'; task: Task } | { type: 'add-subtask'; parentTask: Task }> = [];
|
||||||
const task: Task | undefined = groupTasks[index];
|
for (let i = 0; i < groupTasks.length; i++) {
|
||||||
if (!task) return null;
|
const task = groupTasks[i];
|
||||||
|
displayRows.push({ type: 'task', task });
|
||||||
// PERFORMANCE OPTIMIZATION: Pre-calculate selection state
|
if (showAddSubtaskForTaskId === task.id) {
|
||||||
const isSelected = selectedTaskIds.includes(task.id);
|
displayRows.push({ type: 'add-subtask', parentTask: task });
|
||||||
|
}
|
||||||
return (
|
}
|
||||||
<div
|
|
||||||
className="task-row-container"
|
|
||||||
style={{
|
|
||||||
...style,
|
|
||||||
marginLeft: '4px', // Account for sticky border
|
|
||||||
'--group-color': group.color || '#f0f0f0',
|
|
||||||
contain: 'layout style', // CSS containment for better performance
|
|
||||||
} as React.CSSProperties}
|
|
||||||
>
|
|
||||||
<TaskRow
|
|
||||||
task={task}
|
|
||||||
projectId={projectId}
|
|
||||||
groupId={group.id}
|
|
||||||
currentGrouping={currentGrouping}
|
|
||||||
isSelected={isSelected}
|
|
||||||
index={index}
|
|
||||||
onSelect={onSelectTask}
|
|
||||||
onToggleSubtasks={onToggleSubtasks}
|
|
||||||
fixedColumns={fixedColumns}
|
|
||||||
scrollableColumns={scrollableColumns}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}, [group.id, group.color, groupTasks, projectId, currentGrouping, selectedTaskIds, onSelectTask, onToggleSubtasks, fixedColumns, scrollableColumns]);
|
|
||||||
|
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const headerScrollRef = useRef<HTMLDivElement>(null);
|
const headerScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const listRef = useRef<FixedSizeList>(null);
|
||||||
|
|
||||||
// PERFORMANCE OPTIMIZATION: Throttled scroll handler
|
// PERFORMANCE OPTIMIZATION: Throttled scroll handler
|
||||||
const handleScroll = useCallback(() => {
|
const handleScroll = useCallback(() => {
|
||||||
@@ -544,57 +552,177 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
|||||||
contain: 'layout style', // CSS containment for better performance
|
contain: 'layout style', // CSS containment for better performance
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SortableContext items={group.taskIds} strategy={verticalListSortingStrategy}>
|
<SortableContext
|
||||||
|
items={group.taskIds}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
{shouldVirtualize ? (
|
{shouldVirtualize ? (
|
||||||
<List
|
<List
|
||||||
height={availableTaskRowsHeight}
|
height={availableTaskRowsHeight}
|
||||||
width={totalTableWidth}
|
itemCount={displayRows.length}
|
||||||
itemCount={groupTasks.length}
|
|
||||||
itemSize={TASK_ROW_HEIGHT}
|
itemSize={TASK_ROW_HEIGHT}
|
||||||
|
width={totalTableWidth}
|
||||||
|
ref={listRef}
|
||||||
overscanCount={overscanCount}
|
overscanCount={overscanCount}
|
||||||
className="react-window-list"
|
|
||||||
style={{ minWidth: totalTableWidth }}
|
|
||||||
// PERFORMANCE OPTIMIZATION: Remove all expensive props for maximum performance
|
|
||||||
useIsScrolling={false}
|
|
||||||
itemData={undefined}
|
|
||||||
// Disable all animations and transitions
|
|
||||||
onItemsRendered={() => {}}
|
|
||||||
onScroll={() => {}}
|
|
||||||
>
|
>
|
||||||
{Row}
|
{({ index, style }) => {
|
||||||
|
const row = displayRows[index];
|
||||||
|
if (row.type === 'task') {
|
||||||
|
return (
|
||||||
|
<div style={style} key={row.task.id}>
|
||||||
|
<TaskRow
|
||||||
|
task={row.task}
|
||||||
|
projectId={projectId}
|
||||||
|
groupId={group.id}
|
||||||
|
currentGrouping={currentGrouping}
|
||||||
|
isSelected={selectedTaskIds.includes(row.task.id)}
|
||||||
|
index={index}
|
||||||
|
onSelect={onSelectTask}
|
||||||
|
onToggleSubtasks={onToggleSubtasks}
|
||||||
|
fixedColumns={fixedColumns}
|
||||||
|
scrollableColumns={scrollableColumns}
|
||||||
|
onExpandSubtaskInput={() => setShowAddSubtaskForTaskId(row.task.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (row.type === 'add-subtask') {
|
||||||
|
return (
|
||||||
|
<div style={style} key={row.parentTask.id + '-add-subtask'} className={`add-subtask-row visible ${isDarkMode ? 'dark' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="task-row-container flex h-10 max-h-10 relative w-full">
|
||||||
|
<div className="task-table-all-columns flex w-full">
|
||||||
|
{(fixedColumns ?? []).map((col, index) => {
|
||||||
|
const borderClasses = `border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||||
|
if (col.key === 'task') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
className={`flex items-center px-2 ${borderClasses}`}
|
||||||
|
style={{ width: col.width }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0 pl-6">
|
||||||
|
<Input
|
||||||
|
ref={addSubtaskInputRef}
|
||||||
|
placeholder={t('enterSubtaskName')}
|
||||||
|
value={newSubtaskName}
|
||||||
|
onChange={e => setNewSubtaskName(e.target.value)}
|
||||||
|
onPressEnter={() => handleAddSubtask(row.parentTask.id)}
|
||||||
|
onBlur={handleCancelAddSubtask}
|
||||||
|
className={`add-subtask-input flex-1 ${isDarkMode ? 'bg-gray-700 border-gray-600 text-gray-200' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||||
|
size="small"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
className={`flex items-center px-2 ${borderClasses}`}
|
||||||
|
style={{ width: col.width }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
{(scrollableColumns ?? []).map((col, index) => {
|
||||||
|
const borderClasses = `border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
className={`flex items-center px-2 ${borderClasses}`}
|
||||||
|
style={{ width: col.width }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}}
|
||||||
</List>
|
</List>
|
||||||
) : (
|
) : (
|
||||||
// PERFORMANCE OPTIMIZATION: Use React.Fragment to reduce DOM nodes
|
// PERFORMANCE OPTIMIZATION: Use React.Fragment to reduce DOM nodes
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
{groupTasks.map((task: Task, index: number) => {
|
{displayRows.map((row, idx) => {
|
||||||
// PERFORMANCE OPTIMIZATION: Pre-calculate selection state
|
if (row.type === 'task') {
|
||||||
const isSelected = selectedTaskIds.includes(task.id);
|
return (
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={task.id}
|
|
||||||
className="task-row-container"
|
|
||||||
style={{
|
|
||||||
height: TASK_ROW_HEIGHT,
|
|
||||||
marginLeft: '4px', // Account for sticky border
|
|
||||||
'--group-color': group.color || '#f0f0f0',
|
|
||||||
contain: 'layout style', // CSS containment
|
|
||||||
} as React.CSSProperties}
|
|
||||||
>
|
|
||||||
<TaskRow
|
<TaskRow
|
||||||
task={task}
|
key={row.task.id}
|
||||||
|
task={row.task}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
groupId={group.id}
|
groupId={group.id}
|
||||||
currentGrouping={currentGrouping}
|
currentGrouping={currentGrouping}
|
||||||
isSelected={isSelected}
|
isSelected={selectedTaskIds.includes(row.task.id)}
|
||||||
index={index}
|
index={idx}
|
||||||
onSelect={onSelectTask}
|
onSelect={onSelectTask}
|
||||||
onToggleSubtasks={onToggleSubtasks}
|
onToggleSubtasks={onToggleSubtasks}
|
||||||
fixedColumns={fixedColumns}
|
fixedColumns={fixedColumns}
|
||||||
scrollableColumns={scrollableColumns}
|
scrollableColumns={scrollableColumns}
|
||||||
|
onExpandSubtaskInput={() => setShowAddSubtaskForTaskId(row.task.id)}
|
||||||
/>
|
/>
|
||||||
</div>
|
);
|
||||||
);
|
}
|
||||||
|
if (row.type === 'add-subtask') {
|
||||||
|
return (
|
||||||
|
<div key={row.parentTask.id + '-add-subtask'} className={`add-subtask-row visible ${isDarkMode ? 'dark' : ''}`}
|
||||||
|
style={{ display: 'flex', alignItems: 'center', minHeight: 40 }}
|
||||||
|
>
|
||||||
|
<div className="task-row-container flex h-10 max-h-10 relative w-full">
|
||||||
|
<div className="task-table-all-columns flex w-full">
|
||||||
|
{(fixedColumns ?? []).map((col, index) => {
|
||||||
|
const borderClasses = `border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||||
|
if (col.key === 'task') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
className={`flex items-center px-2 ${borderClasses}`}
|
||||||
|
style={{ width: col.width }}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 flex-1 min-w-0 pl-6">
|
||||||
|
<Input
|
||||||
|
ref={addSubtaskInputRef}
|
||||||
|
placeholder={t('enterSubtaskName')}
|
||||||
|
value={newSubtaskName}
|
||||||
|
onChange={e => setNewSubtaskName(e.target.value)}
|
||||||
|
onPressEnter={() => handleAddSubtask(row.parentTask.id)}
|
||||||
|
onBlur={handleCancelAddSubtask}
|
||||||
|
className={`add-subtask-input flex-1 ${isDarkMode ? 'bg-gray-700 border-gray-600 text-gray-200' : 'bg-white border-gray-300 text-gray-900'}`}
|
||||||
|
size="small"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
className={`flex items-center px-2 ${borderClasses}`}
|
||||||
|
style={{ width: col.width }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
{(scrollableColumns ?? []).map((col, index) => {
|
||||||
|
const borderClasses = `border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={col.key}
|
||||||
|
className={`flex items-center px-2 ${borderClasses}`}
|
||||||
|
style={{ width: col.width }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
})}
|
})}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
)}
|
)}
|
||||||
@@ -684,7 +812,7 @@ const VirtualizedTaskList: React.FC<VirtualizedTaskListProps> = React.memo(({
|
|||||||
}
|
}
|
||||||
.task-group-header-text {
|
.task-group-header-text {
|
||||||
color: white !important;
|
color: white !important;
|
||||||
font-size: 13px !important;
|
font-size: 14px !important;
|
||||||
font-weight: 600 !important;
|
font-weight: 600 !important;
|
||||||
margin: 0 !important;
|
margin: 0 !important;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ const TaskTemplateDrawer = ({
|
|||||||
fetchTemplateData();
|
fetchTemplateData();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// Tasks should already have the name property set correctly
|
||||||
setTemplateData({ tasks: selectedTasks });
|
setTemplateData({ tasks: selectedTasks });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -126,7 +127,7 @@ const TaskTemplateDrawer = ({
|
|||||||
open={showDrawer}
|
open={showDrawer}
|
||||||
onClose={onCloseDrawer}
|
onClose={onCloseDrawer}
|
||||||
afterOpenChange={afterOpenChange}
|
afterOpenChange={afterOpenChange}
|
||||||
destroyOnClose={true}
|
destroyOnHidden={true}
|
||||||
footer={
|
footer={
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', justifyContent: 'right' }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', justifyContent: 'right' }}>
|
||||||
<Button onClick={onCloseDrawer}>{t('cancelButton')}</Button>
|
<Button onClick={onCloseDrawer}>{t('cancelButton')}</Button>
|
||||||
|
|||||||
@@ -35,6 +35,23 @@ const LabelsDropdown = ({
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Filter labels based on search input
|
||||||
|
const filteredLabels = useMemo(() => {
|
||||||
|
if (!createLabelText.trim()) {
|
||||||
|
return labelsList;
|
||||||
|
}
|
||||||
|
return labelsList.filter(label =>
|
||||||
|
label.name?.toLowerCase().includes(createLabelText.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [labelsList, createLabelText]);
|
||||||
|
|
||||||
|
// Check if the search text matches any existing label exactly
|
||||||
|
const exactMatch = useMemo(() => {
|
||||||
|
return labelsList.some(label =>
|
||||||
|
label.name?.toLowerCase() === createLabelText.toLowerCase()
|
||||||
|
);
|
||||||
|
}, [labelsList, createLabelText]);
|
||||||
|
|
||||||
const isOnApply = () => {
|
const isOnApply = () => {
|
||||||
if (!createLabelText.trim() && selectedLabels.length === 0) return;
|
if (!createLabelText.trim() && selectedLabels.length === 0) return;
|
||||||
onApply();
|
onApply();
|
||||||
@@ -42,18 +59,17 @@ const LabelsDropdown = ({
|
|||||||
return (
|
return (
|
||||||
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
|
<Card className="custom-card" styles={{ body: { padding: 8 } }}>
|
||||||
<Flex vertical>
|
<Flex vertical>
|
||||||
{/* Always show the list, filtered by input */}
|
{/* Show filtered labels list */}
|
||||||
{!createLabelText && (
|
<List
|
||||||
<List
|
style={{
|
||||||
style={{
|
padding: 0,
|
||||||
padding: 0,
|
overflow: 'auto',
|
||||||
overflow: 'auto',
|
maxHeight: filteredLabels.length > 10 ? '200px' : 'auto',
|
||||||
maxHeight: labelsList.length > 10 ? '200px' : 'auto', // Set max height if more than 10 labels
|
maxWidth: 250,
|
||||||
maxWidth: 250,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{labelsList.length > 0 && (
|
{filteredLabels.length > 0 ? (
|
||||||
labelsList.map(label => (
|
filteredLabels.map(label => (
|
||||||
<List.Item
|
<List.Item
|
||||||
className={themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'}
|
className={themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'}
|
||||||
key={label.id}
|
key={label.id}
|
||||||
@@ -75,30 +91,68 @@ const LabelsDropdown = ({
|
|||||||
</Checkbox>
|
</Checkbox>
|
||||||
</List.Item>
|
</List.Item>
|
||||||
))
|
))
|
||||||
)}
|
) : createLabelText.trim() ? (
|
||||||
</List>
|
<List.Item
|
||||||
)}
|
className={themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
padding: '4px 8px',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{t('noMatchingLabels')}
|
||||||
|
</Typography.Text>
|
||||||
|
</List.Item>
|
||||||
|
) : (
|
||||||
|
<List.Item
|
||||||
|
className={themeMode === 'dark' ? 'custom-list-item dark' : 'custom-list-item'}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
gap: 8,
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
padding: '4px 8px',
|
||||||
|
border: 'none',
|
||||||
|
cursor: 'pointer',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
|
{t('noLabels')}
|
||||||
|
</Typography.Text>
|
||||||
|
</List.Item>
|
||||||
|
)}
|
||||||
|
</List>
|
||||||
|
|
||||||
<Flex style={{ paddingTop: 8 }} vertical justify="space-between" gap={8}>
|
<Flex style={{ paddingTop: 8 }} vertical justify="space-between" gap={8}>
|
||||||
<Input
|
<Input
|
||||||
ref={labelsInputRef}
|
ref={labelsInputRef}
|
||||||
value={createLabelText}
|
value={createLabelText}
|
||||||
onChange={e => onCreateLabelTextChange(e.currentTarget.value)}
|
onChange={e => onCreateLabelTextChange(e.currentTarget.value)}
|
||||||
placeholder={t('createLabel')}
|
placeholder={t('searchOrCreateLabel')}
|
||||||
onPressEnter={() => {
|
onPressEnter={() => {
|
||||||
isOnApply();
|
isOnApply();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{createLabelText && (
|
{createLabelText && (
|
||||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||||
{t('hitEnterToCreate')}
|
{exactMatch
|
||||||
|
? t('labelExists')
|
||||||
|
: t('hitEnterToCreate')
|
||||||
|
}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
)}
|
)}
|
||||||
{!createLabelText && (
|
<Button
|
||||||
<Button type="primary" size="small" onClick={isOnApply} style={{ width: '100%' }}>
|
type="primary"
|
||||||
{t('apply')}
|
size="small"
|
||||||
</Button>
|
onClick={isOnApply}
|
||||||
)}
|
style={{ width: '100%' }}
|
||||||
|
disabled={!createLabelText.trim() && selectedLabels.length === 0}
|
||||||
|
>
|
||||||
|
{t('apply')}
|
||||||
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Flex>
|
</Flex>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -157,9 +157,6 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
// Get search value from taskReducer
|
// Get search value from taskReducer
|
||||||
const searchValue = state.taskReducer.search || '';
|
const searchValue = state.taskReducer.search || '';
|
||||||
|
|
||||||
console.log('fetchTasksV3 - selectedPriorities:', selectedPriorities);
|
|
||||||
console.log('fetchTasksV3 - searchValue:', searchValue);
|
|
||||||
|
|
||||||
const config: ITaskListConfigV2 = {
|
const config: ITaskListConfigV2 = {
|
||||||
id: projectId,
|
id: projectId,
|
||||||
archived: false,
|
archived: false,
|
||||||
@@ -211,6 +208,60 @@ export const refreshTaskProgress = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Async thunk to reorder tasks with API call
|
||||||
|
export const reorderTasksWithAPI = createAsyncThunk(
|
||||||
|
'taskManagement/reorderTasksWithAPI',
|
||||||
|
async ({ taskIds, newOrder, projectId }: { taskIds: string[]; newOrder: number[]; projectId: string }, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
// Make API call to update task order
|
||||||
|
const response = await tasksApiService.reorderTasks({
|
||||||
|
taskIds,
|
||||||
|
newOrder,
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.done) {
|
||||||
|
return { taskIds, newOrder };
|
||||||
|
} else {
|
||||||
|
return rejectWithValue('Failed to reorder tasks');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Reorder Tasks API Error:', error);
|
||||||
|
return rejectWithValue('Failed to reorder tasks');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Async thunk to move task between groups with API call
|
||||||
|
export const moveTaskToGroupWithAPI = createAsyncThunk(
|
||||||
|
'taskManagement/moveTaskToGroupWithAPI',
|
||||||
|
async ({ taskId, groupType, groupValue, projectId }: {
|
||||||
|
taskId: string;
|
||||||
|
groupType: 'status' | 'priority' | 'phase';
|
||||||
|
groupValue: string;
|
||||||
|
projectId: string;
|
||||||
|
}, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
// Make API call to update task group
|
||||||
|
const response = await tasksApiService.updateTaskGroup({
|
||||||
|
taskId,
|
||||||
|
groupType,
|
||||||
|
groupValue,
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.done) {
|
||||||
|
return { taskId, groupType, groupValue };
|
||||||
|
} else {
|
||||||
|
return rejectWithValue('Failed to move task');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Move Task API Error:', error);
|
||||||
|
return rejectWithValue('Failed to move task');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const taskManagementSlice = createSlice({
|
const taskManagementSlice = createSlice({
|
||||||
name: 'taskManagement',
|
name: 'taskManagement',
|
||||||
initialState: tasksAdapter.getInitialState(initialState),
|
initialState: tasksAdapter.getInitialState(initialState),
|
||||||
@@ -331,15 +382,6 @@ const taskManagementSlice = createSlice({
|
|||||||
}>) => {
|
}>) => {
|
||||||
const { taskId, fromGroupId, toGroupId, taskUpdate } = action.payload;
|
const { taskId, fromGroupId, toGroupId, taskUpdate } = action.payload;
|
||||||
|
|
||||||
console.log('🔧 moveTaskBetweenGroups action:', {
|
|
||||||
taskId,
|
|
||||||
fromGroupId,
|
|
||||||
toGroupId,
|
|
||||||
taskUpdate,
|
|
||||||
hasGroups: !!state.groups,
|
|
||||||
groupsCount: state.groups?.length || 0
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update the task entity with new values
|
// Update the task entity with new values
|
||||||
tasksAdapter.updateOne(state, {
|
tasksAdapter.updateOne(state, {
|
||||||
id: taskId,
|
id: taskId,
|
||||||
@@ -354,25 +396,15 @@ const taskManagementSlice = createSlice({
|
|||||||
// Remove task from old group
|
// Remove task from old group
|
||||||
const fromGroup = state.groups.find(group => group.id === fromGroupId);
|
const fromGroup = state.groups.find(group => group.id === fromGroupId);
|
||||||
if (fromGroup) {
|
if (fromGroup) {
|
||||||
const beforeCount = fromGroup.taskIds.length;
|
|
||||||
fromGroup.taskIds = fromGroup.taskIds.filter(id => id !== taskId);
|
fromGroup.taskIds = fromGroup.taskIds.filter(id => id !== taskId);
|
||||||
console.log(`🔧 Removed task from ${fromGroup.title}: ${beforeCount} -> ${fromGroup.taskIds.length}`);
|
|
||||||
} else {
|
|
||||||
console.warn('🚨 From group not found:', fromGroupId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add task to new group
|
// Add task to new group
|
||||||
const toGroup = state.groups.find(group => group.id === toGroupId);
|
const toGroup = state.groups.find(group => group.id === toGroupId);
|
||||||
if (toGroup) {
|
if (toGroup) {
|
||||||
const beforeCount = toGroup.taskIds.length;
|
|
||||||
// Add to the end of the group (newest last)
|
// Add to the end of the group (newest last)
|
||||||
toGroup.taskIds.push(taskId);
|
toGroup.taskIds.push(taskId);
|
||||||
console.log(`🔧 Added task to ${toGroup.title}: ${beforeCount} -> ${toGroup.taskIds.length}`);
|
|
||||||
} else {
|
|
||||||
console.warn('🚨 To group not found:', toGroupId);
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.warn('🚨 No groups available for task movement');
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -400,7 +432,76 @@ const taskManagementSlice = createSlice({
|
|||||||
changes.phase = groupValue;
|
changes.phase = groupValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update the task entity
|
||||||
tasksAdapter.updateOne(state, { id: taskId, changes });
|
tasksAdapter.updateOne(state, { id: taskId, changes });
|
||||||
|
|
||||||
|
// Update groups if they exist
|
||||||
|
if (state.groups && state.groups.length > 0) {
|
||||||
|
// Find the target group
|
||||||
|
const targetGroup = state.groups.find(group => group.id === newGroupId);
|
||||||
|
if (targetGroup) {
|
||||||
|
// Remove task from all groups first
|
||||||
|
state.groups.forEach(group => {
|
||||||
|
group.taskIds = group.taskIds.filter(id => id !== taskId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add task to target group at the specified index
|
||||||
|
if (newIndex >= targetGroup.taskIds.length) {
|
||||||
|
targetGroup.taskIds.push(taskId);
|
||||||
|
} else {
|
||||||
|
targetGroup.taskIds.splice(newIndex, 0, taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
// Proper reorder action that handles both task entities and group arrays
|
||||||
|
reorderTasksInGroup: (state, action: PayloadAction<{
|
||||||
|
taskId: string;
|
||||||
|
fromGroupId: string;
|
||||||
|
toGroupId: string;
|
||||||
|
fromIndex: number;
|
||||||
|
toIndex: number;
|
||||||
|
groupType: 'status' | 'priority' | 'phase';
|
||||||
|
groupValue: string;
|
||||||
|
}>) => {
|
||||||
|
const { taskId, fromGroupId, toGroupId, fromIndex, toIndex, groupType, groupValue } = action.payload;
|
||||||
|
|
||||||
|
// Update the task entity
|
||||||
|
const changes: Partial<Task> = {
|
||||||
|
order: toIndex,
|
||||||
|
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 });
|
||||||
|
|
||||||
|
// Update groups if they exist
|
||||||
|
if (state.groups && state.groups.length > 0) {
|
||||||
|
// Remove task from source group
|
||||||
|
const fromGroup = state.groups.find(group => group.id === fromGroupId);
|
||||||
|
if (fromGroup) {
|
||||||
|
fromGroup.taskIds = fromGroup.taskIds.filter(id => id !== taskId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add task to target group
|
||||||
|
const toGroup = state.groups.find(group => group.id === toGroupId);
|
||||||
|
if (toGroup) {
|
||||||
|
if (toIndex >= toGroup.taskIds.length) {
|
||||||
|
toGroup.taskIds.push(taskId);
|
||||||
|
} else {
|
||||||
|
toGroup.taskIds.splice(toIndex, 0, taskId);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -486,6 +587,7 @@ export const {
|
|||||||
moveTaskToGroup,
|
moveTaskToGroup,
|
||||||
moveTaskBetweenGroups,
|
moveTaskBetweenGroups,
|
||||||
optimisticTaskMove,
|
optimisticTaskMove,
|
||||||
|
reorderTasksInGroup,
|
||||||
setLoading,
|
setLoading,
|
||||||
setError,
|
setError,
|
||||||
setSelectedPriorities,
|
setSelectedPriorities,
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ function saveFields(fields: TaskListField[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const initialState: TaskListField[] = loadFields();
|
const initialState: TaskListField[] = loadFields();
|
||||||
console.log('TaskListFields slice initial state:', initialState);
|
|
||||||
|
|
||||||
const taskListFieldsSlice = createSlice({
|
const taskListFieldsSlice = createSlice({
|
||||||
name: 'taskManagementFields',
|
name: 'taskManagementFields',
|
||||||
|
|||||||
77
worklenz-frontend/src/hooks/useTranslationPreloader.ts
Normal file
77
worklenz-frontend/src/hooks/useTranslationPreloader.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ensureTranslationsLoaded } from '@/i18n';
|
||||||
|
|
||||||
|
interface UseTranslationPreloaderOptions {
|
||||||
|
namespaces?: string[];
|
||||||
|
fallback?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to ensure translations are loaded before rendering components
|
||||||
|
* This prevents Suspense issues when components use useTranslation
|
||||||
|
*/
|
||||||
|
export const useTranslationPreloader = (
|
||||||
|
namespaces: string[] = ['tasks/task-table-bulk-actions', 'task-management'],
|
||||||
|
options: UseTranslationPreloaderOptions = {}
|
||||||
|
) => {
|
||||||
|
const [isLoaded, setIsLoaded] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const { t, ready } = useTranslation(namespaces);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let isMounted = true;
|
||||||
|
|
||||||
|
const loadTranslations = async () => {
|
||||||
|
try {
|
||||||
|
setIsLoading(true);
|
||||||
|
|
||||||
|
// Ensure translations are loaded
|
||||||
|
await ensureTranslationsLoaded(namespaces);
|
||||||
|
|
||||||
|
// Wait for i18next to be ready
|
||||||
|
if (!ready) {
|
||||||
|
// If i18next is not ready, wait a bit and check again
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isMounted) {
|
||||||
|
setIsLoaded(true);
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (isMounted) {
|
||||||
|
setIsLoaded(true); // Still set as loaded to prevent infinite loading
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadTranslations();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
isMounted = false;
|
||||||
|
};
|
||||||
|
}, [namespaces, ready]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
t,
|
||||||
|
ready: isLoaded && ready,
|
||||||
|
isLoading,
|
||||||
|
isLoaded,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook specifically for bulk action bar translations
|
||||||
|
*/
|
||||||
|
export const useBulkActionTranslations = () => {
|
||||||
|
return useTranslationPreloader(['tasks/task-table-bulk-actions']);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for task management translations
|
||||||
|
*/
|
||||||
|
export const useTaskManagementTranslations = () => {
|
||||||
|
return useTranslationPreloader(['task-management', 'tasks/task-table-bulk-actions']);
|
||||||
|
};
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import { initReactI18next } from 'react-i18next';
|
import { initReactI18next } from 'react-i18next';
|
||||||
import HttpApi from 'i18next-http-backend';
|
import HttpApi from 'i18next-http-backend';
|
||||||
|
import logger from './utils/errorLogger';
|
||||||
|
|
||||||
|
// Essential namespaces that should be preloaded to prevent Suspense
|
||||||
|
const ESSENTIAL_NAMESPACES = [
|
||||||
|
'common',
|
||||||
|
'tasks/task-table-bulk-actions',
|
||||||
|
'task-management',
|
||||||
|
'auth/login',
|
||||||
|
'settings'
|
||||||
|
];
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(HttpApi)
|
.use(HttpApi)
|
||||||
@@ -11,9 +21,57 @@ i18n
|
|||||||
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
loadPath: '/locales/{{lng}}/{{ns}}.json',
|
||||||
},
|
},
|
||||||
defaultNS: 'common',
|
defaultNS: 'common',
|
||||||
|
ns: ESSENTIAL_NAMESPACES,
|
||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false,
|
escapeValue: false,
|
||||||
},
|
},
|
||||||
|
// Preload essential namespaces
|
||||||
|
preload: ['en', 'es', 'pt', 'alb', 'de'],
|
||||||
|
// Load all namespaces on initialization
|
||||||
|
load: 'languageOnly',
|
||||||
|
// Cache translations
|
||||||
|
cache: {
|
||||||
|
enabled: true,
|
||||||
|
expirationTime: 24 * 60 * 60 * 1000, // 24 hours
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Utility function to ensure translations are loaded
|
||||||
|
export const ensureTranslationsLoaded = async (namespaces: string[] = ESSENTIAL_NAMESPACES) => {
|
||||||
|
const currentLang = i18n.language || 'en';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Load all essential namespaces for the current language
|
||||||
|
await Promise.all(
|
||||||
|
namespaces.map(ns =>
|
||||||
|
i18n.loadNamespaces(ns).catch(() => {
|
||||||
|
logger.error(`Failed to load namespace: ${ns}`);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also preload for other languages to prevent delays on language switch
|
||||||
|
const otherLangs = ['en', 'es', 'pt', 'alb', 'de'].filter(lang => lang !== currentLang);
|
||||||
|
await Promise.all(
|
||||||
|
otherLangs.map(lang =>
|
||||||
|
Promise.all(
|
||||||
|
namespaces.map(ns =>
|
||||||
|
i18n.loadNamespaces(ns).catch(() => {
|
||||||
|
logger.error(`Failed to load namespace: ${ns}`);
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Failed to load translations:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize translations on app startup
|
||||||
|
ensureTranslationsLoaded();
|
||||||
|
|
||||||
export default i18n;
|
export default i18n;
|
||||||
|
|||||||
@@ -1,78 +0,0 @@
|
|||||||
import React, { useEffect } from 'react';
|
|
||||||
import { Layout, Typography, Card, Space, Alert } from 'antd';
|
|
||||||
import { useDispatch } from 'react-redux';
|
|
||||||
import TaskListBoard from '@/components/task-management/task-list-board';
|
|
||||||
import { AppDispatch } from '@/app/store';
|
|
||||||
|
|
||||||
const { Header, Content } = Layout;
|
|
||||||
const { Title, Paragraph } = Typography;
|
|
||||||
|
|
||||||
const TaskManagementDemo: React.FC = () => {
|
|
||||||
const dispatch = useDispatch<AppDispatch>();
|
|
||||||
|
|
||||||
// Mock project ID for demo
|
|
||||||
const demoProjectId = 'demo-project-123';
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Initialize demo data if needed
|
|
||||||
// You might want to populate some sample tasks here for demonstration
|
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Layout className="min-h-screen bg-gray-50">
|
|
||||||
<Header className="bg-white shadow-sm">
|
|
||||||
<div className="max-w-7xl mx-auto px-4">
|
|
||||||
<Title level={2} className="mb-0 text-gray-800">
|
|
||||||
Enhanced Task Management System
|
|
||||||
</Title>
|
|
||||||
</div>
|
|
||||||
</Header>
|
|
||||||
|
|
||||||
<Content className="max-w-7xl mx-auto px-4 py-6 w-full">
|
|
||||||
<Space direction="vertical" size="large" className="w-full">
|
|
||||||
{/* Introduction */}
|
|
||||||
<Card>
|
|
||||||
<Title level={3}>Task Management Features</Title>
|
|
||||||
<Paragraph>
|
|
||||||
This enhanced task management system provides a comprehensive interface for managing tasks
|
|
||||||
with the following key features:
|
|
||||||
</Paragraph>
|
|
||||||
<ul className="list-disc list-inside space-y-1 text-gray-700">
|
|
||||||
<li><strong>Dynamic Grouping:</strong> Group tasks by Status, Priority, or Phase</li>
|
|
||||||
<li><strong>Drag & Drop:</strong> Reorder tasks within groups or move between groups</li>
|
|
||||||
<li><strong>Multi-select:</strong> Select multiple tasks for bulk operations</li>
|
|
||||||
<li><strong>Bulk Actions:</strong> Change status, priority, assignees, or delete multiple tasks</li>
|
|
||||||
<li><strong>Subtasks:</strong> Expandable subtask support with progress tracking</li>
|
|
||||||
<li><strong>Real-time Updates:</strong> Live updates via WebSocket connections</li>
|
|
||||||
<li><strong>Rich Task Display:</strong> Progress bars, assignees, labels, due dates, and more</li>
|
|
||||||
</ul>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* Usage Instructions */}
|
|
||||||
<Alert
|
|
||||||
message="Demo Instructions"
|
|
||||||
description={
|
|
||||||
<div>
|
|
||||||
<p><strong>Grouping:</strong> Use the dropdown to switch between Status, Priority, and Phase grouping.</p>
|
|
||||||
<p><strong>Drag & Drop:</strong> Click and drag tasks to reorder within groups or move between groups.</p>
|
|
||||||
<p><strong>Selection:</strong> Click checkboxes to select tasks, then use bulk actions in the blue bar.</p>
|
|
||||||
<p><strong>Subtasks:</strong> Click the +/- buttons next to task names to expand/collapse subtasks.</p>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
type="info"
|
|
||||||
showIcon
|
|
||||||
className="mb-4"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Task List Board */}
|
|
||||||
<TaskListBoard
|
|
||||||
projectId={demoProjectId}
|
|
||||||
className="task-management-demo"
|
|
||||||
/>
|
|
||||||
</Space>
|
|
||||||
</Content>
|
|
||||||
</Layout>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TaskManagementDemo;
|
|
||||||
@@ -1,36 +1,80 @@
|
|||||||
import { Input } from 'antd';
|
import { Input, Button } from 'antd';
|
||||||
import React, { useState } from 'react';
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
|
import { useAppSelector } from '../../../../../../hooks/useAppSelector';
|
||||||
import { colors } from '../../../../../../styles/colors';
|
import { colors } from '../../../../../../styles/colors';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const AddSubTaskListRow = () => {
|
interface AddSubTaskListRowProps {
|
||||||
const [isEdit, setIsEdit] = useState<boolean>(false);
|
visibleColumns: { key: string; label: string; width: number }[];
|
||||||
|
taskColumnKey: string;
|
||||||
|
onAdd: (name: string) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
parentTaskId: string;
|
||||||
|
}
|
||||||
|
|
||||||
// localization
|
const AddSubTaskListRow: React.FC<AddSubTaskListRowProps> = ({
|
||||||
|
visibleColumns,
|
||||||
|
taskColumnKey,
|
||||||
|
onAdd,
|
||||||
|
onCancel,
|
||||||
|
}) => {
|
||||||
|
const [subtaskName, setSubtaskName] = useState('');
|
||||||
|
const inputRef = useRef<any>(null);
|
||||||
const { t } = useTranslation('task-list-table');
|
const { t } = useTranslation('task-list-table');
|
||||||
|
|
||||||
// get data theme data from redux
|
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
const customBorderColor = themeMode === 'dark' && ' border-[#303030]';
|
const customBorderColor = themeMode === 'dark' ? ' border-[#303030]' : '';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter' && subtaskName.trim()) {
|
||||||
|
onAdd(subtaskName.trim());
|
||||||
|
setSubtaskName('');
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
onCancel();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`border-t ${customBorderColor}`}>
|
<tr className={`add-subtask-row${customBorderColor}`}>
|
||||||
{isEdit ? (
|
{visibleColumns.map(col => (
|
||||||
<Input
|
<td key={col.key} style={{ padding: 0, background: 'inherit' }}>
|
||||||
className="h-12 w-full rounded-none"
|
{col.key === taskColumnKey ? (
|
||||||
style={{ borderColor: colors.skyBlue }}
|
<div style={{ display: 'flex', alignItems: 'center', padding: '4px 0' }}>
|
||||||
placeholder={t('addTaskInputPlaceholder')}
|
<Input
|
||||||
onBlur={() => setIsEdit(false)}
|
ref={inputRef}
|
||||||
/>
|
value={subtaskName}
|
||||||
) : (
|
onChange={e => setSubtaskName(e.target.value)}
|
||||||
<Input
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => setIsEdit(true)}
|
onBlur={onCancel}
|
||||||
className="w-[300px] border-none"
|
placeholder={t('enterSubtaskName')}
|
||||||
value={t('addSubTaskText')}
|
style={{ width: '100%' }}
|
||||||
/>
|
autoFocus
|
||||||
)}
|
/>
|
||||||
</div>
|
<Button
|
||||||
|
size="small"
|
||||||
|
type="primary"
|
||||||
|
style={{ marginLeft: 8 }}
|
||||||
|
disabled={!subtaskName.trim()}
|
||||||
|
onClick={() => {
|
||||||
|
if (subtaskName.trim()) {
|
||||||
|
onAdd(subtaskName.trim());
|
||||||
|
setSubtaskName('');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('add')}
|
||||||
|
</Button>
|
||||||
|
<Button size="small" style={{ marginLeft: 4 }} onClick={onCancel}>
|
||||||
|
{t('cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1365,6 +1365,7 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
|||||||
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
const [contextMenuPosition, setContextMenuPosition] = useState({ x: 0, y: 0 });
|
||||||
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
const [editingTaskId, setEditingTaskId] = useState<string | null>(null);
|
||||||
const [editColumnKey, setEditColumnKey] = useState<string | null>(null);
|
const [editColumnKey, setEditColumnKey] = useState<string | null>(null);
|
||||||
|
const [showAddSubtaskFor, setShowAddSubtaskFor] = useState<string | null>(null);
|
||||||
|
|
||||||
const toggleTaskExpansion = (taskId: string) => {
|
const toggleTaskExpansion = (taskId: string) => {
|
||||||
const task = displayTasks.find(t => t.id === taskId);
|
const task = displayTasks.find(t => t.id === taskId);
|
||||||
@@ -1857,14 +1858,38 @@ const TaskListTable: React.FC<TaskListTableProps> = ({ taskList, tableId, active
|
|||||||
{renderTaskRow(updatedTask)}
|
{renderTaskRow(updatedTask)}
|
||||||
{updatedTask.show_sub_tasks && (
|
{updatedTask.show_sub_tasks && (
|
||||||
<>
|
<>
|
||||||
{updatedTask?.sub_tasks?.map(subtask =>
|
{updatedTask?.sub_tasks?.map(subtask =>
|
||||||
subtask?.id ? renderTaskRow(subtask, true) : null
|
subtask?.id ? renderTaskRow(subtask, true) : null
|
||||||
)}
|
)}
|
||||||
<tr key={`add-subtask-${updatedTask.id}`}>
|
{showAddSubtaskFor !== updatedTask.id && (
|
||||||
<td colSpan={visibleColumns.length + 1}>
|
<tr key={`add-subtask-link-${updatedTask.id}`}>
|
||||||
<AddTaskListRow groupId={tableId} parentTask={updatedTask.id} />
|
<td colSpan={visibleColumns.length + 1}>
|
||||||
</td>
|
<div
|
||||||
</tr>
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
color: '#1677ff',
|
||||||
|
cursor: 'pointer',
|
||||||
|
fontWeight: 500,
|
||||||
|
background: '#f6f8fa'
|
||||||
|
}}
|
||||||
|
onClick={() => setShowAddSubtaskFor(updatedTask.id)}
|
||||||
|
>
|
||||||
|
+ Add Sub Task
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
{showAddSubtaskFor === updatedTask.id && (
|
||||||
|
<tr key={`add-subtask-input-${updatedTask.id}`}>
|
||||||
|
<td colSpan={visibleColumns.length + 1}>
|
||||||
|
<AddTaskListRow
|
||||||
|
groupId={tableId}
|
||||||
|
parentTask={updatedTask.id}
|
||||||
|
onCancel={() => setShowAddSubtaskFor(null)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|||||||
@@ -87,7 +87,17 @@ export {
|
|||||||
TableOutlined,
|
TableOutlined,
|
||||||
BarChartOutlined,
|
BarChartOutlined,
|
||||||
FileOutlined,
|
FileOutlined,
|
||||||
MessageOutlined
|
MessageOutlined,
|
||||||
|
FlagOutlined,
|
||||||
|
GroupOutlined,
|
||||||
|
EyeOutlined,
|
||||||
|
InboxOutlined,
|
||||||
|
PaperClipOutlined,
|
||||||
|
HolderOutlined,
|
||||||
|
ExpandAltOutlined,
|
||||||
|
CheckCircleOutlined,
|
||||||
|
MinusCircleOutlined,
|
||||||
|
RetweetOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
// Re-export all components with React
|
// Re-export all components with React
|
||||||
@@ -196,4 +206,48 @@ export default {
|
|||||||
config: antdConfig,
|
config: antdConfig,
|
||||||
message: appMessage,
|
message: appMessage,
|
||||||
notification: appNotification,
|
notification: appNotification,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Commonly used Ant Design configurations for task management
|
||||||
|
export const taskManagementAntdConfig = {
|
||||||
|
// DatePicker default props for consistency
|
||||||
|
datePickerDefaults: {
|
||||||
|
format: 'MMM DD, YYYY',
|
||||||
|
placeholder: 'Set Date',
|
||||||
|
suffixIcon: null,
|
||||||
|
size: 'small' as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Button default props for task actions
|
||||||
|
taskButtonDefaults: {
|
||||||
|
size: 'small' as const,
|
||||||
|
type: 'text' as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Input default props for task editing
|
||||||
|
taskInputDefaults: {
|
||||||
|
size: 'small' as const,
|
||||||
|
variant: 'borderless' as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Select default props for dropdowns
|
||||||
|
taskSelectDefaults: {
|
||||||
|
size: 'small' as const,
|
||||||
|
variant: 'borderless' as const,
|
||||||
|
showSearch: true,
|
||||||
|
optionFilterProp: 'label' as const,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tooltip default props
|
||||||
|
tooltipDefaults: {
|
||||||
|
placement: 'top' as const,
|
||||||
|
mouseEnterDelay: 0.5,
|
||||||
|
mouseLeaveDelay: 0.1,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Dropdown default props
|
||||||
|
dropdownDefaults: {
|
||||||
|
trigger: ['click'] as const,
|
||||||
|
placement: 'bottomLeft' as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ export interface Task {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
order: number;
|
order: number;
|
||||||
|
// Subtask-related properties
|
||||||
|
sub_tasks_count?: number;
|
||||||
|
show_sub_tasks?: boolean;
|
||||||
|
sub_tasks?: Task[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskGroup {
|
export interface TaskGroup {
|
||||||
|
|||||||
Reference in New Issue
Block a user