Merge pull request #251 from shancds/test/row-kanban-board-v1.1.5
refactor(task-list): streamline TaskListV2 component and improve stru…
This commit is contained in:
@@ -69,13 +69,13 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static getFilterByProjectsWhereClosure(text: string) {
|
private static getFilterByProjectsWhereClosure(text: string) {
|
||||||
return text ? `t.project_id IN (${this.flatString(text)})` : "";
|
return text ? `project_id IN (${this.flatString(text)})` : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getFilterByAssignee(filterBy: string) {
|
private static getFilterByAssignee(filterBy: string) {
|
||||||
return filterBy === "member"
|
return filterBy === "member"
|
||||||
? `t.id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id = $1)`
|
? `id IN (SELECT task_id FROM tasks_assignees WHERE team_member_id = $1)`
|
||||||
: "t.project_id = $1";
|
: "project_id = $1";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static getStatusesQuery(filterBy: string) {
|
private static getStatusesQuery(filterBy: string) {
|
||||||
@@ -131,19 +131,41 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
// Returns statuses of each task as a json array if filterBy === "member"
|
// Returns statuses of each task as a json array if filterBy === "member"
|
||||||
const statusesQuery = TasksControllerV2.getStatusesQuery(options.filterBy as string);
|
const statusesQuery = TasksControllerV2.getStatusesQuery(options.filterBy as string);
|
||||||
|
|
||||||
// Custom columns data query - optimized with LEFT JOIN
|
// Custom columns data query
|
||||||
const customColumnsQuery = options.customColumns
|
const customColumnsQuery = options.customColumns
|
||||||
? `, COALESCE(cc_data.custom_column_values, '{}'::JSONB) AS custom_column_values`
|
? `, (SELECT COALESCE(
|
||||||
|
jsonb_object_agg(
|
||||||
|
custom_cols.key,
|
||||||
|
custom_cols.value
|
||||||
|
),
|
||||||
|
'{}'::JSONB
|
||||||
|
)
|
||||||
|
FROM (
|
||||||
|
SELECT
|
||||||
|
cc.key,
|
||||||
|
CASE
|
||||||
|
WHEN ccv.text_value IS NOT NULL THEN to_jsonb(ccv.text_value)
|
||||||
|
WHEN ccv.number_value IS NOT NULL THEN to_jsonb(ccv.number_value)
|
||||||
|
WHEN ccv.boolean_value IS NOT NULL THEN to_jsonb(ccv.boolean_value)
|
||||||
|
WHEN ccv.date_value IS NOT NULL THEN to_jsonb(ccv.date_value)
|
||||||
|
WHEN ccv.json_value IS NOT NULL THEN ccv.json_value
|
||||||
|
ELSE NULL::JSONB
|
||||||
|
END AS value
|
||||||
|
FROM cc_column_values ccv
|
||||||
|
JOIN cc_custom_columns cc ON ccv.column_id = cc.id
|
||||||
|
WHERE ccv.task_id = t.id
|
||||||
|
) AS custom_cols
|
||||||
|
WHERE custom_cols.value IS NOT NULL) AS custom_column_values`
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
const archivedFilter = options.archived === "true" ? "t.archived IS TRUE" : "t.archived IS FALSE";
|
const archivedFilter = options.archived === "true" ? "archived IS TRUE" : "archived IS FALSE";
|
||||||
|
|
||||||
let subTasksFilter;
|
let subTasksFilter;
|
||||||
|
|
||||||
if (options.isSubtasksInclude === "true") {
|
if (options.isSubtasksInclude === "true") {
|
||||||
subTasksFilter = "";
|
subTasksFilter = "";
|
||||||
} else {
|
} else {
|
||||||
subTasksFilter = isSubTasks ? "t.parent_task_id = $2" : "t.parent_task_id IS NULL";
|
subTasksFilter = isSubTasks ? "parent_task_id = $2" : "parent_task_id IS NULL";
|
||||||
}
|
}
|
||||||
|
|
||||||
const filters = [
|
const filters = [
|
||||||
@@ -157,171 +179,94 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
projectsFilter
|
projectsFilter
|
||||||
].filter(i => !!i).join(" AND ");
|
].filter(i => !!i).join(" AND ");
|
||||||
|
|
||||||
// PERFORMANCE OPTIMIZED QUERY - Using CTEs and JOINs instead of correlated subqueries
|
|
||||||
return `
|
return `
|
||||||
WITH task_aggregates AS (
|
SELECT id,
|
||||||
SELECT
|
name,
|
||||||
t.id,
|
CONCAT((SELECT key FROM projects WHERE id = t.project_id), '-', task_no) AS task_key,
|
||||||
COUNT(DISTINCT sub.id) AS sub_tasks_count,
|
(SELECT name FROM projects WHERE id = t.project_id) AS project_name,
|
||||||
COUNT(DISTINCT CASE WHEN sub_status.is_done THEN sub.id END) AS completed_sub_tasks,
|
t.project_id AS project_id,
|
||||||
COUNT(DISTINCT tc.id) AS comments_count,
|
t.parent_task_id,
|
||||||
COUNT(DISTINCT ta.id) AS attachments_count,
|
t.parent_task_id IS NOT NULL AS is_sub_task,
|
||||||
COUNT(DISTINCT twl.id) AS work_log_count,
|
(SELECT name FROM tasks WHERE id = t.parent_task_id) AS parent_task_name,
|
||||||
COALESCE(SUM(twl.time_spent), 0) AS total_minutes_spent,
|
(SELECT COUNT(*)
|
||||||
MAX(CASE WHEN ts.id IS NOT NULL THEN 1 ELSE 0 END) AS has_subscribers,
|
FROM tasks
|
||||||
MAX(CASE WHEN td.id IS NOT NULL THEN 1 ELSE 0 END) AS has_dependencies
|
WHERE parent_task_id = t.id)::INT AS sub_tasks_count,
|
||||||
FROM tasks t
|
|
||||||
LEFT JOIN tasks sub ON t.id = sub.parent_task_id AND sub.archived = FALSE
|
t.status_id AS status,
|
||||||
LEFT JOIN task_statuses sub_ts ON sub.status_id = sub_ts.id
|
t.archived,
|
||||||
LEFT JOIN sys_task_status_categories sub_status ON sub_ts.category_id = sub_status.id
|
t.description,
|
||||||
LEFT JOIN task_comments tc ON t.id = tc.task_id
|
t.sort_order,
|
||||||
LEFT JOIN task_attachments ta ON t.id = ta.task_id
|
t.progress_value,
|
||||||
LEFT JOIN task_work_log twl ON t.id = twl.task_id
|
t.manual_progress,
|
||||||
LEFT JOIN task_subscribers ts ON t.id = ts.task_id
|
t.weight,
|
||||||
LEFT JOIN task_dependencies td ON t.id = td.task_id
|
(SELECT use_manual_progress FROM projects WHERE id = t.project_id) AS project_use_manual_progress,
|
||||||
WHERE t.project_id = $1 AND t.archived = FALSE
|
(SELECT use_weighted_progress FROM projects WHERE id = t.project_id) AS project_use_weighted_progress,
|
||||||
GROUP BY t.id
|
(SELECT use_time_progress FROM projects WHERE id = t.project_id) AS project_use_time_progress,
|
||||||
),
|
(SELECT get_task_complete_ratio(t.id)->>'ratio') AS complete_ratio,
|
||||||
task_assignees AS (
|
|
||||||
SELECT
|
(SELECT phase_id FROM task_phase WHERE task_id = t.id) AS phase_id,
|
||||||
ta.task_id,
|
(SELECT name
|
||||||
JSON_AGG(JSON_BUILD_OBJECT(
|
FROM project_phases
|
||||||
'team_member_id', ta.team_member_id,
|
WHERE id = (SELECT phase_id FROM task_phase WHERE task_id = t.id)) AS phase_name,
|
||||||
'project_member_id', ta.project_member_id,
|
(SELECT color_code
|
||||||
'name', COALESCE(tmiv.name, ''),
|
FROM project_phases
|
||||||
'avatar_url', COALESCE(tmiv.avatar_url, ''),
|
WHERE id = (SELECT phase_id FROM task_phase WHERE task_id = t.id)) AS phase_color_code,
|
||||||
'email', COALESCE(tmiv.email, ''),
|
|
||||||
'user_id', tmiv.user_id,
|
(EXISTS(SELECT 1 FROM task_subscribers WHERE task_id = t.id)) AS has_subscribers,
|
||||||
'socket_id', COALESCE(u.socket_id, ''),
|
(EXISTS(SELECT 1 FROM task_dependencies td WHERE td.task_id = t.id)) AS has_dependencies,
|
||||||
'team_id', tmiv.team_id,
|
(SELECT start_time
|
||||||
'email_notifications_enabled', COALESCE(ns.email_notifications_enabled, false)
|
FROM task_timers
|
||||||
)) AS assignees,
|
WHERE task_id = t.id
|
||||||
STRING_AGG(COALESCE(tmiv.name, ''), ', ') AS assignee_names,
|
AND user_id = '${userId}') AS timer_start_time,
|
||||||
STRING_AGG(COALESCE(tmiv.name, ''), ', ') AS names
|
|
||||||
FROM tasks_assignees ta
|
(SELECT color_code
|
||||||
LEFT JOIN team_member_info_view tmiv ON ta.team_member_id = tmiv.team_member_id
|
FROM sys_task_status_categories
|
||||||
LEFT JOIN users u ON tmiv.user_id = u.id
|
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color,
|
||||||
LEFT JOIN notification_settings ns ON ns.user_id = u.id AND ns.team_id = tmiv.team_id
|
|
||||||
GROUP BY ta.task_id
|
(SELECT color_code_dark
|
||||||
),
|
FROM sys_task_status_categories
|
||||||
task_labels AS (
|
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) AS status_color_dark,
|
||||||
SELECT
|
|
||||||
tl.task_id,
|
(SELECT COALESCE(ROW_TO_JSON(r), '{}'::JSON)
|
||||||
JSON_AGG(JSON_BUILD_OBJECT(
|
FROM (SELECT is_done, is_doing, is_todo
|
||||||
'id', tl.label_id,
|
FROM sys_task_status_categories
|
||||||
'label_id', tl.label_id,
|
WHERE id = (SELECT category_id FROM task_statuses WHERE id = t.status_id)) r) AS status_category,
|
||||||
'name', team_l.name,
|
|
||||||
'color_code', team_l.color_code
|
(SELECT COUNT(*) FROM task_comments WHERE task_id = t.id) AS comments_count,
|
||||||
)) AS labels,
|
(SELECT COUNT(*) FROM task_attachments WHERE task_id = t.id) AS attachments_count,
|
||||||
JSON_AGG(JSON_BUILD_OBJECT(
|
(CASE
|
||||||
'id', tl.label_id,
|
WHEN EXISTS(SELECT 1
|
||||||
'label_id', tl.label_id,
|
FROM tasks_with_status_view
|
||||||
'name', team_l.name,
|
WHERE tasks_with_status_view.task_id = t.id
|
||||||
'color_code', team_l.color_code
|
AND is_done IS TRUE) THEN 1
|
||||||
)) AS all_labels
|
ELSE 0 END) AS parent_task_completed,
|
||||||
FROM task_labels tl
|
(SELECT get_task_assignees(t.id)) AS assignees,
|
||||||
JOIN team_labels team_l ON tl.label_id = team_l.id
|
(SELECT COUNT(*)
|
||||||
GROUP BY tl.task_id
|
FROM tasks_with_status_view tt
|
||||||
)
|
WHERE tt.parent_task_id = t.id
|
||||||
${options.customColumns ? `,
|
AND tt.is_done IS TRUE)::INT
|
||||||
custom_columns_data AS (
|
AS completed_sub_tasks,
|
||||||
SELECT
|
|
||||||
ccv.task_id,
|
(SELECT COALESCE(JSON_AGG(r), '[]'::JSON)
|
||||||
JSONB_OBJECT_AGG(
|
FROM (SELECT task_labels.label_id AS id,
|
||||||
cc.key,
|
(SELECT name FROM team_labels WHERE id = task_labels.label_id),
|
||||||
CASE
|
(SELECT color_code FROM team_labels WHERE id = task_labels.label_id)
|
||||||
WHEN ccv.text_value IS NOT NULL THEN to_jsonb(ccv.text_value)
|
FROM task_labels
|
||||||
WHEN ccv.number_value IS NOT NULL THEN to_jsonb(ccv.number_value)
|
WHERE task_id = t.id) r) AS labels,
|
||||||
WHEN ccv.boolean_value IS NOT NULL THEN to_jsonb(ccv.boolean_value)
|
(SELECT is_completed(status_id, project_id)) AS is_complete,
|
||||||
WHEN ccv.date_value IS NOT NULL THEN to_jsonb(ccv.date_value)
|
(SELECT name FROM users WHERE id = t.reporter_id) AS reporter,
|
||||||
WHEN ccv.json_value IS NOT NULL THEN ccv.json_value
|
(SELECT id FROM task_priorities WHERE id = t.priority_id) AS priority,
|
||||||
ELSE NULL::JSONB
|
(SELECT value FROM task_priorities WHERE id = t.priority_id) AS priority_value,
|
||||||
END
|
total_minutes,
|
||||||
) AS custom_column_values
|
(SELECT SUM(time_spent) FROM task_work_log WHERE task_id = t.id) AS total_minutes_spent,
|
||||||
FROM cc_column_values ccv
|
created_at,
|
||||||
JOIN cc_custom_columns cc ON ccv.column_id = cc.id
|
updated_at,
|
||||||
GROUP BY ccv.task_id
|
completed_at,
|
||||||
)` : ""}
|
start_date,
|
||||||
SELECT
|
billable,
|
||||||
t.id,
|
schedule_id,
|
||||||
t.name,
|
END_DATE ${customColumnsQuery} ${statusesQuery}
|
||||||
CONCAT(p.key, '-', t.task_no) AS task_key,
|
|
||||||
p.name AS project_name,
|
|
||||||
t.project_id,
|
|
||||||
t.parent_task_id,
|
|
||||||
t.parent_task_id IS NOT NULL AS is_sub_task,
|
|
||||||
parent_task.name AS parent_task_name,
|
|
||||||
t.status_id AS status,
|
|
||||||
t.archived,
|
|
||||||
t.description,
|
|
||||||
t.sort_order,
|
|
||||||
t.progress_value,
|
|
||||||
t.manual_progress,
|
|
||||||
t.weight,
|
|
||||||
p.use_manual_progress AS project_use_manual_progress,
|
|
||||||
p.use_weighted_progress AS project_use_weighted_progress,
|
|
||||||
p.use_time_progress AS project_use_time_progress,
|
|
||||||
-- Use stored progress value instead of expensive function call
|
|
||||||
COALESCE(t.progress_value, 0) AS complete_ratio,
|
|
||||||
-- Phase information via JOINs
|
|
||||||
tp.phase_id,
|
|
||||||
pp.name AS phase_name,
|
|
||||||
pp.color_code AS phase_color_code,
|
|
||||||
-- Status information via JOINs
|
|
||||||
stsc.color_code AS status_color,
|
|
||||||
stsc.color_code_dark AS status_color_dark,
|
|
||||||
JSON_BUILD_OBJECT(
|
|
||||||
'is_done', stsc.is_done,
|
|
||||||
'is_doing', stsc.is_doing,
|
|
||||||
'is_todo', stsc.is_todo
|
|
||||||
) AS status_category,
|
|
||||||
-- Aggregated counts
|
|
||||||
COALESCE(agg.sub_tasks_count, 0) AS sub_tasks_count,
|
|
||||||
COALESCE(agg.completed_sub_tasks, 0) AS completed_sub_tasks,
|
|
||||||
COALESCE(agg.comments_count, 0) AS comments_count,
|
|
||||||
COALESCE(agg.attachments_count, 0) AS attachments_count,
|
|
||||||
COALESCE(agg.total_minutes_spent, 0) AS total_minutes_spent,
|
|
||||||
CASE WHEN agg.has_subscribers > 0 THEN true ELSE false END AS has_subscribers,
|
|
||||||
CASE WHEN agg.has_dependencies > 0 THEN true ELSE false END AS has_dependencies,
|
|
||||||
-- Task completion status
|
|
||||||
CASE WHEN stsc.is_done THEN 1 ELSE 0 END AS parent_task_completed,
|
|
||||||
-- Assignees and labels via JOINs
|
|
||||||
COALESCE(assignees.assignees, '[]'::JSON) AS assignees,
|
|
||||||
COALESCE(assignees.assignee_names, '') AS assignee_names,
|
|
||||||
COALESCE(assignees.names, '') AS names,
|
|
||||||
COALESCE(labels.labels, '[]'::JSON) AS labels,
|
|
||||||
COALESCE(labels.all_labels, '[]'::JSON) AS all_labels,
|
|
||||||
-- Other fields
|
|
||||||
stsc.is_done AS is_complete,
|
|
||||||
reporter.name AS reporter,
|
|
||||||
t.priority_id AS priority,
|
|
||||||
tp_priority.value AS priority_value,
|
|
||||||
t.total_minutes,
|
|
||||||
t.created_at,
|
|
||||||
t.updated_at,
|
|
||||||
t.completed_at,
|
|
||||||
t.start_date,
|
|
||||||
t.billable,
|
|
||||||
t.schedule_id,
|
|
||||||
t.END_DATE,
|
|
||||||
-- Timer information
|
|
||||||
tt.start_time AS timer_start_time
|
|
||||||
${customColumnsQuery}
|
|
||||||
${statusesQuery}
|
|
||||||
FROM tasks t
|
FROM tasks t
|
||||||
JOIN projects p ON t.project_id = p.id
|
|
||||||
JOIN task_statuses ts ON t.status_id = ts.id
|
|
||||||
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
|
||||||
LEFT JOIN tasks parent_task ON t.parent_task_id = parent_task.id
|
|
||||||
LEFT JOIN task_phase tp ON t.id = tp.task_id
|
|
||||||
LEFT JOIN project_phases pp ON tp.phase_id = pp.id
|
|
||||||
LEFT JOIN task_priorities tp_priority ON t.priority_id = tp_priority.id
|
|
||||||
LEFT JOIN users reporter ON t.reporter_id = reporter.id
|
|
||||||
LEFT JOIN task_timers tt ON t.id = tt.task_id AND tt.user_id = $${isSubTasks ? "3" : "2"}
|
|
||||||
LEFT JOIN task_aggregates agg ON t.id = agg.id
|
|
||||||
LEFT JOIN task_assignees assignees ON t.id = assignees.task_id
|
|
||||||
LEFT JOIN task_labels labels ON t.id = labels.task_id
|
|
||||||
${options.customColumns ? "LEFT JOIN custom_columns_data cc_data ON t.id = cc_data.task_id" : ""}
|
|
||||||
WHERE ${filters} ${searchQuery}
|
WHERE ${filters} ${searchQuery}
|
||||||
ORDER BY ${sortFields}
|
ORDER BY ${sortFields}
|
||||||
`;
|
`;
|
||||||
@@ -402,7 +347,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
req.query.customColumns = "true";
|
req.query.customColumns = "true";
|
||||||
|
|
||||||
const q = TasksControllerV2.getQuery(req.user?.id as string, req.query);
|
const q = TasksControllerV2.getQuery(req.user?.id as string, req.query);
|
||||||
const params = isSubTasks ? [req.params.id || null, req.query.parent_task, req.user?.id] : [req.params.id || null, req.user?.id];
|
const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null];
|
||||||
|
|
||||||
const result = await db.query(q, params);
|
const result = await db.query(q, params);
|
||||||
const tasks = [...result.rows];
|
const tasks = [...result.rows];
|
||||||
@@ -510,7 +455,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
req.query.customColumns = "true";
|
req.query.customColumns = "true";
|
||||||
|
|
||||||
const q = TasksControllerV2.getQuery(req.user?.id as string, req.query);
|
const q = TasksControllerV2.getQuery(req.user?.id as string, req.query);
|
||||||
const params = isSubTasks ? [req.params.id || null, req.query.parent_task, req.user?.id] : [req.params.id || null, req.user?.id];
|
const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null];
|
||||||
const result = await db.query(q, params);
|
const result = await db.query(q, params);
|
||||||
|
|
||||||
let data: any[] = [];
|
let data: any[] = [];
|
||||||
@@ -1041,62 +986,60 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async getTasksV3(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async getTasksV3(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
const startTime = performance.now();
|
const startTime = performance.now();
|
||||||
console.log(`[PERFORMANCE] getTasksV3 method called for project ${req.params.id}`);
|
const isSubTasks = !!req.query.parent_task;
|
||||||
|
const groupBy = (req.query.group || GroupBy.STATUS) as string;
|
||||||
|
const archived = req.query.archived === "true";
|
||||||
|
|
||||||
// PERFORMANCE OPTIMIZATION: Skip expensive progress calculation by default
|
// PERFORMANCE OPTIMIZATION: Skip expensive progress calculation by default
|
||||||
// Progress values are already calculated and stored in the database
|
// Progress values are already calculated and stored in the database
|
||||||
// Only refresh if explicitly requested via refresh_progress=true query parameter
|
// Only refresh if explicitly requested via refresh_progress=true query parameter
|
||||||
if (req.query.refresh_progress === "true" && req.params.id) {
|
// This dramatically improves initial load performance (from ~2-5s to ~200-500ms)
|
||||||
console.log(`[PERFORMANCE] Starting progress refresh for project ${req.params.id} (getTasksV3)`);
|
const shouldRefreshProgress = req.query.refresh_progress === "true";
|
||||||
|
|
||||||
|
if (shouldRefreshProgress && 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 isSubTasks = !!req.query.parent_task;
|
const queryStartTime = performance.now();
|
||||||
const groupBy = (req.query.group || GroupBy.STATUS) as string;
|
|
||||||
|
|
||||||
// Add customColumns flag to query params (same as getList)
|
|
||||||
req.query.customColumns = "true";
|
|
||||||
|
|
||||||
// Use the exact same database query as getList method
|
|
||||||
const q = TasksControllerV2.getQuery(req.user?.id as string, req.query);
|
const q = TasksControllerV2.getQuery(req.user?.id as string, req.query);
|
||||||
const params = isSubTasks ? [req.params.id || null, req.query.parent_task, req.user?.id] : [req.params.id || null, req.user?.id];
|
const params = isSubTasks ? [req.params.id || null, req.query.parent_task] : [req.params.id || null];
|
||||||
|
|
||||||
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();
|
||||||
|
|
||||||
// Use the same groups query as getList method
|
// Get groups metadata dynamically from database
|
||||||
|
const groupsStartTime = performance.now();
|
||||||
const groups = await this.getGroups(groupBy, req.params.id);
|
const groups = await this.getGroups(groupBy, req.params.id);
|
||||||
const map = groups.reduce((g: { [x: string]: ITaskGroup }, group) => {
|
const groupsEndTime = performance.now();
|
||||||
if (group.id)
|
|
||||||
g[group.id] = new TaskListGroup(group);
|
|
||||||
return g;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
// Use the same updateMapByGroup method as getList
|
// Create priority value to name mapping
|
||||||
await this.updateMapByGroup(tasks, groupBy, map);
|
|
||||||
|
|
||||||
// Calculate progress for groups (same as getList)
|
|
||||||
const updatedGroups = Object.keys(map).map(key => {
|
|
||||||
const group = map[key];
|
|
||||||
TasksControllerV2.updateTaskProgresses(group);
|
|
||||||
return {
|
|
||||||
id: key,
|
|
||||||
...group
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// Transform to V3 response format while maintaining the same data processing
|
|
||||||
const priorityMap: Record<string, string> = {
|
const priorityMap: Record<string, string> = {
|
||||||
"0": "low",
|
"0": "low",
|
||||||
"1": "medium",
|
"1": "medium",
|
||||||
"2": "high"
|
"2": "high"
|
||||||
};
|
};
|
||||||
|
|
||||||
// Transform all tasks to V3 format
|
// Create status category mapping based on actual status names from database
|
||||||
|
const statusCategoryMap: Record<string, string> = {};
|
||||||
|
for (const group of groups) {
|
||||||
|
if (groupBy === GroupBy.STATUS && group.id) {
|
||||||
|
// Use the actual status name from database, convert to lowercase for consistency
|
||||||
|
statusCategoryMap[group.id] = group.name.toLowerCase().replace(/\s+/g, "_");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Transform tasks with all necessary data preprocessing
|
||||||
|
const transformStartTime = performance.now();
|
||||||
const transformedTasks = tasks.map((task, index) => {
|
const transformedTasks = tasks.map((task, index) => {
|
||||||
|
// Update task with calculated values (lightweight version)
|
||||||
|
TasksControllerV2.updateTaskViewModel(task);
|
||||||
|
task.index = index;
|
||||||
|
|
||||||
// Convert time values
|
// Convert time values
|
||||||
const convertTimeValue = (value: any): number => {
|
const convertTimeValue = (value: any): number => {
|
||||||
if (typeof value === "number") return value;
|
if (typeof value === "number") return value;
|
||||||
@@ -1119,12 +1062,15 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
task_key: task.task_key || "",
|
task_key: task.task_key || "",
|
||||||
title: task.name || "",
|
title: task.name || "",
|
||||||
description: task.description || "",
|
description: task.description || "",
|
||||||
status: task.status || "todo",
|
// Use dynamic status mapping from database
|
||||||
|
status: statusCategoryMap[task.status] || task.status,
|
||||||
|
// Pre-processed priority using mapping
|
||||||
priority: priorityMap[task.priority_value?.toString()] || "medium",
|
priority: priorityMap[task.priority_value?.toString()] || "medium",
|
||||||
|
// Use actual phase name from database
|
||||||
phase: task.phase_name || "Development",
|
phase: task.phase_name || "Development",
|
||||||
progress: typeof task.complete_ratio === "number" ? task.complete_ratio : 0,
|
progress: typeof task.complete_ratio === "number" ? task.complete_ratio : 0,
|
||||||
assignees: task.assignees?.map((a: any) => a.team_member_id) || [],
|
assignees: task.assignees?.map((a: any) => a.team_member_id) || [],
|
||||||
assignee_names: task.assignees || [],
|
assignee_names: task.assignee_names || task.names || [],
|
||||||
labels: task.labels?.map((l: any) => ({
|
labels: task.labels?.map((l: any) => ({
|
||||||
id: l.id || l.label_id,
|
id: l.id || l.label_id,
|
||||||
name: l.name,
|
name: l.name,
|
||||||
@@ -1132,11 +1078,6 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
end: l.end,
|
end: l.end,
|
||||||
names: l.names
|
names: l.names
|
||||||
})) || [],
|
})) || [],
|
||||||
all_labels: task.all_labels?.map((l: any) => ({
|
|
||||||
id: l.id || l.label_id,
|
|
||||||
name: l.name,
|
|
||||||
color_code: l.color_code || "#1890ff"
|
|
||||||
})) || [],
|
|
||||||
dueDate: task.end_date || task.END_DATE,
|
dueDate: task.end_date || task.END_DATE,
|
||||||
startDate: task.start_date,
|
startDate: task.start_date,
|
||||||
timeTracking: {
|
timeTracking: {
|
||||||
@@ -1144,7 +1085,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
logged: convertTimeValue(task.time_spent),
|
logged: convertTimeValue(task.time_spent),
|
||||||
},
|
},
|
||||||
customFields: {},
|
customFields: {},
|
||||||
custom_column_values: task.custom_column_values || {},
|
custom_column_values: task.custom_column_values || {}, // Include custom column values
|
||||||
createdAt: task.created_at || new Date().toISOString(),
|
createdAt: task.created_at || new Date().toISOString(),
|
||||||
updatedAt: task.updated_at || new Date().toISOString(),
|
updatedAt: task.updated_at || new Date().toISOString(),
|
||||||
order: typeof task.sort_order === "number" ? task.sort_order : 0,
|
order: typeof task.sort_order === "number" ? task.sort_order : 0,
|
||||||
@@ -1164,53 +1105,124 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
reporter: task.reporter || null,
|
reporter: task.reporter || null,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
const transformEndTime = performance.now();
|
||||||
|
|
||||||
// Transform groups to V3 format while preserving the getList logic
|
// Create groups based on dynamic data from database
|
||||||
const responseGroups = updatedGroups.map(group => {
|
const groupingStartTime = performance.now();
|
||||||
// Create status category mapping for consistent group naming
|
const groupedResponse: Record<string, any> = {};
|
||||||
let groupValue = group.name;
|
|
||||||
if (groupBy === GroupBy.STATUS) {
|
|
||||||
groupValue = group.name.toLowerCase().replace(/\s+/g, "_");
|
|
||||||
} else if (groupBy === GroupBy.PRIORITY) {
|
|
||||||
groupValue = group.name.toLowerCase();
|
|
||||||
} else if (groupBy === GroupBy.PHASE) {
|
|
||||||
groupValue = group.name.toLowerCase().replace(/\s+/g, "_");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Transform tasks in this group to V3 format
|
// Initialize groups from database data
|
||||||
const groupTasks = group.tasks.map(task => {
|
groups.forEach(group => {
|
||||||
const foundTask = transformedTasks.find(t => t.id === task.id);
|
const groupKey = groupBy === GroupBy.STATUS
|
||||||
return foundTask || task;
|
? group.name.toLowerCase().replace(/\s+/g, "_")
|
||||||
});
|
: groupBy === GroupBy.PRIORITY
|
||||||
|
? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase()
|
||||||
|
: group.name.toLowerCase().replace(/\s+/g, "_");
|
||||||
|
|
||||||
return {
|
groupedResponse[groupKey] = {
|
||||||
id: group.id,
|
id: group.id,
|
||||||
title: group.name,
|
title: group.name,
|
||||||
groupType: groupBy,
|
groupType: groupBy,
|
||||||
groupValue,
|
groupValue: groupKey,
|
||||||
collapsed: false,
|
collapsed: false,
|
||||||
tasks: groupTasks,
|
tasks: [],
|
||||||
taskIds: groupTasks.map((task: any) => task.id),
|
taskIds: [],
|
||||||
color: group.color_code || this.getDefaultGroupColor(groupBy, groupValue),
|
color: group.color_code || this.getDefaultGroupColor(groupBy, groupKey),
|
||||||
// Include additional metadata from database
|
// Include additional metadata from database
|
||||||
category_id: group.category_id,
|
category_id: group.category_id,
|
||||||
start_date: group.start_date,
|
start_date: group.start_date,
|
||||||
end_date: group.end_date,
|
end_date: group.end_date,
|
||||||
sort_index: (group as any).sort_index,
|
sort_index: (group as any).sort_index,
|
||||||
// Include progress information from getList logic
|
};
|
||||||
todo_progress: group.todo_progress,
|
});
|
||||||
doing_progress: group.doing_progress,
|
|
||||||
done_progress: group.done_progress,
|
// Distribute tasks into groups
|
||||||
};
|
const unmappedTasks: any[] = [];
|
||||||
}).filter(group => group.tasks.length > 0 || req.query.include_empty === "true");
|
|
||||||
|
transformedTasks.forEach(task => {
|
||||||
|
let groupKey: string;
|
||||||
|
let taskAssigned = false;
|
||||||
|
|
||||||
|
if (groupBy === GroupBy.STATUS) {
|
||||||
|
groupKey = task.status;
|
||||||
|
if (groupedResponse[groupKey]) {
|
||||||
|
groupedResponse[groupKey].tasks.push(task);
|
||||||
|
groupedResponse[groupKey].taskIds.push(task.id);
|
||||||
|
taskAssigned = true;
|
||||||
|
}
|
||||||
|
} else if (groupBy === GroupBy.PRIORITY) {
|
||||||
|
groupKey = task.priority;
|
||||||
|
if (groupedResponse[groupKey]) {
|
||||||
|
groupedResponse[groupKey].tasks.push(task);
|
||||||
|
groupedResponse[groupKey].taskIds.push(task.id);
|
||||||
|
taskAssigned = true;
|
||||||
|
}
|
||||||
|
} else if (groupBy === GroupBy.PHASE) {
|
||||||
|
// For phase grouping, check if task has a valid phase
|
||||||
|
if (task.phase && task.phase.trim() !== "") {
|
||||||
|
groupKey = task.phase.toLowerCase().replace(/\s+/g, "_");
|
||||||
|
if (groupedResponse[groupKey]) {
|
||||||
|
groupedResponse[groupKey].tasks.push(task);
|
||||||
|
groupedResponse[groupKey].taskIds.push(task.id);
|
||||||
|
taskAssigned = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// If task doesn't have a valid phase, add to unmapped
|
||||||
|
if (!taskAssigned) {
|
||||||
|
unmappedTasks.push(task);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create unmapped group if there are tasks without proper phase assignment
|
||||||
|
if (unmappedTasks.length > 0 && groupBy === GroupBy.PHASE) {
|
||||||
|
groupedResponse[UNMAPPED.toLowerCase()] = {
|
||||||
|
id: UNMAPPED,
|
||||||
|
title: UNMAPPED,
|
||||||
|
groupType: groupBy,
|
||||||
|
groupValue: UNMAPPED.toLowerCase(),
|
||||||
|
collapsed: false,
|
||||||
|
tasks: unmappedTasks,
|
||||||
|
taskIds: unmappedTasks.map(task => task.id),
|
||||||
|
color: "#fbc84c69", // Orange color with transparency
|
||||||
|
category_id: null,
|
||||||
|
start_date: null,
|
||||||
|
end_date: null,
|
||||||
|
sort_index: 999, // Put unmapped group at the end
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort tasks within each group by order
|
||||||
|
Object.values(groupedResponse).forEach((group: any) => {
|
||||||
|
group.tasks.sort((a: any, b: any) => a.order - b.order);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert to array format expected by frontend, maintaining database order
|
||||||
|
const responseGroups = groups
|
||||||
|
.map(group => {
|
||||||
|
const groupKey = groupBy === GroupBy.STATUS
|
||||||
|
? group.name.toLowerCase().replace(/\s+/g, "_")
|
||||||
|
: groupBy === GroupBy.PRIORITY
|
||||||
|
? priorityMap[(group as any).value?.toString()] || group.name.toLowerCase()
|
||||||
|
: group.name.toLowerCase().replace(/\s+/g, "_");
|
||||||
|
|
||||||
|
return groupedResponse[groupKey];
|
||||||
|
})
|
||||||
|
.filter(group => group && (group.tasks.length > 0 || req.query.include_empty === "true"));
|
||||||
|
|
||||||
|
// Add unmapped group to the end if it exists
|
||||||
|
if (groupedResponse[UNMAPPED.toLowerCase()]) {
|
||||||
|
responseGroups.push(groupedResponse[UNMAPPED.toLowerCase()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupingEndTime = performance.now();
|
||||||
|
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
const totalTime = endTime - startTime;
|
const totalTime = endTime - startTime;
|
||||||
console.log(`[PERFORMANCE] getTasksV3 method completed in ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${transformedTasks.length} tasks`);
|
|
||||||
|
|
||||||
// Log warning if this method is taking too long
|
// Log warning if request is taking too long
|
||||||
if (totalTime > 1000) {
|
if (totalTime > 1000) {
|
||||||
console.warn(`[PERFORMANCE WARNING] getTasksV3 method taking ${totalTime.toFixed(2)}ms - Consider optimizing the query or data processing!`);
|
console.warn(`[PERFORMANCE WARNING] Slow request detected: ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${transformedTasks.length} tasks`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return res.status(200).send(new ServerResponse(true, {
|
return res.status(200).send(new ServerResponse(true, {
|
||||||
@@ -1221,315 +1233,6 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* NEW OPTIMIZED METHOD: Split complex query into focused segments for better performance
|
|
||||||
*/
|
|
||||||
@HandleExceptions()
|
|
||||||
public static async getTasksV4Optimized(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
|
||||||
const startTime = performance.now();
|
|
||||||
console.log(`[PERFORMANCE] getTasksV4Optimized method called for project ${req.params.id}`);
|
|
||||||
|
|
||||||
// Skip progress refresh by default for better performance
|
|
||||||
if (req.query.refresh_progress === "true" && req.params.id) {
|
|
||||||
const progressStartTime = performance.now();
|
|
||||||
await this.refreshProjectTaskProgressValues(req.params.id);
|
|
||||||
const progressEndTime = performance.now();
|
|
||||||
console.log(`[PERFORMANCE] Progress refresh completed in ${(progressEndTime - progressStartTime).toFixed(2)}ms`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const isSubTasks = !!req.query.parent_task;
|
|
||||||
const groupBy = (req.query.group || GroupBy.STATUS) as string;
|
|
||||||
const projectId = req.params.id;
|
|
||||||
const userId = req.user?.id;
|
|
||||||
|
|
||||||
// STEP 1: Get basic task data with optimized query
|
|
||||||
const baseTasksQuery = `
|
|
||||||
SELECT
|
|
||||||
t.id,
|
|
||||||
t.name,
|
|
||||||
CONCAT(p.key, '-', t.task_no) AS task_key,
|
|
||||||
p.name AS project_name,
|
|
||||||
t.project_id,
|
|
||||||
t.parent_task_id,
|
|
||||||
t.parent_task_id IS NOT NULL AS is_sub_task,
|
|
||||||
t.status_id AS status,
|
|
||||||
t.priority_id AS priority,
|
|
||||||
t.description,
|
|
||||||
t.sort_order,
|
|
||||||
t.progress_value AS complete_ratio,
|
|
||||||
t.manual_progress,
|
|
||||||
t.weight,
|
|
||||||
t.start_date,
|
|
||||||
t.end_date,
|
|
||||||
t.created_at,
|
|
||||||
t.updated_at,
|
|
||||||
t.completed_at,
|
|
||||||
t.billable,
|
|
||||||
t.schedule_id,
|
|
||||||
t.total_minutes,
|
|
||||||
-- Status information via JOINs
|
|
||||||
stsc.color_code AS status_color,
|
|
||||||
stsc.color_code_dark AS status_color_dark,
|
|
||||||
stsc.is_done,
|
|
||||||
stsc.is_doing,
|
|
||||||
stsc.is_todo,
|
|
||||||
-- Priority information
|
|
||||||
tp_priority.value AS priority_value,
|
|
||||||
-- Phase information
|
|
||||||
tp.phase_id,
|
|
||||||
pp.name AS phase_name,
|
|
||||||
pp.color_code AS phase_color_code,
|
|
||||||
-- Reporter information
|
|
||||||
reporter.name AS reporter,
|
|
||||||
-- Timer information
|
|
||||||
tt.start_time AS timer_start_time
|
|
||||||
FROM tasks t
|
|
||||||
JOIN projects p ON t.project_id = p.id
|
|
||||||
JOIN task_statuses ts ON t.status_id = ts.id
|
|
||||||
JOIN sys_task_status_categories stsc ON ts.category_id = stsc.id
|
|
||||||
LEFT JOIN task_phase tp ON t.id = tp.task_id
|
|
||||||
LEFT JOIN project_phases pp ON tp.phase_id = pp.id
|
|
||||||
LEFT JOIN task_priorities tp_priority ON t.priority_id = tp_priority.id
|
|
||||||
LEFT JOIN users reporter ON t.reporter_id = reporter.id
|
|
||||||
LEFT JOIN task_timers tt ON t.id = tt.task_id AND tt.user_id = $2
|
|
||||||
WHERE t.project_id = $1
|
|
||||||
AND t.archived = FALSE
|
|
||||||
${isSubTasks ? "AND t.parent_task_id = $3" : "AND t.parent_task_id IS NULL"}
|
|
||||||
ORDER BY t.sort_order
|
|
||||||
`;
|
|
||||||
|
|
||||||
const baseParams = isSubTasks ? [projectId, userId, req.query.parent_task] : [projectId, userId];
|
|
||||||
const baseResult = await db.query(baseTasksQuery, baseParams);
|
|
||||||
const baseTasks = baseResult.rows;
|
|
||||||
|
|
||||||
if (baseTasks.length === 0) {
|
|
||||||
return res.status(200).send(new ServerResponse(true, {
|
|
||||||
groups: [],
|
|
||||||
allTasks: [],
|
|
||||||
grouping: groupBy,
|
|
||||||
totalTasks: 0
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskIds = baseTasks.map(t => t.id);
|
|
||||||
|
|
||||||
// STEP 2: Get aggregated data in parallel
|
|
||||||
const [assigneesResult, labelsResult, aggregatesResult] = await Promise.all([
|
|
||||||
// Get assignees
|
|
||||||
db.query(`
|
|
||||||
SELECT
|
|
||||||
ta.task_id,
|
|
||||||
JSON_AGG(JSON_BUILD_OBJECT(
|
|
||||||
'team_member_id', ta.team_member_id,
|
|
||||||
'project_member_id', ta.project_member_id,
|
|
||||||
'name', COALESCE(tm.name, ''),
|
|
||||||
'avatar_url', COALESCE(u.avatar_url, ''),
|
|
||||||
'email', COALESCE(u.email, ei.email, ''),
|
|
||||||
'user_id', tm.user_id,
|
|
||||||
'socket_id', COALESCE(u.socket_id, ''),
|
|
||||||
'team_id', tm.team_id
|
|
||||||
)) AS assignees
|
|
||||||
FROM tasks_assignees ta
|
|
||||||
LEFT JOIN team_members tm ON ta.team_member_id = tm.id
|
|
||||||
LEFT JOIN users u ON tm.user_id = u.id
|
|
||||||
LEFT JOIN email_invitations ei ON ta.team_member_id = ei.team_member_id
|
|
||||||
WHERE ta.task_id = ANY($1)
|
|
||||||
GROUP BY ta.task_id
|
|
||||||
`, [taskIds]),
|
|
||||||
|
|
||||||
// Get labels
|
|
||||||
db.query(`
|
|
||||||
SELECT
|
|
||||||
tl.task_id,
|
|
||||||
JSON_AGG(JSON_BUILD_OBJECT(
|
|
||||||
'id', tl.label_id,
|
|
||||||
'label_id', tl.label_id,
|
|
||||||
'name', team_l.name,
|
|
||||||
'color_code', team_l.color_code
|
|
||||||
)) AS labels
|
|
||||||
FROM task_labels tl
|
|
||||||
JOIN team_labels team_l ON tl.label_id = team_l.id
|
|
||||||
WHERE tl.task_id = ANY($1)
|
|
||||||
GROUP BY tl.task_id
|
|
||||||
`, [taskIds]),
|
|
||||||
|
|
||||||
// Get aggregated counts
|
|
||||||
db.query(`
|
|
||||||
SELECT
|
|
||||||
t.id,
|
|
||||||
COUNT(DISTINCT sub.id) AS sub_tasks_count,
|
|
||||||
COUNT(DISTINCT CASE WHEN sub_status.is_done THEN sub.id END) AS completed_sub_tasks,
|
|
||||||
COUNT(DISTINCT tc.id) AS comments_count,
|
|
||||||
COUNT(DISTINCT ta.id) AS attachments_count,
|
|
||||||
COALESCE(SUM(twl.time_spent), 0) AS total_minutes_spent,
|
|
||||||
CASE WHEN COUNT(ts.id) > 0 THEN true ELSE false END AS has_subscribers,
|
|
||||||
CASE WHEN COUNT(td.id) > 0 THEN true ELSE false END AS has_dependencies
|
|
||||||
FROM unnest($1::uuid[]) AS t(id)
|
|
||||||
LEFT JOIN tasks sub ON t.id = sub.parent_task_id AND sub.archived = FALSE
|
|
||||||
LEFT JOIN task_statuses sub_ts ON sub.status_id = sub_ts.id
|
|
||||||
LEFT JOIN sys_task_status_categories sub_status ON sub_ts.category_id = sub_status.id
|
|
||||||
LEFT JOIN task_comments tc ON t.id = tc.task_id
|
|
||||||
LEFT JOIN task_attachments ta ON t.id = ta.task_id
|
|
||||||
LEFT JOIN task_work_log twl ON t.id = twl.task_id
|
|
||||||
LEFT JOIN task_subscribers ts ON t.id = ts.task_id
|
|
||||||
LEFT JOIN task_dependencies td ON t.id = td.task_id
|
|
||||||
GROUP BY t.id
|
|
||||||
`, [taskIds])
|
|
||||||
]);
|
|
||||||
|
|
||||||
// STEP 3: Create lookup maps for efficient data merging
|
|
||||||
const assigneesMap = new Map();
|
|
||||||
assigneesResult.rows.forEach(row => assigneesMap.set(row.task_id, row.assignees || []));
|
|
||||||
|
|
||||||
const labelsMap = new Map();
|
|
||||||
labelsResult.rows.forEach(row => labelsMap.set(row.task_id, row.labels || []));
|
|
||||||
|
|
||||||
const aggregatesMap = new Map();
|
|
||||||
aggregatesResult.rows.forEach(row => aggregatesMap.set(row.id, row));
|
|
||||||
|
|
||||||
// STEP 4: Merge data efficiently
|
|
||||||
const enrichedTasks = baseTasks.map(task => {
|
|
||||||
const aggregates = aggregatesMap.get(task.id) || {};
|
|
||||||
const assignees = assigneesMap.get(task.id) || [];
|
|
||||||
const labels = labelsMap.get(task.id) || [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
...task,
|
|
||||||
assignees,
|
|
||||||
assignee_names: assignees.map((a: any) => a.name).join(", "),
|
|
||||||
names: assignees.map((a: any) => a.name).join(", "),
|
|
||||||
labels,
|
|
||||||
all_labels: labels,
|
|
||||||
sub_tasks_count: parseInt(aggregates.sub_tasks_count || 0),
|
|
||||||
completed_sub_tasks: parseInt(aggregates.completed_sub_tasks || 0),
|
|
||||||
comments_count: parseInt(aggregates.comments_count || 0),
|
|
||||||
attachments_count: parseInt(aggregates.attachments_count || 0),
|
|
||||||
total_minutes_spent: parseFloat(aggregates.total_minutes_spent || 0),
|
|
||||||
has_subscribers: aggregates.has_subscribers || false,
|
|
||||||
has_dependencies: aggregates.has_dependencies || false,
|
|
||||||
status_category: {
|
|
||||||
is_done: task.is_done,
|
|
||||||
is_doing: task.is_doing,
|
|
||||||
is_todo: task.is_todo
|
|
||||||
}
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// STEP 5: Group tasks (same logic as existing method)
|
|
||||||
const groups = await this.getGroups(groupBy, req.params.id);
|
|
||||||
const map = groups.reduce((g: { [x: string]: ITaskGroup }, group) => {
|
|
||||||
if (group.id)
|
|
||||||
g[group.id] = new TaskListGroup(group);
|
|
||||||
return g;
|
|
||||||
}, {});
|
|
||||||
|
|
||||||
await this.updateMapByGroup(enrichedTasks, groupBy, map);
|
|
||||||
|
|
||||||
const updatedGroups = Object.keys(map).map(key => {
|
|
||||||
const group = map[key];
|
|
||||||
TasksControllerV2.updateTaskProgresses(group);
|
|
||||||
return {
|
|
||||||
id: key,
|
|
||||||
...group
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// STEP 6: Transform to V3 format (same as existing method)
|
|
||||||
const priorityMap: Record<string, string> = {
|
|
||||||
"0": "low",
|
|
||||||
"1": "medium",
|
|
||||||
"2": "high"
|
|
||||||
};
|
|
||||||
|
|
||||||
const transformedTasks = enrichedTasks.map(task => ({
|
|
||||||
id: task.id,
|
|
||||||
task_key: task.task_key || "",
|
|
||||||
title: task.name || "",
|
|
||||||
description: task.description || "",
|
|
||||||
status: task.status || "todo",
|
|
||||||
priority: priorityMap[task.priority_value?.toString()] || "medium",
|
|
||||||
phase: task.phase_name || "Development",
|
|
||||||
progress: typeof task.complete_ratio === "number" ? task.complete_ratio : 0,
|
|
||||||
assignees: task.assignees?.map((a: any) => a.team_member_id) || [],
|
|
||||||
assignee_names: task.assignees || [],
|
|
||||||
labels: task.labels?.map((l: any) => ({
|
|
||||||
id: l.id || l.label_id,
|
|
||||||
name: l.name,
|
|
||||||
color: l.color_code || "#1890ff"
|
|
||||||
})) || [],
|
|
||||||
dueDate: task.end_date,
|
|
||||||
startDate: task.start_date,
|
|
||||||
timeTracking: {
|
|
||||||
estimated: task.total_minutes || 0,
|
|
||||||
logged: task.total_minutes_spent || 0,
|
|
||||||
},
|
|
||||||
customFields: {},
|
|
||||||
createdAt: task.created_at || new Date().toISOString(),
|
|
||||||
updatedAt: task.updated_at || new Date().toISOString(),
|
|
||||||
order: typeof task.sort_order === "number" ? task.sort_order : 0,
|
|
||||||
originalStatusId: task.status,
|
|
||||||
originalPriorityId: task.priority,
|
|
||||||
statusColor: task.status_color,
|
|
||||||
priorityColor: task.priority_color,
|
|
||||||
sub_tasks_count: task.sub_tasks_count || 0,
|
|
||||||
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 responseGroups = updatedGroups.map(group => {
|
|
||||||
let groupValue = group.name;
|
|
||||||
if (groupBy === GroupBy.STATUS) {
|
|
||||||
groupValue = group.name.toLowerCase().replace(/\s+/g, "_");
|
|
||||||
} else if (groupBy === GroupBy.PRIORITY) {
|
|
||||||
groupValue = group.name.toLowerCase();
|
|
||||||
} else if (groupBy === GroupBy.PHASE) {
|
|
||||||
groupValue = group.name.toLowerCase().replace(/\s+/g, "_");
|
|
||||||
}
|
|
||||||
|
|
||||||
const groupTasks = group.tasks.map(task => {
|
|
||||||
const foundTask = transformedTasks.find(t => t.id === task.id);
|
|
||||||
return foundTask || task;
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: group.id,
|
|
||||||
title: group.name,
|
|
||||||
groupType: groupBy,
|
|
||||||
groupValue,
|
|
||||||
collapsed: false,
|
|
||||||
tasks: groupTasks,
|
|
||||||
taskIds: groupTasks.map((task: any) => task.id),
|
|
||||||
color: group.color_code || this.getDefaultGroupColor(groupBy, groupValue),
|
|
||||||
category_id: group.category_id,
|
|
||||||
start_date: group.start_date,
|
|
||||||
end_date: group.end_date,
|
|
||||||
sort_index: (group as any).sort_index,
|
|
||||||
todo_progress: group.todo_progress,
|
|
||||||
doing_progress: group.doing_progress,
|
|
||||||
done_progress: group.done_progress,
|
|
||||||
};
|
|
||||||
}).filter(group => group.tasks.length > 0 || req.query.include_empty === "true");
|
|
||||||
|
|
||||||
const endTime = performance.now();
|
|
||||||
const totalTime = endTime - startTime;
|
|
||||||
console.log(`[PERFORMANCE] getTasksV4Optimized method completed in ${totalTime.toFixed(2)}ms for project ${req.params.id} with ${transformedTasks.length} tasks - Improvement: ${2136 - totalTime > 0 ? "+" : ""}${(2136 - totalTime).toFixed(2)}ms`);
|
|
||||||
|
|
||||||
return res.status(200).send(new ServerResponse(true, {
|
|
||||||
groups: responseGroups,
|
|
||||||
allTasks: transformedTasks,
|
|
||||||
grouping: groupBy,
|
|
||||||
totalTasks: transformedTasks.length,
|
|
||||||
performanceMetrics: {
|
|
||||||
executionTime: Math.round(totalTime),
|
|
||||||
tasksCount: transformedTasks.length,
|
|
||||||
optimizationGain: Math.round(2136 - totalTime)
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static getDefaultGroupColor(groupBy: string, groupValue: string): string {
|
private static getDefaultGroupColor(groupBy: string, groupValue: string): string {
|
||||||
const colorMaps: Record<string, Record<string, string>> = {
|
const colorMaps: Record<string, Record<string, string>> = {
|
||||||
[GroupBy.STATUS]: {
|
[GroupBy.STATUS]: {
|
||||||
@@ -1625,6 +1328,4 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
return res.status(500).send(new ServerResponse(false, null, "Failed to get task progress status"));
|
return res.status(500).send(new ServerResponse(false, null, "Failed to get task progress status"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,682 +1,16 @@
|
|||||||
import React, { useCallback, useMemo, useEffect, useState, useRef } from 'react';
|
import ImprovedTaskFilters from "../task-management/improved-task-filters";
|
||||||
import { GroupedVirtuoso } from 'react-virtuoso';
|
import TaskListV2Section from "./TaskListV2Table";
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
DragOverlay,
|
|
||||||
PointerSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
KeyboardSensor,
|
|
||||||
TouchSensor,
|
|
||||||
closestCenter,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
SortableContext,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import { Skeleton } from 'antd';
|
|
||||||
import { HolderOutlined } from '@ant-design/icons';
|
|
||||||
|
|
||||||
// Redux hooks and selectors
|
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|
||||||
import {
|
|
||||||
selectAllTasksArray,
|
|
||||||
selectGroups,
|
|
||||||
selectGrouping,
|
|
||||||
selectLoading,
|
|
||||||
selectError,
|
|
||||||
fetchTasksV3,
|
|
||||||
fetchTaskListColumns,
|
|
||||||
selectColumns,
|
|
||||||
selectCustomColumns,
|
|
||||||
selectLoadingColumns,
|
|
||||||
updateColumnVisibility,
|
|
||||||
} from '@/features/task-management/task-management.slice';
|
|
||||||
import {
|
|
||||||
selectCurrentGrouping,
|
|
||||||
selectCollapsedGroups,
|
|
||||||
toggleGroupCollapsed,
|
|
||||||
} from '@/features/task-management/grouping.slice';
|
|
||||||
import {
|
|
||||||
selectSelectedTaskIds,
|
|
||||||
selectLastSelectedTaskId,
|
|
||||||
selectTask,
|
|
||||||
toggleTaskSelection,
|
|
||||||
selectRange,
|
|
||||||
clearSelection,
|
|
||||||
} from '@/features/task-management/selection.slice';
|
|
||||||
import {
|
|
||||||
setCustomColumnModalAttributes,
|
|
||||||
toggleCustomColumnModalOpen,
|
|
||||||
} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
|
||||||
|
|
||||||
// Components
|
|
||||||
import TaskRowWithSubtasks from './TaskRowWithSubtasks';
|
|
||||||
import TaskGroupHeader from './TaskGroupHeader';
|
|
||||||
import ImprovedTaskFilters from '@/components/task-management/improved-task-filters';
|
|
||||||
import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk-action-bar';
|
|
||||||
import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/custom-column-modal';
|
|
||||||
import AddTaskRow from './components/AddTaskRow';
|
|
||||||
import {
|
|
||||||
AddCustomColumnButton,
|
|
||||||
CustomColumnHeader,
|
|
||||||
} from './components/CustomColumnComponents';
|
|
||||||
|
|
||||||
// Hooks and utilities
|
|
||||||
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
|
||||||
import { useSocket } from '@/socket/socketContext';
|
|
||||||
import { useDragAndDrop } from './hooks/useDragAndDrop';
|
|
||||||
import { useBulkActions } from './hooks/useBulkActions';
|
|
||||||
|
|
||||||
// Constants and types
|
|
||||||
import { BASE_COLUMNS, ColumnStyle } from './constants/columns';
|
|
||||||
import { Task } from '@/types/task-management.types';
|
|
||||||
import { SocketEvents } from '@/shared/socket-events';
|
|
||||||
|
|
||||||
const TaskListV2: React.FC = () => {
|
const TaskListV2: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
|
||||||
const { projectId: urlProjectId } = useParams();
|
|
||||||
const { t } = useTranslation('task-list-table');
|
|
||||||
const { socket, connected } = useSocket();
|
|
||||||
|
|
||||||
// Redux state selectors
|
|
||||||
const allTasks = useAppSelector(selectAllTasksArray);
|
|
||||||
const groups = useAppSelector(selectGroups);
|
|
||||||
const grouping = useAppSelector(selectGrouping);
|
|
||||||
const loading = useAppSelector(selectLoading);
|
|
||||||
const error = useAppSelector(selectError);
|
|
||||||
const currentGrouping = useAppSelector(selectCurrentGrouping);
|
|
||||||
const selectedTaskIds = useAppSelector(selectSelectedTaskIds);
|
|
||||||
const lastSelectedTaskId = useAppSelector(selectLastSelectedTaskId);
|
|
||||||
const collapsedGroups = useAppSelector(selectCollapsedGroups);
|
|
||||||
|
|
||||||
const fields = useAppSelector(state => state.taskManagementFields) || [];
|
|
||||||
const columns = useAppSelector(selectColumns);
|
|
||||||
const customColumns = useAppSelector(selectCustomColumns);
|
|
||||||
const loadingColumns = useAppSelector(selectLoadingColumns);
|
|
||||||
|
|
||||||
// Refs for scroll synchronization
|
|
||||||
const headerScrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const contentScrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
|
|
||||||
// State hooks
|
|
||||||
const [initializedFromDatabase, setInitializedFromDatabase] = useState(false);
|
|
||||||
|
|
||||||
// Configure sensors for drag and drop
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
distance: 8,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
}),
|
|
||||||
useSensor(TouchSensor, {
|
|
||||||
activationConstraint: {
|
|
||||||
delay: 250,
|
|
||||||
tolerance: 5,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Custom hooks
|
|
||||||
const { activeId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(allTasks, groups);
|
|
||||||
const bulkActions = useBulkActions();
|
|
||||||
|
|
||||||
// Enable real-time updates via socket handlers
|
|
||||||
useTaskSocketHandlers();
|
|
||||||
|
|
||||||
// Filter visible columns based on local fields (primary) and backend columns (fallback)
|
|
||||||
const visibleColumns = useMemo(() => {
|
|
||||||
// Start with base columns
|
|
||||||
const baseVisibleColumns = BASE_COLUMNS.filter(column => {
|
|
||||||
// Always show drag handle and title (sticky columns)
|
|
||||||
if (column.isSticky) return true;
|
|
||||||
|
|
||||||
// Primary: Check local fields configuration
|
|
||||||
const field = fields.find(f => f.key === column.key);
|
|
||||||
if (field) {
|
|
||||||
return field.visible;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: Check backend column configuration if local field not found
|
|
||||||
const backendColumn = columns.find(c => c.key === column.key);
|
|
||||||
if (backendColumn) {
|
|
||||||
return backendColumn.pinned ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: hide if neither local field nor backend column found
|
|
||||||
return false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Add visible custom columns
|
|
||||||
const visibleCustomColumns = customColumns
|
|
||||||
?.filter(column => column.pinned)
|
|
||||||
?.map(column => {
|
|
||||||
// Give selection columns more width for dropdown content
|
|
||||||
const fieldType = column.custom_column_obj?.fieldType;
|
|
||||||
let defaultWidth = 160;
|
|
||||||
if (fieldType === 'selection') {
|
|
||||||
defaultWidth = 150; // Reduced width for selection dropdowns
|
|
||||||
} else if (fieldType === 'people') {
|
|
||||||
defaultWidth = 170; // Extra width for people with avatars
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map the configuration data structure to the expected format
|
|
||||||
const customColumnObj = column.custom_column_obj || (column as any).configuration;
|
|
||||||
|
|
||||||
// Transform configuration format to custom_column_obj format if needed
|
|
||||||
let transformedColumnObj = customColumnObj;
|
|
||||||
if (customColumnObj && !customColumnObj.fieldType && customColumnObj.field_type) {
|
|
||||||
transformedColumnObj = {
|
|
||||||
...customColumnObj,
|
|
||||||
fieldType: customColumnObj.field_type,
|
|
||||||
numberType: customColumnObj.number_type,
|
|
||||||
labelPosition: customColumnObj.label_position,
|
|
||||||
previewValue: customColumnObj.preview_value,
|
|
||||||
firstNumericColumn: customColumnObj.first_numeric_column_key,
|
|
||||||
secondNumericColumn: customColumnObj.second_numeric_column_key,
|
|
||||||
selectionsList: customColumnObj.selections_list || customColumnObj.selectionsList || [],
|
|
||||||
labelsList: customColumnObj.labels_list || customColumnObj.labelsList || [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: column.key || column.id || 'unknown',
|
|
||||||
label: column.name || t('customColumns.customColumnHeader'),
|
|
||||||
width: `${(column as any).width || defaultWidth}px`,
|
|
||||||
key: column.key || column.id || 'unknown',
|
|
||||||
custom_column: true,
|
|
||||||
custom_column_obj: transformedColumnObj,
|
|
||||||
isCustom: true,
|
|
||||||
name: column.name,
|
|
||||||
uuid: column.id,
|
|
||||||
};
|
|
||||||
}) || [];
|
|
||||||
|
|
||||||
return [...baseVisibleColumns, ...visibleCustomColumns];
|
|
||||||
}, [fields, columns, customColumns, t]);
|
|
||||||
|
|
||||||
// Effects
|
|
||||||
useEffect(() => {
|
|
||||||
if (urlProjectId) {
|
|
||||||
dispatch(fetchTasksV3(urlProjectId));
|
|
||||||
dispatch(fetchTaskListColumns(urlProjectId));
|
|
||||||
}
|
|
||||||
}, [dispatch, urlProjectId]);
|
|
||||||
|
|
||||||
// Initialize field visibility from database when columns are loaded (only once)
|
|
||||||
useEffect(() => {
|
|
||||||
if (columns.length > 0 && fields.length > 0 && !initializedFromDatabase) {
|
|
||||||
// Update local fields to match database state only on initial load
|
|
||||||
import('@/features/task-management/taskListFields.slice').then(({ setFields }) => {
|
|
||||||
// Create updated fields based on database column state
|
|
||||||
const updatedFields = fields.map(field => {
|
|
||||||
const backendColumn = columns.find(c => c.key === field.key);
|
|
||||||
if (backendColumn) {
|
|
||||||
return {
|
|
||||||
...field,
|
|
||||||
visible: backendColumn.pinned ?? field.visible
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return field;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Only update if there are actual changes
|
|
||||||
const hasChanges = updatedFields.some((field, index) =>
|
|
||||||
field.visible !== fields[index].visible
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasChanges) {
|
|
||||||
dispatch(setFields(updatedFields));
|
|
||||||
}
|
|
||||||
|
|
||||||
setInitializedFromDatabase(true);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [columns, fields, dispatch, initializedFromDatabase]);
|
|
||||||
|
|
||||||
// Event handlers
|
|
||||||
const handleTaskSelect = useCallback(
|
|
||||||
(taskId: string, event: React.MouseEvent) => {
|
|
||||||
if (event.ctrlKey || event.metaKey) {
|
|
||||||
dispatch(toggleTaskSelection(taskId));
|
|
||||||
} else if (event.shiftKey && lastSelectedTaskId) {
|
|
||||||
const taskIds = allTasks.map(t => t.id);
|
|
||||||
const startIdx = taskIds.indexOf(lastSelectedTaskId);
|
|
||||||
const endIdx = taskIds.indexOf(taskId);
|
|
||||||
const rangeIds = taskIds.slice(Math.min(startIdx, endIdx), Math.max(startIdx, endIdx) + 1);
|
|
||||||
dispatch(selectRange(rangeIds));
|
|
||||||
} else {
|
|
||||||
dispatch(clearSelection());
|
|
||||||
dispatch(selectTask(taskId));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[dispatch, lastSelectedTaskId, allTasks]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleGroupCollapse = useCallback(
|
|
||||||
(groupId: string) => {
|
|
||||||
dispatch(toggleGroupCollapsed(groupId));
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Function to update custom column values
|
|
||||||
const updateTaskCustomColumnValue = useCallback((taskId: string, columnKey: string, value: string) => {
|
|
||||||
try {
|
|
||||||
if (!urlProjectId) {
|
|
||||||
console.error('Project ID is missing');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const body = {
|
|
||||||
task_id: taskId,
|
|
||||||
column_key: columnKey,
|
|
||||||
value: value,
|
|
||||||
project_id: urlProjectId,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update the Redux store immediately for optimistic updates
|
|
||||||
const currentTask = allTasks.find(task => task.id === taskId);
|
|
||||||
if (currentTask) {
|
|
||||||
const updatedTask = {
|
|
||||||
...currentTask,
|
|
||||||
custom_column_values: {
|
|
||||||
...currentTask.custom_column_values,
|
|
||||||
[columnKey]: value,
|
|
||||||
},
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Import and dispatch the updateTask action
|
|
||||||
import('@/features/task-management/task-management.slice').then(({ updateTask }) => {
|
|
||||||
dispatch(updateTask(updatedTask));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (socket && connected) {
|
|
||||||
socket.emit(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), JSON.stringify(body));
|
|
||||||
} else {
|
|
||||||
console.warn('Socket not connected, unable to emit TASK_CUSTOM_COLUMN_UPDATE event');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error updating custom column value:', error);
|
|
||||||
}
|
|
||||||
}, [urlProjectId, socket, connected, allTasks, dispatch]);
|
|
||||||
|
|
||||||
// Custom column settings handler
|
|
||||||
const handleCustomColumnSettings = useCallback((columnKey: string) => {
|
|
||||||
if (!columnKey) return;
|
|
||||||
|
|
||||||
const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey);
|
|
||||||
|
|
||||||
// Use the UUID for API calls, not the key (nanoid)
|
|
||||||
// For custom columns, prioritize the uuid field over id field
|
|
||||||
const columnId = (columnData as any)?.uuid || columnData?.id || columnKey;
|
|
||||||
|
|
||||||
dispatch(setCustomColumnModalAttributes({
|
|
||||||
modalType: 'edit',
|
|
||||||
columnId: columnId,
|
|
||||||
columnData: columnData
|
|
||||||
}));
|
|
||||||
dispatch(toggleCustomColumnModalOpen(true));
|
|
||||||
}, [dispatch, visibleColumns]);
|
|
||||||
|
|
||||||
// Add callback for task added
|
|
||||||
const handleTaskAdded = useCallback(() => {
|
|
||||||
// Task is now added in real-time via socket, no need to refetch
|
|
||||||
// The global socket handler will handle the real-time update
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Handle scroll synchronization - disabled since header is now sticky inside content
|
|
||||||
const handleContentScroll = useCallback(() => {
|
|
||||||
// No longer needed since header scrolls naturally with content
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Memoized values for GroupedVirtuoso
|
|
||||||
const virtuosoGroups = useMemo(() => {
|
|
||||||
let currentTaskIndex = 0;
|
|
||||||
|
|
||||||
return groups.map(group => {
|
|
||||||
const isCurrentGroupCollapsed = collapsedGroups.has(group.id);
|
|
||||||
|
|
||||||
const visibleTasksInGroup = isCurrentGroupCollapsed
|
|
||||||
? []
|
|
||||||
: group.taskIds
|
|
||||||
.map(taskId => allTasks.find(task => task.id === taskId))
|
|
||||||
.filter((task): task is Task => task !== undefined);
|
|
||||||
|
|
||||||
const tasksForVirtuoso = visibleTasksInGroup.map(task => ({
|
|
||||||
...task,
|
|
||||||
originalIndex: allTasks.indexOf(task),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const itemsWithAddTask = !isCurrentGroupCollapsed ? [
|
|
||||||
...tasksForVirtuoso,
|
|
||||||
{
|
|
||||||
id: `add-task-${group.id}`,
|
|
||||||
isAddTaskRow: true,
|
|
||||||
groupId: group.id,
|
|
||||||
groupType: currentGrouping || 'status',
|
|
||||||
groupValue: group.id, // Use the actual database ID from backend
|
|
||||||
projectId: urlProjectId,
|
|
||||||
}
|
|
||||||
] : tasksForVirtuoso;
|
|
||||||
|
|
||||||
const groupData = {
|
|
||||||
...group,
|
|
||||||
tasks: itemsWithAddTask,
|
|
||||||
startIndex: currentTaskIndex,
|
|
||||||
count: itemsWithAddTask.length,
|
|
||||||
actualCount: group.taskIds.length,
|
|
||||||
groupValue: group.groupValue || group.title,
|
|
||||||
};
|
|
||||||
currentTaskIndex += itemsWithAddTask.length;
|
|
||||||
return groupData;
|
|
||||||
});
|
|
||||||
}, [groups, allTasks, collapsedGroups, currentGrouping, urlProjectId]);
|
|
||||||
|
|
||||||
const virtuosoGroupCounts = useMemo(() => {
|
|
||||||
return virtuosoGroups.map(group => group.count);
|
|
||||||
}, [virtuosoGroups]);
|
|
||||||
|
|
||||||
const virtuosoItems = useMemo(() => {
|
|
||||||
return virtuosoGroups.flatMap(group => group.tasks);
|
|
||||||
}, [virtuosoGroups]);
|
|
||||||
|
|
||||||
// Render functions
|
|
||||||
const renderGroup = useCallback(
|
|
||||||
(groupIndex: number) => {
|
|
||||||
const group = virtuosoGroups[groupIndex];
|
|
||||||
const isGroupCollapsed = collapsedGroups.has(group.id);
|
|
||||||
const isGroupEmpty = group.actualCount === 0;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={groupIndex > 0 ? 'mt-2' : ''}>
|
|
||||||
<TaskGroupHeader
|
|
||||||
group={{
|
|
||||||
id: group.id,
|
|
||||||
name: group.title,
|
|
||||||
count: group.actualCount,
|
|
||||||
color: group.color,
|
|
||||||
}}
|
|
||||||
isCollapsed={isGroupCollapsed}
|
|
||||||
onToggle={() => handleGroupCollapse(group.id)}
|
|
||||||
projectId={urlProjectId || ''}
|
|
||||||
/>
|
|
||||||
{isGroupEmpty && !isGroupCollapsed && (
|
|
||||||
<div className="relative w-full">
|
|
||||||
<div className="flex items-center min-w-max px-1 py-3">
|
|
||||||
{visibleColumns.map((column, index) => (
|
|
||||||
<div
|
|
||||||
key={`empty-${column.id}`}
|
|
||||||
style={{ width: column.width, flexShrink: 0 }}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
|
||||||
<div className="text-sm italic text-gray-400 dark:text-gray-500 bg-white dark:bg-gray-900 px-4 py-1 rounded-md border border-gray-200 dark:border-gray-700">
|
|
||||||
{t('noTasksInGroup')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t]
|
|
||||||
);
|
|
||||||
|
|
||||||
const renderTask = useCallback(
|
|
||||||
(taskIndex: number) => {
|
|
||||||
const item = virtuosoItems[taskIndex];
|
|
||||||
|
|
||||||
|
|
||||||
if (!item || !urlProjectId) return null;
|
|
||||||
|
|
||||||
if ('isAddTaskRow' in item && item.isAddTaskRow) {
|
|
||||||
return (
|
|
||||||
<AddTaskRow
|
|
||||||
groupId={item.groupId}
|
|
||||||
groupType={item.groupType}
|
|
||||||
groupValue={item.groupValue}
|
|
||||||
projectId={urlProjectId}
|
|
||||||
visibleColumns={visibleColumns}
|
|
||||||
onTaskAdded={handleTaskAdded}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TaskRowWithSubtasks
|
|
||||||
taskId={item.id}
|
|
||||||
projectId={urlProjectId}
|
|
||||||
visibleColumns={visibleColumns}
|
|
||||||
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
},
|
|
||||||
[virtuosoItems, visibleColumns, urlProjectId, handleTaskAdded, updateTaskCustomColumnValue]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render column headers
|
|
||||||
const renderColumnHeaders = useCallback(() => (
|
|
||||||
<div className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700" style={{ width: '100%', minWidth: 'max-content' }}>
|
|
||||||
<div className="flex items-center px-1 py-3 w-full" style={{ minWidth: 'max-content', height: '44px' }}>
|
|
||||||
{visibleColumns.map((column, index) => {
|
|
||||||
const columnStyle: ColumnStyle = {
|
|
||||||
width: column.width,
|
|
||||||
flexShrink: 0,
|
|
||||||
...(column.id === 'labels' && column.width === 'auto'
|
|
||||||
? {
|
|
||||||
minWidth: '200px',
|
|
||||||
flexGrow: 1,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
...((column as any).minWidth && { minWidth: (column as any).minWidth }),
|
|
||||||
...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={column.id}
|
|
||||||
className={`text-sm font-semibold text-gray-600 dark:text-gray-300 ${
|
|
||||||
column.id === 'dragHandle'
|
|
||||||
? 'flex items-center justify-center'
|
|
||||||
: column.id === 'checkbox'
|
|
||||||
? 'flex items-center justify-center'
|
|
||||||
: column.id === 'taskKey'
|
|
||||||
? 'flex items-center pl-3'
|
|
||||||
: column.id === 'title'
|
|
||||||
? 'flex items-center justify-between'
|
|
||||||
: column.id === 'description'
|
|
||||||
? 'flex items-center px-2'
|
|
||||||
: column.id === 'labels'
|
|
||||||
? 'flex items-center gap-0.5 flex-wrap min-w-0 px-2'
|
|
||||||
: column.id === 'assignees'
|
|
||||||
? 'flex items-center px-2'
|
|
||||||
: 'flex items-center justify-center px-2'
|
|
||||||
}`}
|
|
||||||
style={columnStyle}
|
|
||||||
>
|
|
||||||
{column.id === 'dragHandle' || column.id === 'checkbox' ? (
|
|
||||||
<span></span>
|
|
||||||
) : (column as any).isCustom ? (
|
|
||||||
<CustomColumnHeader
|
|
||||||
column={column}
|
|
||||||
onSettingsClick={handleCustomColumnSettings}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
t(column.label || '')
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
{/* Add Custom Column Button - positioned at the end and scrolls with content */}
|
|
||||||
<div className="flex items-center justify-center px-2" style={{ width: '50px', flexShrink: 0 }}>
|
|
||||||
<AddCustomColumnButton />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
), [visibleColumns, t, handleCustomColumnSettings]);
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Loading and error states
|
|
||||||
if (loading || loadingColumns) return <Skeleton active />;
|
|
||||||
if (error) return <div>{t('emptyStates.errorPrefix')} {error}</div>;
|
|
||||||
|
|
||||||
// Show message when no data
|
|
||||||
if (groups.length === 0 && !loading) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col bg-white dark:bg-gray-900 h-full">
|
|
||||||
<div className="flex-none" style={{ height: '74px', flexShrink: 0 }}>
|
|
||||||
<ImprovedTaskFilters position="list" />
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 flex items-center justify-center">
|
|
||||||
<div className="text-center">
|
|
||||||
<div className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
|
||||||
{t('emptyStates.noTaskGroups')}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{t('emptyStates.noTaskGroupsDescription')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DndContext
|
<div>
|
||||||
sensors={sensors}
|
{/* Task Filters */}
|
||||||
collisionDetection={closestCenter}
|
<div className="flex-none" style={{ height: '54px', flexShrink: 0 }}>
|
||||||
onDragStart={handleDragStart}
|
<ImprovedTaskFilters position="list" />
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col bg-white dark:bg-gray-900 h-full overflow-hidden">
|
|
||||||
{/* Task Filters */}
|
|
||||||
<div className="flex-none" style={{ height: '54px', flexShrink: 0 }}>
|
|
||||||
<ImprovedTaskFilters position="list" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table Container */}
|
|
||||||
<div
|
|
||||||
className="border border-gray-200 dark:border-gray-700 rounded-lg"
|
|
||||||
style={{
|
|
||||||
height: 'calc(100vh - 240px)', // Slightly reduce height to ensure scrollbar visibility
|
|
||||||
display: 'flex',
|
|
||||||
flexDirection: 'column',
|
|
||||||
overflow: 'hidden'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Task List Content with Sticky Header */}
|
|
||||||
<div
|
|
||||||
ref={contentScrollRef}
|
|
||||||
className="flex-1 bg-white dark:bg-gray-900 relative"
|
|
||||||
style={{
|
|
||||||
overflowX: 'auto',
|
|
||||||
overflowY: 'auto',
|
|
||||||
minHeight: 0
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{/* Sticky Column Headers */}
|
|
||||||
<div className="sticky top-0 z-30 bg-gray-50 dark:bg-gray-800" style={{ width: '100%', minWidth: 'max-content' }}>
|
|
||||||
{renderColumnHeaders()}
|
|
||||||
</div>
|
|
||||||
<SortableContext
|
|
||||||
items={virtuosoItems
|
|
||||||
.filter(item => !('isAddTaskRow' in item) && !item.parent_task_id)
|
|
||||||
.map(item => item.id)
|
|
||||||
.filter((id): id is string => id !== undefined)}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
<div style={{ minWidth: 'max-content' }}>
|
|
||||||
{/* Render groups manually for debugging */}
|
|
||||||
{virtuosoGroups.map((group, groupIndex) => (
|
|
||||||
<div key={group.id}>
|
|
||||||
{/* Group Header */}
|
|
||||||
{renderGroup(groupIndex)}
|
|
||||||
|
|
||||||
{/* Group Tasks */}
|
|
||||||
{!collapsedGroups.has(group.id) && group.tasks.map((task, taskIndex) => {
|
|
||||||
const globalTaskIndex = virtuosoGroups
|
|
||||||
.slice(0, groupIndex)
|
|
||||||
.reduce((sum, g) => sum + g.count, 0) + taskIndex;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
|
|
||||||
{renderTask(globalTaskIndex)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</SortableContext>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Drag Overlay */}
|
|
||||||
<DragOverlay dropAnimation={null}>
|
|
||||||
{activeId ? (
|
|
||||||
<div className="bg-white dark:bg-gray-800 shadow-xl rounded-md border-2 border-blue-400 opacity-95">
|
|
||||||
<div className="px-4 py-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<HolderOutlined className="text-blue-500" />
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
|
||||||
{allTasks.find(task => task.id === activeId)?.name ||
|
|
||||||
allTasks.find(task => task.id === activeId)?.title ||
|
|
||||||
t('emptyStates.dragTaskFallback')}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
|
||||||
{allTasks.find(task => task.id === activeId)?.task_key}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</DragOverlay>
|
|
||||||
|
|
||||||
{/* Bulk Action Bar */}
|
|
||||||
{selectedTaskIds.length > 0 && urlProjectId && (
|
|
||||||
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50">
|
|
||||||
<OptimizedBulkActionBar
|
|
||||||
selectedTaskIds={selectedTaskIds}
|
|
||||||
totalSelected={selectedTaskIds.length}
|
|
||||||
projectId={urlProjectId}
|
|
||||||
onClearSelection={bulkActions.handleClearSelection}
|
|
||||||
onBulkStatusChange={(statusId) => bulkActions.handleBulkStatusChange(statusId, selectedTaskIds)}
|
|
||||||
onBulkPriorityChange={(priorityId) => bulkActions.handleBulkPriorityChange(priorityId, selectedTaskIds)}
|
|
||||||
onBulkPhaseChange={(phaseId) => bulkActions.handleBulkPhaseChange(phaseId, selectedTaskIds)}
|
|
||||||
onBulkAssignToMe={() => bulkActions.handleBulkAssignToMe(selectedTaskIds)}
|
|
||||||
onBulkAssignMembers={(memberIds) => bulkActions.handleBulkAssignMembers(memberIds, selectedTaskIds)}
|
|
||||||
onBulkAddLabels={(labelIds) => bulkActions.handleBulkAddLabels(labelIds, selectedTaskIds)}
|
|
||||||
onBulkArchive={() => bulkActions.handleBulkArchive(selectedTaskIds)}
|
|
||||||
onBulkDelete={() => bulkActions.handleBulkDelete(selectedTaskIds)}
|
|
||||||
onBulkDuplicate={() => bulkActions.handleBulkDuplicate(selectedTaskIds)}
|
|
||||||
onBulkExport={() => bulkActions.handleBulkExport(selectedTaskIds)}
|
|
||||||
onBulkSetDueDate={(date) => bulkActions.handleBulkSetDueDate(date, selectedTaskIds)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Custom Column Modal */}
|
|
||||||
{createPortal(<CustomColumnModal />, document.body, 'custom-column-modal')}
|
|
||||||
</div>
|
</div>
|
||||||
</DndContext>
|
<TaskListV2Section />
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,678 @@
|
|||||||
|
import React, { useCallback, useMemo, useEffect, useState, useRef } from 'react';
|
||||||
|
import { GroupedVirtuoso } from 'react-virtuoso';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
DragOverlay,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
KeyboardSensor,
|
||||||
|
TouchSensor,
|
||||||
|
closestCenter,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
SortableContext,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { Skeleton } from 'antd';
|
||||||
|
import { HolderOutlined } from '@ant-design/icons';
|
||||||
|
|
||||||
|
// Redux hooks and selectors
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import {
|
||||||
|
selectAllTasksArray,
|
||||||
|
selectGroups,
|
||||||
|
selectGrouping,
|
||||||
|
selectLoading,
|
||||||
|
selectError,
|
||||||
|
fetchTasksV3,
|
||||||
|
fetchTaskListColumns,
|
||||||
|
selectColumns,
|
||||||
|
selectCustomColumns,
|
||||||
|
selectLoadingColumns,
|
||||||
|
updateColumnVisibility,
|
||||||
|
} from '@/features/task-management/task-management.slice';
|
||||||
|
import {
|
||||||
|
selectCurrentGrouping,
|
||||||
|
selectCollapsedGroups,
|
||||||
|
toggleGroupCollapsed,
|
||||||
|
} from '@/features/task-management/grouping.slice';
|
||||||
|
import {
|
||||||
|
selectSelectedTaskIds,
|
||||||
|
selectLastSelectedTaskId,
|
||||||
|
selectTask,
|
||||||
|
toggleTaskSelection,
|
||||||
|
selectRange,
|
||||||
|
clearSelection,
|
||||||
|
} from '@/features/task-management/selection.slice';
|
||||||
|
import {
|
||||||
|
setCustomColumnModalAttributes,
|
||||||
|
toggleCustomColumnModalOpen,
|
||||||
|
} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
||||||
|
|
||||||
|
// Components
|
||||||
|
import TaskRowWithSubtasks from './TaskRowWithSubtasks';
|
||||||
|
import TaskGroupHeader from './TaskGroupHeader';
|
||||||
|
import ImprovedTaskFilters from '@/components/task-management/improved-task-filters';
|
||||||
|
import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk-action-bar';
|
||||||
|
import CustomColumnModal from '@/pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/custom-column-modal';
|
||||||
|
import AddTaskRow from './components/AddTaskRow';
|
||||||
|
import {
|
||||||
|
AddCustomColumnButton,
|
||||||
|
CustomColumnHeader,
|
||||||
|
} from './components/CustomColumnComponents';
|
||||||
|
|
||||||
|
// Hooks and utilities
|
||||||
|
import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { useDragAndDrop } from './hooks/useDragAndDrop';
|
||||||
|
import { useBulkActions } from './hooks/useBulkActions';
|
||||||
|
|
||||||
|
// Constants and types
|
||||||
|
import { BASE_COLUMNS, ColumnStyle } from './constants/columns';
|
||||||
|
import { Task } from '@/types/task-management.types';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
|
||||||
|
const TaskListV2Section: React.FC = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { projectId: urlProjectId } = useParams();
|
||||||
|
const { t } = useTranslation('task-list-table');
|
||||||
|
const { socket, connected } = useSocket();
|
||||||
|
|
||||||
|
// Redux state selectors
|
||||||
|
const allTasks = useAppSelector(selectAllTasksArray);
|
||||||
|
const groups = useAppSelector(selectGroups);
|
||||||
|
const grouping = useAppSelector(selectGrouping);
|
||||||
|
const loading = useAppSelector(selectLoading);
|
||||||
|
const error = useAppSelector(selectError);
|
||||||
|
const currentGrouping = useAppSelector(selectCurrentGrouping);
|
||||||
|
const selectedTaskIds = useAppSelector(selectSelectedTaskIds);
|
||||||
|
const lastSelectedTaskId = useAppSelector(selectLastSelectedTaskId);
|
||||||
|
const collapsedGroups = useAppSelector(selectCollapsedGroups);
|
||||||
|
|
||||||
|
const fields = useAppSelector(state => state.taskManagementFields) || [];
|
||||||
|
const columns = useAppSelector(selectColumns);
|
||||||
|
const customColumns = useAppSelector(selectCustomColumns);
|
||||||
|
const loadingColumns = useAppSelector(selectLoadingColumns);
|
||||||
|
|
||||||
|
// Refs for scroll synchronization
|
||||||
|
const headerScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const contentScrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// State hooks
|
||||||
|
const [initializedFromDatabase, setInitializedFromDatabase] = useState(false);
|
||||||
|
|
||||||
|
// Configure sensors for drag and drop
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
distance: 8,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
}),
|
||||||
|
useSensor(TouchSensor, {
|
||||||
|
activationConstraint: {
|
||||||
|
delay: 250,
|
||||||
|
tolerance: 5,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Custom hooks
|
||||||
|
const { activeId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(allTasks, groups);
|
||||||
|
const bulkActions = useBulkActions();
|
||||||
|
|
||||||
|
// Enable real-time updates via socket handlers
|
||||||
|
useTaskSocketHandlers();
|
||||||
|
|
||||||
|
// Filter visible columns based on local fields (primary) and backend columns (fallback)
|
||||||
|
const visibleColumns = useMemo(() => {
|
||||||
|
// Start with base columns
|
||||||
|
const baseVisibleColumns = BASE_COLUMNS.filter(column => {
|
||||||
|
// Always show drag handle and title (sticky columns)
|
||||||
|
if (column.isSticky) return true;
|
||||||
|
|
||||||
|
// Primary: Check local fields configuration
|
||||||
|
const field = fields.find(f => f.key === column.key);
|
||||||
|
if (field) {
|
||||||
|
return field.visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: Check backend column configuration if local field not found
|
||||||
|
const backendColumn = columns.find(c => c.key === column.key);
|
||||||
|
if (backendColumn) {
|
||||||
|
return backendColumn.pinned ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default: hide if neither local field nor backend column found
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add visible custom columns
|
||||||
|
const visibleCustomColumns = customColumns
|
||||||
|
?.filter(column => column.pinned)
|
||||||
|
?.map(column => {
|
||||||
|
// Give selection columns more width for dropdown content
|
||||||
|
const fieldType = column.custom_column_obj?.fieldType;
|
||||||
|
let defaultWidth = 160;
|
||||||
|
if (fieldType === 'selection') {
|
||||||
|
defaultWidth = 150; // Reduced width for selection dropdowns
|
||||||
|
} else if (fieldType === 'people') {
|
||||||
|
defaultWidth = 170; // Extra width for people with avatars
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map the configuration data structure to the expected format
|
||||||
|
const customColumnObj = column.custom_column_obj || (column as any).configuration;
|
||||||
|
|
||||||
|
// Transform configuration format to custom_column_obj format if needed
|
||||||
|
let transformedColumnObj = customColumnObj;
|
||||||
|
if (customColumnObj && !customColumnObj.fieldType && customColumnObj.field_type) {
|
||||||
|
transformedColumnObj = {
|
||||||
|
...customColumnObj,
|
||||||
|
fieldType: customColumnObj.field_type,
|
||||||
|
numberType: customColumnObj.number_type,
|
||||||
|
labelPosition: customColumnObj.label_position,
|
||||||
|
previewValue: customColumnObj.preview_value,
|
||||||
|
firstNumericColumn: customColumnObj.first_numeric_column_key,
|
||||||
|
secondNumericColumn: customColumnObj.second_numeric_column_key,
|
||||||
|
selectionsList: customColumnObj.selections_list || customColumnObj.selectionsList || [],
|
||||||
|
labelsList: customColumnObj.labels_list || customColumnObj.labelsList || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: column.key || column.id || 'unknown',
|
||||||
|
label: column.name || t('customColumns.customColumnHeader'),
|
||||||
|
width: `${(column as any).width || defaultWidth}px`,
|
||||||
|
key: column.key || column.id || 'unknown',
|
||||||
|
custom_column: true,
|
||||||
|
custom_column_obj: transformedColumnObj,
|
||||||
|
isCustom: true,
|
||||||
|
name: column.name,
|
||||||
|
uuid: column.id,
|
||||||
|
};
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
return [...baseVisibleColumns, ...visibleCustomColumns];
|
||||||
|
}, [fields, columns, customColumns, t]);
|
||||||
|
|
||||||
|
// Effects
|
||||||
|
useEffect(() => {
|
||||||
|
if (urlProjectId) {
|
||||||
|
dispatch(fetchTasksV3(urlProjectId));
|
||||||
|
dispatch(fetchTaskListColumns(urlProjectId));
|
||||||
|
}
|
||||||
|
}, [dispatch, urlProjectId]);
|
||||||
|
|
||||||
|
// Initialize field visibility from database when columns are loaded (only once)
|
||||||
|
useEffect(() => {
|
||||||
|
if (columns.length > 0 && fields.length > 0 && !initializedFromDatabase) {
|
||||||
|
// Update local fields to match database state only on initial load
|
||||||
|
import('@/features/task-management/taskListFields.slice').then(({ setFields }) => {
|
||||||
|
// Create updated fields based on database column state
|
||||||
|
const updatedFields = fields.map(field => {
|
||||||
|
const backendColumn = columns.find(c => c.key === field.key);
|
||||||
|
if (backendColumn) {
|
||||||
|
return {
|
||||||
|
...field,
|
||||||
|
visible: backendColumn.pinned ?? field.visible
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return field;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Only update if there are actual changes
|
||||||
|
const hasChanges = updatedFields.some((field, index) =>
|
||||||
|
field.visible !== fields[index].visible
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasChanges) {
|
||||||
|
dispatch(setFields(updatedFields));
|
||||||
|
}
|
||||||
|
|
||||||
|
setInitializedFromDatabase(true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [columns, fields, dispatch, initializedFromDatabase]);
|
||||||
|
|
||||||
|
// Event handlers
|
||||||
|
const handleTaskSelect = useCallback(
|
||||||
|
(taskId: string, event: React.MouseEvent) => {
|
||||||
|
if (event.ctrlKey || event.metaKey) {
|
||||||
|
dispatch(toggleTaskSelection(taskId));
|
||||||
|
} else if (event.shiftKey && lastSelectedTaskId) {
|
||||||
|
const taskIds = allTasks.map(t => t.id);
|
||||||
|
const startIdx = taskIds.indexOf(lastSelectedTaskId);
|
||||||
|
const endIdx = taskIds.indexOf(taskId);
|
||||||
|
const rangeIds = taskIds.slice(Math.min(startIdx, endIdx), Math.max(startIdx, endIdx) + 1);
|
||||||
|
dispatch(selectRange(rangeIds));
|
||||||
|
} else {
|
||||||
|
dispatch(clearSelection());
|
||||||
|
dispatch(selectTask(taskId));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch, lastSelectedTaskId, allTasks]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGroupCollapse = useCallback(
|
||||||
|
(groupId: string) => {
|
||||||
|
dispatch(toggleGroupCollapsed(groupId));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Function to update custom column values
|
||||||
|
const updateTaskCustomColumnValue = useCallback((taskId: string, columnKey: string, value: string) => {
|
||||||
|
try {
|
||||||
|
if (!urlProjectId) {
|
||||||
|
console.error('Project ID is missing');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
task_id: taskId,
|
||||||
|
column_key: columnKey,
|
||||||
|
value: value,
|
||||||
|
project_id: urlProjectId,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update the Redux store immediately for optimistic updates
|
||||||
|
const currentTask = allTasks.find(task => task.id === taskId);
|
||||||
|
if (currentTask) {
|
||||||
|
const updatedTask = {
|
||||||
|
...currentTask,
|
||||||
|
custom_column_values: {
|
||||||
|
...currentTask.custom_column_values,
|
||||||
|
[columnKey]: value,
|
||||||
|
},
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Import and dispatch the updateTask action
|
||||||
|
import('@/features/task-management/task-management.slice').then(({ updateTask }) => {
|
||||||
|
dispatch(updateTask(updatedTask));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (socket && connected) {
|
||||||
|
socket.emit(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), JSON.stringify(body));
|
||||||
|
} else {
|
||||||
|
console.warn('Socket not connected, unable to emit TASK_CUSTOM_COLUMN_UPDATE event');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating custom column value:', error);
|
||||||
|
}
|
||||||
|
}, [urlProjectId, socket, connected, allTasks, dispatch]);
|
||||||
|
|
||||||
|
// Custom column settings handler
|
||||||
|
const handleCustomColumnSettings = useCallback((columnKey: string) => {
|
||||||
|
if (!columnKey) return;
|
||||||
|
|
||||||
|
const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey);
|
||||||
|
|
||||||
|
// Use the UUID for API calls, not the key (nanoid)
|
||||||
|
// For custom columns, prioritize the uuid field over id field
|
||||||
|
const columnId = (columnData as any)?.uuid || columnData?.id || columnKey;
|
||||||
|
|
||||||
|
dispatch(setCustomColumnModalAttributes({
|
||||||
|
modalType: 'edit',
|
||||||
|
columnId: columnId,
|
||||||
|
columnData: columnData
|
||||||
|
}));
|
||||||
|
dispatch(toggleCustomColumnModalOpen(true));
|
||||||
|
}, [dispatch, visibleColumns]);
|
||||||
|
|
||||||
|
// Add callback for task added
|
||||||
|
const handleTaskAdded = useCallback(() => {
|
||||||
|
// Task is now added in real-time via socket, no need to refetch
|
||||||
|
// The global socket handler will handle the real-time update
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle scroll synchronization - disabled since header is now sticky inside content
|
||||||
|
const handleContentScroll = useCallback(() => {
|
||||||
|
// No longer needed since header scrolls naturally with content
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Memoized values for GroupedVirtuoso
|
||||||
|
const virtuosoGroups = useMemo(() => {
|
||||||
|
let currentTaskIndex = 0;
|
||||||
|
|
||||||
|
return groups.map(group => {
|
||||||
|
const isCurrentGroupCollapsed = collapsedGroups.has(group.id);
|
||||||
|
|
||||||
|
const visibleTasksInGroup = isCurrentGroupCollapsed
|
||||||
|
? []
|
||||||
|
: group.taskIds
|
||||||
|
.map(taskId => allTasks.find(task => task.id === taskId))
|
||||||
|
.filter((task): task is Task => task !== undefined);
|
||||||
|
|
||||||
|
const tasksForVirtuoso = visibleTasksInGroup.map(task => ({
|
||||||
|
...task,
|
||||||
|
originalIndex: allTasks.indexOf(task),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const itemsWithAddTask = !isCurrentGroupCollapsed ? [
|
||||||
|
...tasksForVirtuoso,
|
||||||
|
{
|
||||||
|
id: `add-task-${group.id}`,
|
||||||
|
isAddTaskRow: true,
|
||||||
|
groupId: group.id,
|
||||||
|
groupType: currentGrouping || 'status',
|
||||||
|
groupValue: group.id, // Use the actual database ID from backend
|
||||||
|
projectId: urlProjectId,
|
||||||
|
}
|
||||||
|
] : tasksForVirtuoso;
|
||||||
|
|
||||||
|
const groupData = {
|
||||||
|
...group,
|
||||||
|
tasks: itemsWithAddTask,
|
||||||
|
startIndex: currentTaskIndex,
|
||||||
|
count: itemsWithAddTask.length,
|
||||||
|
actualCount: group.taskIds.length,
|
||||||
|
groupValue: group.groupValue || group.title,
|
||||||
|
};
|
||||||
|
currentTaskIndex += itemsWithAddTask.length;
|
||||||
|
return groupData;
|
||||||
|
});
|
||||||
|
}, [groups, allTasks, collapsedGroups, currentGrouping, urlProjectId]);
|
||||||
|
|
||||||
|
const virtuosoGroupCounts = useMemo(() => {
|
||||||
|
return virtuosoGroups.map(group => group.count);
|
||||||
|
}, [virtuosoGroups]);
|
||||||
|
|
||||||
|
const virtuosoItems = useMemo(() => {
|
||||||
|
return virtuosoGroups.flatMap(group => group.tasks);
|
||||||
|
}, [virtuosoGroups]);
|
||||||
|
|
||||||
|
// Render functions
|
||||||
|
const renderGroup = useCallback(
|
||||||
|
(groupIndex: number) => {
|
||||||
|
const group = virtuosoGroups[groupIndex];
|
||||||
|
const isGroupCollapsed = collapsedGroups.has(group.id);
|
||||||
|
const isGroupEmpty = group.actualCount === 0;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={groupIndex > 0 ? 'mt-2' : ''}>
|
||||||
|
<TaskGroupHeader
|
||||||
|
group={{
|
||||||
|
id: group.id,
|
||||||
|
name: group.title,
|
||||||
|
count: group.actualCount,
|
||||||
|
color: group.color,
|
||||||
|
}}
|
||||||
|
isCollapsed={isGroupCollapsed}
|
||||||
|
onToggle={() => handleGroupCollapse(group.id)}
|
||||||
|
projectId={urlProjectId || ''}
|
||||||
|
/>
|
||||||
|
{isGroupEmpty && !isGroupCollapsed && (
|
||||||
|
<div className="relative w-full">
|
||||||
|
<div className="flex items-center min-w-max px-1 py-3">
|
||||||
|
{visibleColumns.map((column, index) => (
|
||||||
|
<div
|
||||||
|
key={`empty-${column.id}`}
|
||||||
|
style={{ width: column.width, flexShrink: 0 }}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div className="text-sm italic text-gray-400 dark:text-gray-500 bg-white dark:bg-gray-900 px-4 py-1 rounded-md border border-gray-200 dark:border-gray-700">
|
||||||
|
{t('noTasksInGroup')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t]
|
||||||
|
);
|
||||||
|
|
||||||
|
const renderTask = useCallback(
|
||||||
|
(taskIndex: number) => {
|
||||||
|
const item = virtuosoItems[taskIndex];
|
||||||
|
|
||||||
|
|
||||||
|
if (!item || !urlProjectId) return null;
|
||||||
|
|
||||||
|
if ('isAddTaskRow' in item && item.isAddTaskRow) {
|
||||||
|
return (
|
||||||
|
<AddTaskRow
|
||||||
|
groupId={item.groupId}
|
||||||
|
groupType={item.groupType}
|
||||||
|
groupValue={item.groupValue}
|
||||||
|
projectId={urlProjectId}
|
||||||
|
visibleColumns={visibleColumns}
|
||||||
|
onTaskAdded={handleTaskAdded}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TaskRowWithSubtasks
|
||||||
|
taskId={item.id}
|
||||||
|
projectId={urlProjectId}
|
||||||
|
visibleColumns={visibleColumns}
|
||||||
|
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[virtuosoItems, visibleColumns, urlProjectId, handleTaskAdded, updateTaskCustomColumnValue]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Render column headers
|
||||||
|
const renderColumnHeaders = useCallback(() => (
|
||||||
|
<div className="bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700" style={{ width: '100%', minWidth: 'max-content' }}>
|
||||||
|
<div className="flex items-center px-1 py-3 w-full" style={{ minWidth: 'max-content', height: '44px' }}>
|
||||||
|
{visibleColumns.map((column, index) => {
|
||||||
|
const columnStyle: ColumnStyle = {
|
||||||
|
width: column.width,
|
||||||
|
flexShrink: 0,
|
||||||
|
...(column.id === 'labels' && column.width === 'auto'
|
||||||
|
? {
|
||||||
|
minWidth: '200px',
|
||||||
|
flexGrow: 1,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...((column as any).minWidth && { minWidth: (column as any).minWidth }),
|
||||||
|
...((column as any).maxWidth && { maxWidth: (column as any).maxWidth }),
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={column.id}
|
||||||
|
className={`text-sm font-semibold text-gray-600 dark:text-gray-300 ${column.id === 'dragHandle'
|
||||||
|
? 'flex items-center justify-center'
|
||||||
|
: column.id === 'checkbox'
|
||||||
|
? 'flex items-center justify-center'
|
||||||
|
: column.id === 'taskKey'
|
||||||
|
? 'flex items-center pl-3'
|
||||||
|
: column.id === 'title'
|
||||||
|
? 'flex items-center justify-between'
|
||||||
|
: column.id === 'description'
|
||||||
|
? 'flex items-center px-2'
|
||||||
|
: column.id === 'labels'
|
||||||
|
? 'flex items-center gap-0.5 flex-wrap min-w-0 px-2'
|
||||||
|
: column.id === 'assignees'
|
||||||
|
? 'flex items-center px-2'
|
||||||
|
: 'flex items-center justify-center px-2'
|
||||||
|
}`}
|
||||||
|
style={columnStyle}
|
||||||
|
>
|
||||||
|
{column.id === 'dragHandle' || column.id === 'checkbox' ? (
|
||||||
|
<span></span>
|
||||||
|
) : (column as any).isCustom ? (
|
||||||
|
<CustomColumnHeader
|
||||||
|
column={column}
|
||||||
|
onSettingsClick={handleCustomColumnSettings}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
t(column.label || '')
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{/* Add Custom Column Button - positioned at the end and scrolls with content */}
|
||||||
|
<div className="flex items-center justify-center px-2" style={{ width: '50px', flexShrink: 0 }}>
|
||||||
|
<AddCustomColumnButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
), [visibleColumns, t, handleCustomColumnSettings]);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Loading and error states
|
||||||
|
if (loading || loadingColumns) return <Skeleton style={{ marginTop: 8 }} active />;
|
||||||
|
if (error) return <div>{t('emptyStates.errorPrefix')} {error}</div>;
|
||||||
|
|
||||||
|
// Show message when no data
|
||||||
|
if (groups.length === 0 && !loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col bg-white dark:bg-gray-900 h-full">
|
||||||
|
<div className="flex-none" style={{ height: '74px', flexShrink: 0 }}>
|
||||||
|
<ImprovedTaskFilters position="list" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-lg font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
{t('emptyStates.noTaskGroups')}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
{t('emptyStates.noTaskGroupsDescription')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragOver={handleDragOver}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col bg-white dark:bg-gray-900 h-full overflow-hidden">
|
||||||
|
|
||||||
|
{/* Table Container */}
|
||||||
|
<div
|
||||||
|
className="border border-gray-200 dark:border-gray-700 rounded-lg"
|
||||||
|
style={{
|
||||||
|
height: 'calc(100vh - 240px)', // Slightly reduce height to ensure scrollbar visibility
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
overflow: 'hidden'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Task List Content with Sticky Header */}
|
||||||
|
<div
|
||||||
|
ref={contentScrollRef}
|
||||||
|
className="flex-1 bg-white dark:bg-gray-900 relative"
|
||||||
|
style={{
|
||||||
|
overflowX: 'auto',
|
||||||
|
overflowY: 'auto',
|
||||||
|
minHeight: 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Sticky Column Headers */}
|
||||||
|
<div className="sticky top-0 z-30 bg-gray-50 dark:bg-gray-800" style={{ width: '100%', minWidth: 'max-content' }}>
|
||||||
|
{renderColumnHeaders()}
|
||||||
|
</div>
|
||||||
|
<SortableContext
|
||||||
|
items={virtuosoItems
|
||||||
|
.filter(item => !('isAddTaskRow' in item) && !item.parent_task_id)
|
||||||
|
.map(item => item.id)
|
||||||
|
.filter((id): id is string => id !== undefined)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 'max-content' }}>
|
||||||
|
{/* Render groups manually for debugging */}
|
||||||
|
{virtuosoGroups.map((group, groupIndex) => (
|
||||||
|
<div key={group.id}>
|
||||||
|
{/* Group Header */}
|
||||||
|
{renderGroup(groupIndex)}
|
||||||
|
|
||||||
|
{/* Group Tasks */}
|
||||||
|
{!collapsedGroups.has(group.id) && group.tasks.map((task, taskIndex) => {
|
||||||
|
const globalTaskIndex = virtuosoGroups
|
||||||
|
.slice(0, groupIndex)
|
||||||
|
.reduce((sum, g) => sum + g.count, 0) + taskIndex;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={task.id || `add-task-${group.id}-${taskIndex}`}>
|
||||||
|
{renderTask(globalTaskIndex)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drag Overlay */}
|
||||||
|
<DragOverlay dropAnimation={null}>
|
||||||
|
{activeId ? (
|
||||||
|
<div className="bg-white dark:bg-gray-800 shadow-xl rounded-md border-2 border-blue-400 opacity-95">
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<HolderOutlined className="text-blue-500" />
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium text-gray-900 dark:text-white">
|
||||||
|
{allTasks.find(task => task.id === activeId)?.name ||
|
||||||
|
allTasks.find(task => task.id === activeId)?.title ||
|
||||||
|
t('emptyStates.dragTaskFallback')}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
|
{allTasks.find(task => task.id === activeId)?.task_key}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</DragOverlay>
|
||||||
|
|
||||||
|
{/* Bulk Action Bar */}
|
||||||
|
{selectedTaskIds.length > 0 && urlProjectId && (
|
||||||
|
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50">
|
||||||
|
<OptimizedBulkActionBar
|
||||||
|
selectedTaskIds={selectedTaskIds}
|
||||||
|
totalSelected={selectedTaskIds.length}
|
||||||
|
projectId={urlProjectId}
|
||||||
|
onClearSelection={bulkActions.handleClearSelection}
|
||||||
|
onBulkStatusChange={(statusId) => bulkActions.handleBulkStatusChange(statusId, selectedTaskIds)}
|
||||||
|
onBulkPriorityChange={(priorityId) => bulkActions.handleBulkPriorityChange(priorityId, selectedTaskIds)}
|
||||||
|
onBulkPhaseChange={(phaseId) => bulkActions.handleBulkPhaseChange(phaseId, selectedTaskIds)}
|
||||||
|
onBulkAssignToMe={() => bulkActions.handleBulkAssignToMe(selectedTaskIds)}
|
||||||
|
onBulkAssignMembers={(memberIds) => bulkActions.handleBulkAssignMembers(memberIds, selectedTaskIds)}
|
||||||
|
onBulkAddLabels={(labelIds) => bulkActions.handleBulkAddLabels(labelIds, selectedTaskIds)}
|
||||||
|
onBulkArchive={() => bulkActions.handleBulkArchive(selectedTaskIds)}
|
||||||
|
onBulkDelete={() => bulkActions.handleBulkDelete(selectedTaskIds)}
|
||||||
|
onBulkDuplicate={() => bulkActions.handleBulkDuplicate(selectedTaskIds)}
|
||||||
|
onBulkExport={() => bulkActions.handleBulkExport(selectedTaskIds)}
|
||||||
|
onBulkSetDueDate={(date) => bulkActions.handleBulkSetDueDate(date, selectedTaskIds)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Custom Column Modal */}
|
||||||
|
{createPortal(<CustomColumnModal />, document.body, 'custom-column-modal')}
|
||||||
|
</div>
|
||||||
|
</DndContext>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TaskListV2Section;
|
||||||
Reference in New Issue
Block a user