Merge pull request #237 from Worklenz/fix/task-drag-and-drop-improvement
Fix/task drag and drop improvement
This commit is contained in:
@@ -134,6 +134,25 @@ export default class TaskStatusesController extends WorklenzControllerBase {
|
|||||||
return res.status(200).send(new ServerResponse(true, data));
|
return res.status(200).send(new ServerResponse(true, data));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@HandleExceptions()
|
||||||
|
public static async updateCategory(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
|
const hasMoreCategories = await TaskStatusesController.hasMoreCategories(req.params.id, req.query.current_project_id as string);
|
||||||
|
|
||||||
|
if (!hasMoreCategories)
|
||||||
|
return res.status(200).send(new ServerResponse(false, null, existsErrorMessage).withTitle("Status category update failed!"));
|
||||||
|
|
||||||
|
const q = `
|
||||||
|
UPDATE task_statuses
|
||||||
|
SET category_id = $2
|
||||||
|
WHERE id = $1
|
||||||
|
AND project_id = $3
|
||||||
|
RETURNING (SELECT color_code FROM sys_task_status_categories WHERE id = task_statuses.category_id), (SELECT color_code_dark FROM sys_task_status_categories WHERE id = task_statuses.category_id);
|
||||||
|
`;
|
||||||
|
const result = await db.query(q, [req.params.id, req.body.category_id, req.query.current_project_id]);
|
||||||
|
const [data] = result.rows;
|
||||||
|
return res.status(200).send(new ServerResponse(true, data));
|
||||||
|
}
|
||||||
|
|
||||||
@HandleExceptions()
|
@HandleExceptions()
|
||||||
public static async updateStatusOrder(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
public static async updateStatusOrder(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise<IWorkLenzResponse> {
|
||||||
const q = `SELECT update_status_order($1);`;
|
const q = `SELECT update_status_order($1);`;
|
||||||
|
|||||||
@@ -1085,6 +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 || {}, // 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,
|
||||||
@@ -1135,22 +1136,61 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Distribute tasks into groups
|
// Distribute tasks into groups
|
||||||
|
const unmappedTasks: any[] = [];
|
||||||
|
|
||||||
transformedTasks.forEach(task => {
|
transformedTasks.forEach(task => {
|
||||||
let groupKey: string;
|
let groupKey: string;
|
||||||
|
let taskAssigned = false;
|
||||||
|
|
||||||
if (groupBy === GroupBy.STATUS) {
|
if (groupBy === GroupBy.STATUS) {
|
||||||
groupKey = task.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) {
|
} else if (groupBy === GroupBy.PRIORITY) {
|
||||||
groupKey = task.priority;
|
groupKey = task.priority;
|
||||||
} else {
|
if (groupedResponse[groupKey]) {
|
||||||
groupKey = task.phase.toLowerCase().replace(/\s+/g, "_");
|
groupedResponse[groupKey].tasks.push(task);
|
||||||
}
|
groupedResponse[groupKey].taskIds.push(task.id);
|
||||||
|
taskAssigned = true;
|
||||||
if (groupedResponse[groupKey]) {
|
}
|
||||||
groupedResponse[groupKey].tasks.push(task);
|
} else if (groupBy === GroupBy.PHASE) {
|
||||||
groupedResponse[groupKey].taskIds.push(task.id);
|
// 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
|
// Sort tasks within each group by order
|
||||||
Object.values(groupedResponse).forEach((group: any) => {
|
Object.values(groupedResponse).forEach((group: any) => {
|
||||||
group.tasks.sort((a: any, b: any) => a.order - b.order);
|
group.tasks.sort((a: any, b: any) => a.order - b.order);
|
||||||
@@ -1169,6 +1209,11 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
})
|
})
|
||||||
.filter(group => group && (group.tasks.length > 0 || req.query.include_empty === "true"));
|
.filter(group => group && (group.tasks.length > 0 || req.query.include_empty === "true"));
|
||||||
|
|
||||||
|
// Add unmapped group to the end if it exists
|
||||||
|
if (groupedResponse[UNMAPPED.toLowerCase()]) {
|
||||||
|
responseGroups.push(groupedResponse[UNMAPPED.toLowerCase()]);
|
||||||
|
}
|
||||||
|
|
||||||
const groupingEndTime = performance.now();
|
const groupingEndTime = performance.now();
|
||||||
|
|
||||||
const endTime = performance.now();
|
const endTime = performance.now();
|
||||||
@@ -1183,15 +1228,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
groups: responseGroups,
|
groups: responseGroups,
|
||||||
allTasks: transformedTasks,
|
allTasks: transformedTasks,
|
||||||
grouping: groupBy,
|
grouping: groupBy,
|
||||||
totalTasks: transformedTasks.length,
|
totalTasks: transformedTasks.length
|
||||||
performanceMetrics: {
|
|
||||||
totalTime: Math.round(totalTime),
|
|
||||||
queryTime: Math.round(queryEndTime - queryStartTime),
|
|
||||||
transformTime: Math.round(transformEndTime - transformStartTime),
|
|
||||||
groupingTime: Math.round(groupingEndTime - groupingStartTime),
|
|
||||||
progressRefreshTime: shouldRefreshProgress ? Math.round(queryStartTime - startTime) : 0,
|
|
||||||
taskCount: transformedTasks.length
|
|
||||||
}
|
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1213,6 +1250,7 @@ export default class TasksControllerV2 extends TasksControllerBase {
|
|||||||
development: "#1890ff",
|
development: "#1890ff",
|
||||||
testing: "#faad14",
|
testing: "#faad14",
|
||||||
deployment: "#52c41a",
|
deployment: "#52c41a",
|
||||||
|
unmapped: "#fbc84c69",
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ statusesApiRouter.put("/order", statusOrderValidator, safeControllerFunction(Tas
|
|||||||
statusesApiRouter.get("/categories", safeControllerFunction(TaskStatusesController.getCategories));
|
statusesApiRouter.get("/categories", safeControllerFunction(TaskStatusesController.getCategories));
|
||||||
statusesApiRouter.get("/:id", idParamValidator, safeControllerFunction(TaskStatusesController.getById));
|
statusesApiRouter.get("/:id", idParamValidator, safeControllerFunction(TaskStatusesController.getById));
|
||||||
statusesApiRouter.put("/name/:id", projectManagerValidator, idParamValidator, taskStatusBodyValidator, safeControllerFunction(TaskStatusesController.updateName));
|
statusesApiRouter.put("/name/:id", projectManagerValidator, idParamValidator, taskStatusBodyValidator, safeControllerFunction(TaskStatusesController.updateName));
|
||||||
|
statusesApiRouter.put("/category/:id", projectManagerValidator, idParamValidator, safeControllerFunction(TaskStatusesController.updateCategory));
|
||||||
statusesApiRouter.put("/:id", projectManagerValidator, idParamValidator, taskStatusBodyValidator, safeControllerFunction(TaskStatusesController.update));
|
statusesApiRouter.put("/:id", projectManagerValidator, idParamValidator, taskStatusBodyValidator, safeControllerFunction(TaskStatusesController.update));
|
||||||
statusesApiRouter.delete("/:id", projectManagerValidator, idParamValidator, statusDeleteValidator, safeControllerFunction(TaskStatusesController.deleteById));
|
statusesApiRouter.delete("/:id", projectManagerValidator, idParamValidator, statusDeleteValidator, safeControllerFunction(TaskStatusesController.deleteById));
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ export async function on_quick_task(_io: Server, socket: Socket, data?: string)
|
|||||||
const q = `SELECT create_quick_task($1) AS task;`;
|
const q = `SELECT create_quick_task($1) AS task;`;
|
||||||
const body = JSON.parse(data as string);
|
const body = JSON.parse(data as string);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
body.name = (body.name || "").trim();
|
body.name = (body.name || "").trim();
|
||||||
body.priority_id = body.priority_id?.trim() || null;
|
body.priority_id = body.priority_id?.trim() || null;
|
||||||
body.status_id = body.status_id?.trim() || null;
|
body.status_id = body.status_id?.trim() || null;
|
||||||
@@ -111,10 +113,12 @@ export async function on_quick_task(_io: Server, socket: Socket, data?: string)
|
|||||||
|
|
||||||
notifyProjectUpdates(socket, d.task.id);
|
notifyProjectUpdates(socket, d.task.id);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Empty task name, emit null to indicate no task was created
|
||||||
|
socket.emit(SocketEvents.QUICK_TASK.toString(), null);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log_error(error);
|
log_error(error);
|
||||||
|
socket.emit(SocketEvents.QUICK_TASK.toString(), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.emit(SocketEvents.QUICK_TASK.toString(), null);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
|
|
||||||
"addTaskText": "Shto Detyrë",
|
"addTaskText": "Shto Detyrë",
|
||||||
"addSubTaskText": "+ Shto Nën-Detyrë",
|
"addSubTaskText": "+ Shto Nën-Detyrë",
|
||||||
|
"noTasksInGroup": "Nuk ka detyra në këtë grup",
|
||||||
"addTaskInputPlaceholder": "Shkruaj detyrën dhe shtyp Enter",
|
"addTaskInputPlaceholder": "Shkruaj detyrën dhe shtyp Enter",
|
||||||
|
|
||||||
"openButton": "Hap",
|
"openButton": "Hap",
|
||||||
@@ -65,5 +66,47 @@
|
|||||||
"clearDueDate": "Pastro datën e afatit",
|
"clearDueDate": "Pastro datën e afatit",
|
||||||
"clearStartDate": "Pastro datën e fillimit",
|
"clearStartDate": "Pastro datën e fillimit",
|
||||||
"dueDatePlaceholder": "Data e afatit",
|
"dueDatePlaceholder": "Data e afatit",
|
||||||
"startDatePlaceholder": "Data e fillimit"
|
"startDatePlaceholder": "Data e fillimit",
|
||||||
|
|
||||||
|
"customColumns": {
|
||||||
|
"addCustomColumn": "Shto një kolonë të personalizuar",
|
||||||
|
"customColumnHeader": "Kolona e Personalizuar",
|
||||||
|
"customColumnSettings": "Cilësimet e kolonës së personalizuar",
|
||||||
|
"noCustomValue": "Asnjë vlerë",
|
||||||
|
"peopleField": "Fusha e njerëzve",
|
||||||
|
"noDate": "Asnjë datë",
|
||||||
|
"unsupportedField": "Lloj fushe i pambështetur",
|
||||||
|
|
||||||
|
"modal": {
|
||||||
|
"addFieldTitle": "Shto fushë",
|
||||||
|
"editFieldTitle": "Redakto fushën",
|
||||||
|
"fieldTitle": "Titulli i fushës",
|
||||||
|
"fieldTitleRequired": "Titulli i fushës është i kërkuar",
|
||||||
|
"columnTitlePlaceholder": "Titulli i kolonës",
|
||||||
|
"type": "Lloji",
|
||||||
|
"deleteConfirmTitle": "Jeni i sigurt që doni të fshini këtë kolonë të personalizuar?",
|
||||||
|
"deleteConfirmDescription": "Kjo veprim nuk mund të zhbëhet. Të gjitha të dhënat e lidhura me këtë kolonë do të fshihen përgjithmonë.",
|
||||||
|
"deleteButton": "Fshi",
|
||||||
|
"cancelButton": "Anulo",
|
||||||
|
"createButton": "Krijo",
|
||||||
|
"updateButton": "Përditëso",
|
||||||
|
"createSuccessMessage": "Kolona e personalizuar u krijua me sukses",
|
||||||
|
"updateSuccessMessage": "Kolona e personalizuar u përditësua me sukses",
|
||||||
|
"deleteSuccessMessage": "Kolona e personalizuar u fshi me sukses",
|
||||||
|
"deleteErrorMessage": "Dështoi në fshirjen e kolonës së personalizuar",
|
||||||
|
"createErrorMessage": "Dështoi në krijimin e kolonës së personalizuar",
|
||||||
|
"updateErrorMessage": "Dështoi në përditësimin e kolonës së personalizuar"
|
||||||
|
},
|
||||||
|
|
||||||
|
"fieldTypes": {
|
||||||
|
"people": "Njerëz",
|
||||||
|
"number": "Numër",
|
||||||
|
"date": "Data",
|
||||||
|
"selection": "Zgjedhje",
|
||||||
|
"checkbox": "Kutia e kontrollit",
|
||||||
|
"labels": "Etiketat",
|
||||||
|
"key": "Çelësi",
|
||||||
|
"formula": "Formula"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,11 @@
|
|||||||
"attachments": "bashkëngjitje",
|
"attachments": "bashkëngjitje",
|
||||||
"enterSubtaskName": "Shkruani emrin e nën-detyrës...",
|
"enterSubtaskName": "Shkruani emrin e nën-detyrës...",
|
||||||
"add": "Shto",
|
"add": "Shto",
|
||||||
"cancel": "Anulo"
|
"cancel": "Anulo",
|
||||||
|
"renameGroup": "Riemërto Grupin",
|
||||||
|
"renameStatus": "Riemërto Statusin",
|
||||||
|
"renamePhase": "Riemërto Fazën",
|
||||||
|
"changeCategory": "Ndrysho Kategorinë",
|
||||||
|
"clickToEditGroupName": "Kliko për të ndryshuar emrin e grupit",
|
||||||
|
"enterGroupName": "Shkruani emrin e grupit"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"addTaskText": "Aufgabe hinzufügen",
|
"addTaskText": "Aufgabe hinzufügen",
|
||||||
"addSubTaskText": "+ Unteraufgabe hinzufügen",
|
"addSubTaskText": "+ Unteraufgabe hinzufügen",
|
||||||
"addTaskInputPlaceholder": "Aufgabe eingeben und Enter drücken",
|
"addTaskInputPlaceholder": "Aufgabe eingeben und Enter drücken",
|
||||||
|
"noTasksInGroup": "Keine Aufgaben in dieser Gruppe",
|
||||||
|
|
||||||
"openButton": "Öffnen",
|
"openButton": "Öffnen",
|
||||||
"okButton": "OK",
|
"okButton": "OK",
|
||||||
@@ -65,5 +66,47 @@
|
|||||||
"clearDueDate": "Fälligkeitsdatum löschen",
|
"clearDueDate": "Fälligkeitsdatum löschen",
|
||||||
"clearStartDate": "Startdatum löschen",
|
"clearStartDate": "Startdatum löschen",
|
||||||
"dueDatePlaceholder": "Fälligkeitsdatum",
|
"dueDatePlaceholder": "Fälligkeitsdatum",
|
||||||
"startDatePlaceholder": "Startdatum"
|
"startDatePlaceholder": "Startdatum",
|
||||||
|
|
||||||
|
"customColumns": {
|
||||||
|
"addCustomColumn": "Benutzerdefinierte Spalte hinzufügen",
|
||||||
|
"customColumnHeader": "Benutzerdefinierte Spalte",
|
||||||
|
"customColumnSettings": "Einstellungen für benutzerdefinierte Spalte",
|
||||||
|
"noCustomValue": "Kein Wert",
|
||||||
|
"peopleField": "Personenfeld",
|
||||||
|
"noDate": "Kein Datum",
|
||||||
|
"unsupportedField": "Nicht unterstützter Feldtyp",
|
||||||
|
|
||||||
|
"modal": {
|
||||||
|
"addFieldTitle": "Feld hinzufügen",
|
||||||
|
"editFieldTitle": "Feld bearbeiten",
|
||||||
|
"fieldTitle": "Feldtitel",
|
||||||
|
"fieldTitleRequired": "Feldtitel ist erforderlich",
|
||||||
|
"columnTitlePlaceholder": "Spaltentitel",
|
||||||
|
"type": "Typ",
|
||||||
|
"deleteConfirmTitle": "Sind Sie sicher, dass Sie diese benutzerdefinierte Spalte löschen möchten?",
|
||||||
|
"deleteConfirmDescription": "Diese Aktion kann nicht rückgängig gemacht werden. Alle mit dieser Spalte verbundenen Daten werden dauerhaft gelöscht.",
|
||||||
|
"deleteButton": "Löschen",
|
||||||
|
"cancelButton": "Abbrechen",
|
||||||
|
"createButton": "Erstellen",
|
||||||
|
"updateButton": "Aktualisieren",
|
||||||
|
"createSuccessMessage": "Benutzerdefinierte Spalte erfolgreich erstellt",
|
||||||
|
"updateSuccessMessage": "Benutzerdefinierte Spalte erfolgreich aktualisiert",
|
||||||
|
"deleteSuccessMessage": "Benutzerdefinierte Spalte erfolgreich gelöscht",
|
||||||
|
"deleteErrorMessage": "Fehler beim Löschen der benutzerdefinierten Spalte",
|
||||||
|
"createErrorMessage": "Fehler beim Erstellen der benutzerdefinierten Spalte",
|
||||||
|
"updateErrorMessage": "Fehler beim Aktualisieren der benutzerdefinierten Spalte"
|
||||||
|
},
|
||||||
|
|
||||||
|
"fieldTypes": {
|
||||||
|
"people": "Personen",
|
||||||
|
"number": "Zahl",
|
||||||
|
"date": "Datum",
|
||||||
|
"selection": "Auswahl",
|
||||||
|
"checkbox": "Kontrollkästchen",
|
||||||
|
"labels": "Etiketten",
|
||||||
|
"key": "Schlüssel",
|
||||||
|
"formula": "Formel"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,11 @@
|
|||||||
"attachments": "Anhänge",
|
"attachments": "Anhänge",
|
||||||
"enterSubtaskName": "Unteraufgabenname eingeben...",
|
"enterSubtaskName": "Unteraufgabenname eingeben...",
|
||||||
"add": "Hinzufügen",
|
"add": "Hinzufügen",
|
||||||
"cancel": "Abbrechen"
|
"cancel": "Abbrechen",
|
||||||
|
"renameGroup": "Gruppe umbenennen",
|
||||||
|
"renameStatus": "Status umbenennen",
|
||||||
|
"renamePhase": "Phase umbenennen",
|
||||||
|
"changeCategory": "Kategorie ändern",
|
||||||
|
"clickToEditGroupName": "Klicken Sie, um den Gruppennamen zu bearbeiten",
|
||||||
|
"enterGroupName": "Gruppennamen eingeben"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,6 +39,7 @@
|
|||||||
"addTaskText": "Add Task",
|
"addTaskText": "Add Task",
|
||||||
"addSubTaskText": "Add Sub Task",
|
"addSubTaskText": "Add Sub Task",
|
||||||
"addTaskInputPlaceholder": "Type your task and hit enter",
|
"addTaskInputPlaceholder": "Type your task and hit enter",
|
||||||
|
"noTasksInGroup": "No tasks in this group",
|
||||||
|
|
||||||
"openButton": "Open",
|
"openButton": "Open",
|
||||||
"okButton": "Ok",
|
"okButton": "Ok",
|
||||||
@@ -65,5 +66,47 @@
|
|||||||
"clearDueDate": "Clear due date",
|
"clearDueDate": "Clear due date",
|
||||||
"clearStartDate": "Clear start date",
|
"clearStartDate": "Clear start date",
|
||||||
"dueDatePlaceholder": "Due Date",
|
"dueDatePlaceholder": "Due Date",
|
||||||
"startDatePlaceholder": "Start Date"
|
"startDatePlaceholder": "Start Date",
|
||||||
|
|
||||||
|
"customColumns": {
|
||||||
|
"addCustomColumn": "Add a custom column",
|
||||||
|
"customColumnHeader": "Custom Column",
|
||||||
|
"customColumnSettings": "Custom column settings",
|
||||||
|
"noCustomValue": "No value",
|
||||||
|
"peopleField": "People field",
|
||||||
|
"noDate": "No date",
|
||||||
|
"unsupportedField": "Unsupported field type",
|
||||||
|
|
||||||
|
"modal": {
|
||||||
|
"addFieldTitle": "Add field",
|
||||||
|
"editFieldTitle": "Edit field",
|
||||||
|
"fieldTitle": "Field title",
|
||||||
|
"fieldTitleRequired": "Field title is required",
|
||||||
|
"columnTitlePlaceholder": "Column title",
|
||||||
|
"type": "Type",
|
||||||
|
"deleteConfirmTitle": "Are you sure you want to delete this custom column?",
|
||||||
|
"deleteConfirmDescription": "This action cannot be undone. All data associated with this column will be permanently deleted.",
|
||||||
|
"deleteButton": "Delete",
|
||||||
|
"cancelButton": "Cancel",
|
||||||
|
"createButton": "Create",
|
||||||
|
"updateButton": "Update",
|
||||||
|
"createSuccessMessage": "Custom column created successfully",
|
||||||
|
"updateSuccessMessage": "Custom column updated successfully",
|
||||||
|
"deleteSuccessMessage": "Custom column deleted successfully",
|
||||||
|
"deleteErrorMessage": "Failed to delete custom column",
|
||||||
|
"createErrorMessage": "Failed to create custom column",
|
||||||
|
"updateErrorMessage": "Failed to update custom column"
|
||||||
|
},
|
||||||
|
|
||||||
|
"fieldTypes": {
|
||||||
|
"people": "People",
|
||||||
|
"number": "Number",
|
||||||
|
"date": "Date",
|
||||||
|
"selection": "Selection",
|
||||||
|
"checkbox": "Checkbox",
|
||||||
|
"labels": "Labels",
|
||||||
|
"key": "Key",
|
||||||
|
"formula": "Formula"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,11 @@
|
|||||||
"attachments": "attachments",
|
"attachments": "attachments",
|
||||||
"enterSubtaskName": "Enter subtask name...",
|
"enterSubtaskName": "Enter subtask name...",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"cancel": "Cancel"
|
"cancel": "Cancel",
|
||||||
|
"renameGroup": "Rename Group",
|
||||||
|
"renameStatus": "Rename Status",
|
||||||
|
"renamePhase": "Rename Phase",
|
||||||
|
"changeCategory": "Change Category",
|
||||||
|
"clickToEditGroupName": "Click to edit group name",
|
||||||
|
"enterGroupName": "Enter group name"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
|
|
||||||
"addTaskText": "Agregar tarea",
|
"addTaskText": "Agregar tarea",
|
||||||
"addSubTaskText": "Agregar subtarea",
|
"addSubTaskText": "Agregar subtarea",
|
||||||
|
"noTasksInGroup": "No hay tareas en este grupo",
|
||||||
"addTaskInputPlaceholder": "Escribe tu tarea y presiona enter",
|
"addTaskInputPlaceholder": "Escribe tu tarea y presiona enter",
|
||||||
|
|
||||||
"openButton": "Abrir",
|
"openButton": "Abrir",
|
||||||
@@ -65,5 +66,47 @@
|
|||||||
"clearDueDate": "Limpiar fecha de vencimiento",
|
"clearDueDate": "Limpiar fecha de vencimiento",
|
||||||
"clearStartDate": "Limpiar fecha de inicio",
|
"clearStartDate": "Limpiar fecha de inicio",
|
||||||
"dueDatePlaceholder": "Fecha de vencimiento",
|
"dueDatePlaceholder": "Fecha de vencimiento",
|
||||||
"startDatePlaceholder": "Fecha de inicio"
|
"startDatePlaceholder": "Fecha de inicio",
|
||||||
|
|
||||||
|
"customColumns": {
|
||||||
|
"addCustomColumn": "Agregar una columna personalizada",
|
||||||
|
"customColumnHeader": "Columna Personalizada",
|
||||||
|
"customColumnSettings": "Configuración de columna personalizada",
|
||||||
|
"noCustomValue": "Sin valor",
|
||||||
|
"peopleField": "Campo de personas",
|
||||||
|
"noDate": "Sin fecha",
|
||||||
|
"unsupportedField": "Tipo de campo no compatible",
|
||||||
|
|
||||||
|
"modal": {
|
||||||
|
"addFieldTitle": "Agregar campo",
|
||||||
|
"editFieldTitle": "Editar campo",
|
||||||
|
"fieldTitle": "Título del campo",
|
||||||
|
"fieldTitleRequired": "El título del campo es obligatorio",
|
||||||
|
"columnTitlePlaceholder": "Título de la columna",
|
||||||
|
"type": "Tipo",
|
||||||
|
"deleteConfirmTitle": "¿Está seguro de que desea eliminar esta columna personalizada?",
|
||||||
|
"deleteConfirmDescription": "Esta acción no se puede deshacer. Todos los datos asociados con esta columna se eliminarán permanentemente.",
|
||||||
|
"deleteButton": "Eliminar",
|
||||||
|
"cancelButton": "Cancelar",
|
||||||
|
"createButton": "Crear",
|
||||||
|
"updateButton": "Actualizar",
|
||||||
|
"createSuccessMessage": "Columna personalizada creada exitosamente",
|
||||||
|
"updateSuccessMessage": "Columna personalizada actualizada exitosamente",
|
||||||
|
"deleteSuccessMessage": "Columna personalizada eliminada exitosamente",
|
||||||
|
"deleteErrorMessage": "Error al eliminar la columna personalizada",
|
||||||
|
"createErrorMessage": "Error al crear la columna personalizada",
|
||||||
|
"updateErrorMessage": "Error al actualizar la columna personalizada"
|
||||||
|
},
|
||||||
|
|
||||||
|
"fieldTypes": {
|
||||||
|
"people": "Personas",
|
||||||
|
"number": "Número",
|
||||||
|
"date": "Fecha",
|
||||||
|
"selection": "Selección",
|
||||||
|
"checkbox": "Casilla de verificación",
|
||||||
|
"labels": "Etiquetas",
|
||||||
|
"key": "Clave",
|
||||||
|
"formula": "Fórmula"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,11 @@
|
|||||||
"attachments": "adjuntos",
|
"attachments": "adjuntos",
|
||||||
"enterSubtaskName": "Ingresa el nombre de la subtarea...",
|
"enterSubtaskName": "Ingresa el nombre de la subtarea...",
|
||||||
"add": "Añadir",
|
"add": "Añadir",
|
||||||
"cancel": "Cancelar"
|
"cancel": "Cancelar",
|
||||||
|
"renameGroup": "Renombrar Grupo",
|
||||||
|
"renameStatus": "Renombrar Estado",
|
||||||
|
"renamePhase": "Renombrar Fase",
|
||||||
|
"changeCategory": "Cambiar Categoría",
|
||||||
|
"clickToEditGroupName": "Haz clic para editar el nombre del grupo",
|
||||||
|
"enterGroupName": "Ingresa el nombre del grupo"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,7 @@
|
|||||||
|
|
||||||
"addTaskText": "Adicionar Tarefa",
|
"addTaskText": "Adicionar Tarefa",
|
||||||
"addSubTaskText": "+ Adicionar Subtarefa",
|
"addSubTaskText": "+ Adicionar Subtarefa",
|
||||||
|
"noTasksInGroup": "Nenhuma tarefa neste grupo",
|
||||||
"addTaskInputPlaceholder": "Digite sua tarefa e pressione enter",
|
"addTaskInputPlaceholder": "Digite sua tarefa e pressione enter",
|
||||||
|
|
||||||
"openButton": "Abrir",
|
"openButton": "Abrir",
|
||||||
@@ -65,5 +66,47 @@
|
|||||||
"clearDueDate": "Limpar data de vencimento",
|
"clearDueDate": "Limpar data de vencimento",
|
||||||
"clearStartDate": "Limpar data de início",
|
"clearStartDate": "Limpar data de início",
|
||||||
"dueDatePlaceholder": "Data de vencimento",
|
"dueDatePlaceholder": "Data de vencimento",
|
||||||
"startDatePlaceholder": "Data de início"
|
"startDatePlaceholder": "Data de início",
|
||||||
|
|
||||||
|
"customColumns": {
|
||||||
|
"addCustomColumn": "Adicionar uma coluna personalizada",
|
||||||
|
"customColumnHeader": "Coluna Personalizada",
|
||||||
|
"customColumnSettings": "Configurações da coluna personalizada",
|
||||||
|
"noCustomValue": "Sem valor",
|
||||||
|
"peopleField": "Campo de pessoas",
|
||||||
|
"noDate": "Sem data",
|
||||||
|
"unsupportedField": "Tipo de campo não suportado",
|
||||||
|
|
||||||
|
"modal": {
|
||||||
|
"addFieldTitle": "Adicionar campo",
|
||||||
|
"editFieldTitle": "Editar campo",
|
||||||
|
"fieldTitle": "Título do campo",
|
||||||
|
"fieldTitleRequired": "O título do campo é obrigatório",
|
||||||
|
"columnTitlePlaceholder": "Título da coluna",
|
||||||
|
"type": "Tipo",
|
||||||
|
"deleteConfirmTitle": "Tem certeza de que deseja excluir esta coluna personalizada?",
|
||||||
|
"deleteConfirmDescription": "Esta ação não pode ser desfeita. Todos os dados associados a esta coluna serão excluídos permanentemente.",
|
||||||
|
"deleteButton": "Excluir",
|
||||||
|
"cancelButton": "Cancelar",
|
||||||
|
"createButton": "Criar",
|
||||||
|
"updateButton": "Atualizar",
|
||||||
|
"createSuccessMessage": "Coluna personalizada criada com sucesso",
|
||||||
|
"updateSuccessMessage": "Coluna personalizada atualizada com sucesso",
|
||||||
|
"deleteSuccessMessage": "Coluna personalizada excluída com sucesso",
|
||||||
|
"deleteErrorMessage": "Falha ao excluir a coluna personalizada",
|
||||||
|
"createErrorMessage": "Falha ao criar a coluna personalizada",
|
||||||
|
"updateErrorMessage": "Falha ao atualizar a coluna personalizada"
|
||||||
|
},
|
||||||
|
|
||||||
|
"fieldTypes": {
|
||||||
|
"people": "Pessoas",
|
||||||
|
"number": "Número",
|
||||||
|
"date": "Data",
|
||||||
|
"selection": "Seleção",
|
||||||
|
"checkbox": "Caixa de seleção",
|
||||||
|
"labels": "Etiquetas",
|
||||||
|
"key": "Chave",
|
||||||
|
"formula": "Fórmula"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,5 +11,11 @@
|
|||||||
"attachments": "anexos",
|
"attachments": "anexos",
|
||||||
"enterSubtaskName": "Digite o nome da subtarefa...",
|
"enterSubtaskName": "Digite o nome da subtarefa...",
|
||||||
"add": "Adicionar",
|
"add": "Adicionar",
|
||||||
"cancel": "Cancelar"
|
"cancel": "Cancelar",
|
||||||
|
"renameGroup": "Renomear Grupo",
|
||||||
|
"renameStatus": "Renomear Status",
|
||||||
|
"renamePhase": "Renomear Fase",
|
||||||
|
"changeCategory": "Alterar Categoria",
|
||||||
|
"clickToEditGroupName": "Clique para editar o nome do grupo",
|
||||||
|
"enterGroupName": "Digite o nome do grupo"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,6 +60,20 @@ export const statusApiService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
updateStatusCategory: async (
|
||||||
|
statusId: string,
|
||||||
|
categoryId: string,
|
||||||
|
currentProjectId: string
|
||||||
|
): Promise<IServerResponse<ITaskStatus>> => {
|
||||||
|
const q = toQueryString({ current_project_id: currentProjectId });
|
||||||
|
|
||||||
|
const response = await apiClient.put<IServerResponse<ITaskStatus>>(
|
||||||
|
`${rootUrl}/category/${statusId}${q}`,
|
||||||
|
{ category_id: categoryId }
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
updateStatusOrder: async (
|
updateStatusOrder: async (
|
||||||
body: ITaskStatusCreateRequest,
|
body: ITaskStatusCreateRequest,
|
||||||
currentProjectId: string
|
currentProjectId: string
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ export interface ITaskListConfigV2 {
|
|||||||
group?: string;
|
group?: string;
|
||||||
isSubtasksInclude: boolean;
|
isSubtasksInclude: boolean;
|
||||||
include_empty?: string; // Include empty groups in response
|
include_empty?: string; // Include empty groups in response
|
||||||
|
customColumns?: boolean; // Include custom column values in response
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITaskListV3Response {
|
export interface ITaskListV3Response {
|
||||||
|
|||||||
@@ -9,12 +9,10 @@ import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
|||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
import { SocketEvents } from '@/shared/socket-events';
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
import { useAuthService } from '@/hooks/useAuth';
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
import { Avatar, Button, Checkbox } from '@/components';
|
import { Avatar, Checkbox } from '@/components';
|
||||||
import { sortTeamMembers } from '@/utils/sort-team-members';
|
import { sortTeamMembers } from '@/utils/sort-team-members';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
||||||
import { updateTask } from '@/features/task-management/task-management.slice';
|
|
||||||
import { updateEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice';
|
|
||||||
import { updateTaskAssignees } from '@/features/task-management/task-management.slice';
|
import { updateTaskAssignees } from '@/features/task-management/task-management.slice';
|
||||||
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,341 @@
|
|||||||
|
import React, { useState, useRef, useEffect, useMemo, useCallback } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { PlusOutlined, UserAddOutlined } from '@ant-design/icons';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
||||||
|
import { ITeamMembersViewModel } from '@/types/teamMembers/teamMembersViewModel.types';
|
||||||
|
import { sortTeamMembers } from '@/utils/sort-team-members';
|
||||||
|
import { Avatar, Checkbox } from '@/components';
|
||||||
|
|
||||||
|
interface PeopleDropdownProps {
|
||||||
|
selectedMemberIds: string[];
|
||||||
|
onMemberToggle: (memberId: string, checked: boolean) => void;
|
||||||
|
onInviteClick?: () => void;
|
||||||
|
isDarkMode?: boolean;
|
||||||
|
className?: string;
|
||||||
|
buttonClassName?: string;
|
||||||
|
isLoading?: boolean;
|
||||||
|
loadMembers?: () => void;
|
||||||
|
pendingChanges?: Set<string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const PeopleDropdown: React.FC<PeopleDropdownProps> = ({
|
||||||
|
selectedMemberIds,
|
||||||
|
onMemberToggle,
|
||||||
|
onInviteClick,
|
||||||
|
isDarkMode = false,
|
||||||
|
className = '',
|
||||||
|
buttonClassName = '',
|
||||||
|
isLoading = false,
|
||||||
|
loadMembers,
|
||||||
|
pendingChanges = new Set(),
|
||||||
|
}) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [teamMembers, setTeamMembers] = useState<ITeamMembersViewModel>({ data: [], total: 0 });
|
||||||
|
const [dropdownPosition, setDropdownPosition] = useState({ top: 0, left: 0 });
|
||||||
|
const [hasLoadedMembers, setHasLoadedMembers] = useState(false);
|
||||||
|
|
||||||
|
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
|
||||||
|
|
||||||
|
// Load members on demand when dropdown opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!hasLoadedMembers && loadMembers && isOpen) {
|
||||||
|
loadMembers();
|
||||||
|
setHasLoadedMembers(true);
|
||||||
|
}
|
||||||
|
}, [hasLoadedMembers, loadMembers, isOpen]);
|
||||||
|
|
||||||
|
const filteredMembers = useMemo(() => {
|
||||||
|
return teamMembers?.data?.filter(member =>
|
||||||
|
member.name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||||
|
);
|
||||||
|
}, [teamMembers, searchQuery]);
|
||||||
|
|
||||||
|
// Update dropdown position
|
||||||
|
const updateDropdownPosition = useCallback(() => {
|
||||||
|
if (buttonRef.current) {
|
||||||
|
const rect = buttonRef.current.getBoundingClientRect();
|
||||||
|
const viewportHeight = window.innerHeight;
|
||||||
|
const dropdownHeight = 280; // More accurate height: header(40) + max-height(192) + footer(40) + padding
|
||||||
|
|
||||||
|
// Check if dropdown would go below viewport
|
||||||
|
const spaceBelow = viewportHeight - rect.bottom;
|
||||||
|
const shouldShowAbove = spaceBelow < dropdownHeight && rect.top > dropdownHeight;
|
||||||
|
|
||||||
|
setDropdownPosition({
|
||||||
|
top: shouldShowAbove ? rect.top - dropdownHeight - 4 : rect.bottom + 4,
|
||||||
|
left: rect.left,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Close dropdown when clicking outside and handle scroll
|
||||||
|
useEffect(() => {
|
||||||
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
|
if (
|
||||||
|
dropdownRef.current &&
|
||||||
|
!dropdownRef.current.contains(event.target as Node) &&
|
||||||
|
buttonRef.current &&
|
||||||
|
!buttonRef.current.contains(event.target as Node)
|
||||||
|
) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleScroll = (event: Event) => {
|
||||||
|
if (isOpen) {
|
||||||
|
// Only close dropdown if scrolling happens outside the dropdown
|
||||||
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResize = () => {
|
||||||
|
if (isOpen) {
|
||||||
|
updateDropdownPosition();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isOpen) {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
window.addEventListener('scroll', handleScroll, true);
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
window.removeEventListener('scroll', handleScroll, true);
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}
|
||||||
|
}, [isOpen, updateDropdownPosition]);
|
||||||
|
|
||||||
|
const handleDropdownToggle = (e: React.MouseEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
if (!isOpen) {
|
||||||
|
updateDropdownPosition();
|
||||||
|
|
||||||
|
// Prepare team members data when opening
|
||||||
|
const membersData = (members?.data || []).map(member => ({
|
||||||
|
...member,
|
||||||
|
selected: selectedMemberIds.includes(member.id || ''),
|
||||||
|
}));
|
||||||
|
const sortedMembers = sortTeamMembers(membersData);
|
||||||
|
setTeamMembers({ data: sortedMembers });
|
||||||
|
|
||||||
|
setIsOpen(true);
|
||||||
|
// Focus search input after opening
|
||||||
|
setTimeout(() => {
|
||||||
|
searchInputRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
} else {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMemberToggle = (memberId: string, checked: boolean) => {
|
||||||
|
if (!memberId) return;
|
||||||
|
onMemberToggle(memberId, checked);
|
||||||
|
|
||||||
|
// Update local team members state for dropdown UI
|
||||||
|
setTeamMembers(prev => ({
|
||||||
|
...prev,
|
||||||
|
data: (prev.data || []).map(member =>
|
||||||
|
member.id === memberId ? { ...member, selected: checked } : member
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const checkMemberSelected = (memberId: string) => {
|
||||||
|
if (!memberId) return false;
|
||||||
|
return selectedMemberIds.includes(memberId);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleInviteProjectMemberDrawer = () => {
|
||||||
|
setIsOpen(false); // Close the dropdown first
|
||||||
|
if (onInviteClick) {
|
||||||
|
onInviteClick();
|
||||||
|
} else {
|
||||||
|
dispatch(toggleProjectMemberDrawer()); // Then open the invite drawer
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
onClick={handleDropdownToggle}
|
||||||
|
className={`
|
||||||
|
w-5 h-5 rounded-full border border-dashed flex items-center justify-center
|
||||||
|
transition-colors duration-200
|
||||||
|
${buttonClassName}
|
||||||
|
${
|
||||||
|
isOpen
|
||||||
|
? isDarkMode
|
||||||
|
? 'border-blue-500 bg-blue-900/20 text-blue-400'
|
||||||
|
: 'border-blue-500 bg-blue-50 text-blue-600'
|
||||||
|
: isDarkMode
|
||||||
|
? 'border-gray-600 hover:border-gray-500 hover:bg-gray-800 text-gray-400'
|
||||||
|
: 'border-gray-300 hover:border-gray-400 hover:bg-gray-100 text-gray-600'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-xs" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen &&
|
||||||
|
createPortal(
|
||||||
|
<div
|
||||||
|
ref={dropdownRef}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className={`
|
||||||
|
fixed w-72 rounded-md shadow-lg border people-dropdown-portal ${className}
|
||||||
|
${isDarkMode ? 'bg-gray-800 border-gray-600' : 'bg-white border-gray-200'}
|
||||||
|
`}
|
||||||
|
style={{
|
||||||
|
top: dropdownPosition.top,
|
||||||
|
left: dropdownPosition.left,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`p-2 border-b ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`}>
|
||||||
|
<input
|
||||||
|
ref={searchInputRef}
|
||||||
|
type="text"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
placeholder="Search members..."
|
||||||
|
className={`
|
||||||
|
w-full px-2 py-1 text-xs rounded border
|
||||||
|
${
|
||||||
|
isDarkMode
|
||||||
|
? 'bg-gray-700 border-gray-600 text-gray-100 placeholder-gray-400 focus:border-blue-500'
|
||||||
|
: 'bg-white border-gray-300 text-gray-900 placeholder-gray-500 focus:border-blue-500'
|
||||||
|
}
|
||||||
|
focus:outline-none focus:ring-1 focus:ring-blue-500
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Members List */}
|
||||||
|
<div className="max-h-48 overflow-y-auto">
|
||||||
|
{filteredMembers && filteredMembers.length > 0 ? (
|
||||||
|
filteredMembers.map(member => (
|
||||||
|
<div
|
||||||
|
key={member.id}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-2 p-2 cursor-pointer transition-colors
|
||||||
|
${
|
||||||
|
member.pending_invitation
|
||||||
|
? 'opacity-50 cursor-not-allowed'
|
||||||
|
: isDarkMode
|
||||||
|
? 'hover:bg-gray-700'
|
||||||
|
: 'hover:bg-gray-50'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
onClick={() => {
|
||||||
|
if (!member.pending_invitation) {
|
||||||
|
const isSelected = checkMemberSelected(member.id || '');
|
||||||
|
handleMemberToggle(member.id || '', !isSelected);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
// Add visual feedback for immediate response
|
||||||
|
transition: 'all 0.15s ease-in-out',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<span onClick={e => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={checkMemberSelected(member.id || '')}
|
||||||
|
onChange={checked => handleMemberToggle(member.id || '', checked)}
|
||||||
|
disabled={
|
||||||
|
member.pending_invitation || pendingChanges.has(member.id || '')
|
||||||
|
}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
{pendingChanges.has(member.id || '') && (
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 flex items-center justify-center ${
|
||||||
|
isDarkMode ? 'bg-gray-800/50' : 'bg-white/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={`w-3 h-3 border border-t-transparent rounded-full animate-spin ${
|
||||||
|
isDarkMode ? 'border-blue-400' : 'border-blue-600'
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Avatar
|
||||||
|
src={member.avatar_url}
|
||||||
|
name={member.name || ''}
|
||||||
|
size={24}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div
|
||||||
|
className={`text-xs font-medium truncate ${isDarkMode ? 'text-gray-100' : 'text-gray-900'}`}
|
||||||
|
>
|
||||||
|
{member.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`text-xs truncate ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
{member.email}
|
||||||
|
{member.pending_invitation && (
|
||||||
|
<span className="text-red-400 ml-1">(Pending)</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={`p-4 text-center ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}
|
||||||
|
>
|
||||||
|
<div className="text-xs">
|
||||||
|
{isLoading ? 'Loading members...' : 'No members found'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className={`p-2 border-t ${isDarkMode ? 'border-gray-600' : 'border-gray-200'}`}>
|
||||||
|
<button
|
||||||
|
className={`
|
||||||
|
w-full flex items-center justify-center gap-1 px-2 py-1 text-xs rounded
|
||||||
|
transition-colors
|
||||||
|
${isDarkMode ? 'text-blue-400 hover:bg-gray-700' : 'text-blue-600 hover:bg-blue-50'}
|
||||||
|
`}
|
||||||
|
onClick={handleInviteProjectMemberDrawer}
|
||||||
|
>
|
||||||
|
<UserAddOutlined />
|
||||||
|
Invite member
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PeopleDropdown;
|
||||||
@@ -5,9 +5,11 @@ import { useAppDispatch } from '@/hooks/useAppDispatch';
|
|||||||
import { toggleDrawer } from '../../../features/projects/status/StatusSlice';
|
import { toggleDrawer } from '../../../features/projects/status/StatusSlice';
|
||||||
import { colors } from '@/styles/colors';
|
import { colors } from '@/styles/colors';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
|
||||||
const CreateStatusButton = () => {
|
const CreateStatusButton = () => {
|
||||||
const { t } = useTranslation('task-list-filters');
|
const { t } = useTranslation('task-list-filters');
|
||||||
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
|
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
@@ -19,9 +21,7 @@ const CreateStatusButton = () => {
|
|||||||
onClick={() => dispatch(toggleDrawer())}
|
onClick={() => dispatch(toggleDrawer())}
|
||||||
icon={
|
icon={
|
||||||
<SettingOutlined
|
<SettingOutlined
|
||||||
style={{
|
style={{ color: themeMode === 'dark' ? colors.white : colors.midBlue }}
|
||||||
color: colors.skyBlue,
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -19,16 +19,16 @@ const SubtaskLoadingSkeleton: React.FC<SubtaskLoadingSkeletonProps> = ({ visible
|
|||||||
return <div style={baseStyle} />;
|
return <div style={baseStyle} />;
|
||||||
case 'taskKey':
|
case 'taskKey':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle} className="flex items-center">
|
<div style={baseStyle} className="flex items-center pl-3">
|
||||||
<div className="h-4 w-12 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
<div className="h-5 w-16 bg-gray-200 dark:bg-gray-700 rounded-md animate-pulse border border-gray-300 dark:border-gray-600" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
case 'title':
|
case 'title':
|
||||||
return (
|
return (
|
||||||
<div style={baseStyle} className="flex items-center">
|
<div style={baseStyle} className="flex items-center">
|
||||||
{/* Subtask indentation */}
|
{/* Subtask indentation - tighter spacing */}
|
||||||
<div className="w-8" />
|
<div className="w-4" />
|
||||||
<div className="w-8" />
|
<div className="w-2" />
|
||||||
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
<div className="h-4 w-32 bg-gray-200 dark:bg-gray-700 rounded animate-pulse" />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,25 @@
|
|||||||
import React, { useMemo, useCallback } from 'react';
|
import React, { useMemo, useCallback, useState } from 'react';
|
||||||
import { useDroppable } from '@dnd-kit/core';
|
import { useDroppable } from '@dnd-kit/core';
|
||||||
// @ts-ignore: Heroicons module types
|
// @ts-ignore: Heroicons module types
|
||||||
import { ChevronDownIcon, ChevronRightIcon } from '@heroicons/react/24/outline';
|
import { ChevronDownIcon, ChevronRightIcon, EllipsisHorizontalIcon, PencilIcon, ArrowPathIcon } from '@heroicons/react/24/outline';
|
||||||
import { Checkbox } from 'antd';
|
import { Checkbox, Dropdown, Menu, Input, Modal, Badge, Flex } from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getContrastColor } from '@/utils/colorUtils';
|
import { getContrastColor } from '@/utils/colorUtils';
|
||||||
import { useAppSelector } from '@/hooks/useAppSelector';
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import { selectSelectedTaskIds, selectTask, deselectTask } from '@/features/task-management/selection.slice';
|
import { selectSelectedTaskIds, selectTask, deselectTask } from '@/features/task-management/selection.slice';
|
||||||
import { selectGroups } from '@/features/task-management/task-management.slice';
|
import { selectGroups, fetchTasksV3 } from '@/features/task-management/task-management.slice';
|
||||||
|
import { selectCurrentGrouping } from '@/features/task-management/grouping.slice';
|
||||||
|
import { statusApiService } from '@/api/taskAttributes/status/status.api.service';
|
||||||
|
import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service';
|
||||||
|
import { fetchStatuses } from '@/features/taskAttributes/taskStatusSlice';
|
||||||
|
import { fetchPhasesByProjectId } from '@/features/projects/singleProject/phase/phases.slice';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
import { ITaskStatusUpdateModel } from '@/types/tasks/task-status-update-model.types';
|
||||||
|
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
||||||
|
import logger from '@/utils/errorLogger';
|
||||||
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
|
import { evt_project_board_column_setting_click } from '@/shared/worklenz-analytics-events';
|
||||||
|
|
||||||
interface TaskGroupHeaderProps {
|
interface TaskGroupHeaderProps {
|
||||||
group: {
|
group: {
|
||||||
@@ -18,12 +30,25 @@ interface TaskGroupHeaderProps {
|
|||||||
};
|
};
|
||||||
isCollapsed: boolean;
|
isCollapsed: boolean;
|
||||||
onToggle: () => void;
|
onToggle: () => void;
|
||||||
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, onToggle }) => {
|
const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, onToggle, projectId }) => {
|
||||||
|
const { t } = useTranslation('task-management');
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const selectedTaskIds = useAppSelector(selectSelectedTaskIds);
|
const selectedTaskIds = useAppSelector(selectSelectedTaskIds);
|
||||||
const groups = useAppSelector(selectGroups);
|
const groups = useAppSelector(selectGroups);
|
||||||
|
const currentGrouping = useAppSelector(selectCurrentGrouping);
|
||||||
|
const { statusCategories } = useAppSelector(state => state.taskStatusReducer);
|
||||||
|
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||||
|
const { isOwnerOrAdmin } = useAuthService();
|
||||||
|
|
||||||
|
const [dropdownVisible, setDropdownVisible] = useState(false);
|
||||||
|
const [categoryModalVisible, setCategoryModalVisible] = useState(false);
|
||||||
|
const [isRenaming, setIsRenaming] = useState(false);
|
||||||
|
const [isChangingCategory, setIsChangingCategory] = useState(false);
|
||||||
|
const [isEditingName, setIsEditingName] = useState(false);
|
||||||
|
const [editingName, setEditingName] = useState(group.name);
|
||||||
|
|
||||||
const headerBackgroundColor = group.color || '#F0F0F0'; // Default light gray if no color
|
const headerBackgroundColor = group.color || '#F0F0F0'; // Default light gray if no color
|
||||||
const headerTextColor = getContrastColor(headerBackgroundColor);
|
const headerTextColor = getContrastColor(headerBackgroundColor);
|
||||||
@@ -67,6 +92,139 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
|||||||
}
|
}
|
||||||
}, [dispatch, isAllSelected, tasksInGroup]);
|
}, [dispatch, isAllSelected, tasksInGroup]);
|
||||||
|
|
||||||
|
// Handle inline name editing
|
||||||
|
const handleNameSave = useCallback(async () => {
|
||||||
|
if (!editingName.trim() || editingName.trim() === group.name || isRenaming) return;
|
||||||
|
|
||||||
|
setIsRenaming(true);
|
||||||
|
try {
|
||||||
|
if (currentGrouping === 'status') {
|
||||||
|
// Extract status ID from group ID (format: "status-{statusId}")
|
||||||
|
const statusId = group.id.replace('status-', '');
|
||||||
|
const body: ITaskStatusUpdateModel = {
|
||||||
|
name: editingName.trim(),
|
||||||
|
project_id: projectId,
|
||||||
|
};
|
||||||
|
|
||||||
|
await statusApiService.updateNameOfStatus(statusId, body, projectId);
|
||||||
|
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Status' });
|
||||||
|
dispatch(fetchStatuses(projectId));
|
||||||
|
|
||||||
|
} else if (currentGrouping === 'phase') {
|
||||||
|
// Extract phase ID from group ID (format: "phase-{phaseId}")
|
||||||
|
const phaseId = group.id.replace('phase-', '');
|
||||||
|
const body = { id: phaseId, name: editingName.trim() };
|
||||||
|
|
||||||
|
await phasesApiService.updateNameOfPhase(phaseId, body as ITaskPhase, projectId);
|
||||||
|
trackMixpanelEvent(evt_project_board_column_setting_click, { Rename: 'Phase' });
|
||||||
|
dispatch(fetchPhasesByProjectId(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh task list to get updated group names
|
||||||
|
dispatch(fetchTasksV3(projectId));
|
||||||
|
setIsEditingName(false);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error renaming group:', error);
|
||||||
|
setEditingName(group.name);
|
||||||
|
} finally {
|
||||||
|
setIsRenaming(false);
|
||||||
|
}
|
||||||
|
}, [editingName, group.name, group.id, currentGrouping, projectId, dispatch, trackMixpanelEvent, isRenaming]);
|
||||||
|
|
||||||
|
const handleNameClick = useCallback((e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!isOwnerOrAdmin) return;
|
||||||
|
setIsEditingName(true);
|
||||||
|
setEditingName(group.name);
|
||||||
|
}, [group.name, isOwnerOrAdmin]);
|
||||||
|
|
||||||
|
const handleNameKeyDown = useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleNameSave();
|
||||||
|
} else if (e.key === 'Escape') {
|
||||||
|
setIsEditingName(false);
|
||||||
|
setEditingName(group.name);
|
||||||
|
}
|
||||||
|
e.stopPropagation();
|
||||||
|
}, [group.name, handleNameSave]);
|
||||||
|
|
||||||
|
const handleNameBlur = useCallback(() => {
|
||||||
|
setIsEditingName(false);
|
||||||
|
setEditingName(group.name);
|
||||||
|
}, [group.name]);
|
||||||
|
|
||||||
|
// Handle dropdown menu actions
|
||||||
|
const handleRenameGroup = useCallback(() => {
|
||||||
|
setDropdownVisible(false);
|
||||||
|
setIsEditingName(true);
|
||||||
|
setEditingName(group.name);
|
||||||
|
}, [group.name]);
|
||||||
|
|
||||||
|
const handleChangeCategory = useCallback(() => {
|
||||||
|
setDropdownVisible(false);
|
||||||
|
setCategoryModalVisible(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// Handle category change
|
||||||
|
const handleCategoryChange = useCallback(async (categoryId: string, e?: React.MouseEvent) => {
|
||||||
|
e?.stopPropagation();
|
||||||
|
if (isChangingCategory) return;
|
||||||
|
|
||||||
|
setIsChangingCategory(true);
|
||||||
|
try {
|
||||||
|
// Extract status ID from group ID (format: "status-{statusId}")
|
||||||
|
const statusId = group.id.replace('status-', '');
|
||||||
|
|
||||||
|
await statusApiService.updateStatusCategory(statusId, categoryId, projectId);
|
||||||
|
trackMixpanelEvent(evt_project_board_column_setting_click, { 'Change category': 'Status' });
|
||||||
|
|
||||||
|
// Refresh status list and tasks
|
||||||
|
dispatch(fetchStatuses(projectId));
|
||||||
|
dispatch(fetchTasksV3(projectId));
|
||||||
|
setCategoryModalVisible(false);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error changing category:', error);
|
||||||
|
} finally {
|
||||||
|
setIsChangingCategory(false);
|
||||||
|
}
|
||||||
|
}, [group.id, projectId, dispatch, trackMixpanelEvent, isChangingCategory]);
|
||||||
|
|
||||||
|
// Create dropdown menu items
|
||||||
|
const menuItems = useMemo(() => {
|
||||||
|
if (!isOwnerOrAdmin) return [];
|
||||||
|
|
||||||
|
const items = [
|
||||||
|
{
|
||||||
|
key: 'rename',
|
||||||
|
icon: <PencilIcon className="h-4 w-4" />,
|
||||||
|
label: currentGrouping === 'status' ? t('renameStatus') : currentGrouping === 'phase' ? t('renamePhase') : t('renameGroup'),
|
||||||
|
onClick: (e: any) => {
|
||||||
|
e?.domEvent?.stopPropagation();
|
||||||
|
handleRenameGroup();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Only show "Change Category" when grouped by status
|
||||||
|
if (currentGrouping === 'status') {
|
||||||
|
items.push({
|
||||||
|
key: 'changeCategory',
|
||||||
|
icon: <ArrowPathIcon className="h-4 w-4" />,
|
||||||
|
label: t('changeCategory'),
|
||||||
|
onClick: (e: any) => {
|
||||||
|
e?.domEvent?.stopPropagation();
|
||||||
|
handleChangeCategory();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}, [currentGrouping, handleRenameGroup, handleChangeCategory, isOwnerOrAdmin]);
|
||||||
|
|
||||||
// Make the group header droppable
|
// Make the group header droppable
|
||||||
const { isOver, setNodeRef } = useDroppable({
|
const { isOver, setNodeRef } = useDroppable({
|
||||||
id: group.id,
|
id: group.id,
|
||||||
@@ -79,7 +237,7 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
className={`inline-flex w-max items-center px-4 py-2 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-b border-gray-200 dark:border-gray-700 rounded-t-md ${
|
className={`inline-flex w-max items-center px-1 cursor-pointer hover:opacity-80 transition-opacity duration-200 ease-in-out border-b border-gray-200 dark:border-gray-700 rounded-t-md ${
|
||||||
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
isOver ? 'ring-2 ring-blue-400 ring-opacity-50' : ''
|
||||||
}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
@@ -87,31 +245,38 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
|||||||
color: headerTextColor,
|
color: headerTextColor,
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
top: 0,
|
top: 0,
|
||||||
zIndex: 20 // Higher than sticky columns (zIndex: 1) and column headers (zIndex: 2)
|
zIndex: 25, // Higher than task rows but lower than column headers (z-30)
|
||||||
|
height: '36px',
|
||||||
|
minHeight: '36px',
|
||||||
|
maxHeight: '36px'
|
||||||
}}
|
}}
|
||||||
onClick={onToggle}
|
onClick={onToggle}
|
||||||
>
|
>
|
||||||
{/* Drag Handle Space */}
|
{/* Drag Handle Space - ultra minimal width */}
|
||||||
<div style={{ width: '32px' }} className="flex items-center justify-center">
|
<div style={{ width: '20px' }} className="flex items-center justify-center">
|
||||||
{/* Chevron button */}
|
{/* Chevron button */}
|
||||||
<button
|
<button
|
||||||
className="p-1 rounded-md hover:bg-opacity-20 transition-colors"
|
className="p-0 rounded-sm hover:shadow-lg hover:scale-105 transition-all duration-300 ease-out"
|
||||||
style={{ backgroundColor: headerBackgroundColor, color: headerTextColor, borderColor: headerTextColor, border: '1px solid' }}
|
style={{ backgroundColor: 'transparent', color: headerTextColor }}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onToggle();
|
onToggle();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isCollapsed ? (
|
<div
|
||||||
<ChevronRightIcon className="h-4 w-4" style={{ color: headerTextColor }} />
|
className="transition-transform duration-300 ease-out"
|
||||||
) : (
|
style={{
|
||||||
<ChevronDownIcon className="h-4 w-4" style={{ color: headerTextColor }} />
|
transform: isCollapsed ? 'rotate(0deg)' : 'rotate(90deg)',
|
||||||
)}
|
transformOrigin: 'center'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronRightIcon className="h-3 w-3" style={{ color: headerTextColor }} />
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Select All Checkbox Space */}
|
{/* Select All Checkbox Space - ultra minimal width */}
|
||||||
<div style={{ width: '40px' }} className="flex items-center justify-center">
|
<div style={{ width: '28px' }} className="flex items-center justify-center">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isAllSelected}
|
checked={isAllSelected}
|
||||||
indeterminate={isPartiallySelected}
|
indeterminate={isPartiallySelected}
|
||||||
@@ -123,17 +288,111 @@ const TaskGroupHeader: React.FC<TaskGroupHeaderProps> = ({ group, isCollapsed, o
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Group indicator and name */}
|
{/* Group indicator and name - no gap at all */}
|
||||||
<div className="ml-2 flex items-center gap-3 flex-1">
|
<div className="flex items-center flex-1 ml-1">
|
||||||
{/* Color indicator (removed as full header is colored) */}
|
|
||||||
|
|
||||||
{/* Group name and count */}
|
{/* Group name and count */}
|
||||||
<div className="flex items-center flex-1">
|
<div className="flex items-center">
|
||||||
<span className="text-sm font-medium">
|
{isEditingName && isOwnerOrAdmin ? (
|
||||||
{group.name} ({group.count})
|
<Input
|
||||||
|
value={editingName}
|
||||||
|
onChange={(e) => setEditingName(e.target.value)}
|
||||||
|
onKeyDown={handleNameKeyDown}
|
||||||
|
onBlur={handleNameBlur}
|
||||||
|
className="text-sm font-semibold px-2 py-1 rounded-md transition-all duration-200 focus:ring-2 focus:ring-blue-500 focus:ring-opacity-50"
|
||||||
|
style={{
|
||||||
|
color: headerTextColor,
|
||||||
|
fontSize: '14px',
|
||||||
|
fontWeight: 600,
|
||||||
|
width: `${Math.max(editingName.length * 8 + 16, 80)}px`,
|
||||||
|
minWidth: '80px',
|
||||||
|
backgroundColor: 'rgba(255, 255, 255, 0.1)',
|
||||||
|
border: `1px solid ${headerTextColor}40`,
|
||||||
|
backdropFilter: 'blur(4px)'
|
||||||
|
}}
|
||||||
|
styles={{
|
||||||
|
input: {
|
||||||
|
color: headerTextColor,
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
outline: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
padding: '0'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
disabled={isRenaming}
|
||||||
|
placeholder={t('enterGroupName')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className={`text-sm font-semibold ${isOwnerOrAdmin ? 'cursor-pointer hover:bg-black hover:bg-opacity-10 dark:hover:bg-white dark:hover:bg-opacity-10 rounded px-2 py-1 transition-all duration-200 hover:shadow-sm' : ''}`}
|
||||||
|
onClick={handleNameClick}
|
||||||
|
style={{ color: headerTextColor }}
|
||||||
|
title={isOwnerOrAdmin ? t('clickToEditGroupName') : ''}
|
||||||
|
>
|
||||||
|
{group.name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-sm font-semibold ml-1" style={{ color: headerTextColor }}>
|
||||||
|
({group.count})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Three dots menu */}
|
||||||
|
<div className="flex items-center justify-center ml-2">
|
||||||
|
<Dropdown
|
||||||
|
menu={{ items: menuItems }}
|
||||||
|
trigger={['click']}
|
||||||
|
open={dropdownVisible}
|
||||||
|
onOpenChange={setDropdownVisible}
|
||||||
|
placement="bottomLeft"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
className="p-1 rounded-sm hover:bg-black hover:bg-opacity-10 transition-all duration-200 ease-out"
|
||||||
|
style={{ color: headerTextColor }}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setDropdownVisible(!dropdownVisible);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EllipsisHorizontalIcon className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Change Category Modal */}
|
||||||
|
<Modal
|
||||||
|
title="Change Category"
|
||||||
|
open={categoryModalVisible}
|
||||||
|
onCancel={() => setCategoryModalVisible(false)}
|
||||||
|
footer={null}
|
||||||
|
width={400}
|
||||||
|
>
|
||||||
|
<div className="py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{statusCategories?.map((category) => (
|
||||||
|
<div
|
||||||
|
key={category.id}
|
||||||
|
className="flex items-center justify-between p-3 border rounded-lg cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
onClick={(e) => category.id && handleCategoryChange(category.id, e)}
|
||||||
|
>
|
||||||
|
<Flex align="center" gap={12}>
|
||||||
|
<Badge color={category.color_code} />
|
||||||
|
<span className="font-medium">{category.name}</span>
|
||||||
|
</Flex>
|
||||||
|
{isChangingCategory && (
|
||||||
|
<div className="text-blue-500">
|
||||||
|
<ArrowPathIcon className="h-4 w-4 animate-spin" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,8 @@
|
|||||||
import React, { useState, useCallback, useMemo, useEffect } from 'react';
|
import React, { useCallback, useMemo, useEffect } from 'react';
|
||||||
import { GroupedVirtuoso } from 'react-virtuoso';
|
import { GroupedVirtuoso } from 'react-virtuoso';
|
||||||
import {
|
import {
|
||||||
DndContext,
|
DndContext,
|
||||||
DragEndEvent,
|
|
||||||
DragOverEvent,
|
|
||||||
DragOverlay,
|
DragOverlay,
|
||||||
DragStartEvent,
|
|
||||||
PointerSensor,
|
PointerSensor,
|
||||||
useSensor,
|
useSensor,
|
||||||
useSensors,
|
useSensors,
|
||||||
@@ -18,6 +15,13 @@ import {
|
|||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
sortableKeyboardCoordinates,
|
sortableKeyboardCoordinates,
|
||||||
} from '@dnd-kit/sortable';
|
} 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 { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
import {
|
import {
|
||||||
@@ -26,11 +30,12 @@ import {
|
|||||||
selectGrouping,
|
selectGrouping,
|
||||||
selectLoading,
|
selectLoading,
|
||||||
selectError,
|
selectError,
|
||||||
selectSelectedPriorities,
|
|
||||||
selectSearch,
|
|
||||||
fetchTasksV3,
|
fetchTasksV3,
|
||||||
reorderTasksInGroup,
|
fetchTaskListColumns,
|
||||||
moveTaskBetweenGroups,
|
selectColumns,
|
||||||
|
selectCustomColumns,
|
||||||
|
selectLoadingColumns,
|
||||||
|
updateColumnVisibility,
|
||||||
} from '@/features/task-management/task-management.slice';
|
} from '@/features/task-management/task-management.slice';
|
||||||
import {
|
import {
|
||||||
selectCurrentGrouping,
|
selectCurrentGrouping,
|
||||||
@@ -40,63 +45,60 @@ import {
|
|||||||
import {
|
import {
|
||||||
selectSelectedTaskIds,
|
selectSelectedTaskIds,
|
||||||
selectLastSelectedTaskId,
|
selectLastSelectedTaskId,
|
||||||
selectIsTaskSelected,
|
|
||||||
selectTask,
|
selectTask,
|
||||||
deselectTask,
|
|
||||||
toggleTaskSelection,
|
toggleTaskSelection,
|
||||||
selectRange,
|
selectRange,
|
||||||
clearSelection,
|
clearSelection,
|
||||||
} from '@/features/task-management/selection.slice';
|
} 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 TaskRowWithSubtasks from './TaskRowWithSubtasks';
|
||||||
import TaskGroupHeader from './TaskGroupHeader';
|
import TaskGroupHeader from './TaskGroupHeader';
|
||||||
import { Task, TaskGroup } from '@/types/task-management.types';
|
|
||||||
import { RootState } from '@/app/store';
|
|
||||||
import { TaskListField } from '@/types/task-list-field.types';
|
|
||||||
import { useParams } from 'react-router-dom';
|
|
||||||
import ImprovedTaskFilters from '@/components/task-management/improved-task-filters';
|
import ImprovedTaskFilters from '@/components/task-management/improved-task-filters';
|
||||||
import OptimizedBulkActionBar from '@/components/task-management/optimized-bulk-action-bar';
|
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 { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers';
|
||||||
import { HolderOutlined } from '@ant-design/icons';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
import { useDragAndDrop } from './hooks/useDragAndDrop';
|
||||||
|
import { useBulkActions } from './hooks/useBulkActions';
|
||||||
|
|
||||||
// Base column configuration
|
// Constants and types
|
||||||
const BASE_COLUMNS = [
|
import { BASE_COLUMNS, ColumnStyle } from './constants/columns';
|
||||||
{ id: 'dragHandle', label: '', width: '32px', isSticky: true, key: 'dragHandle' },
|
import { Task } from '@/types/task-management.types';
|
||||||
{ id: 'checkbox', label: '', width: '40px', isSticky: true, key: 'checkbox' },
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
{ id: 'taskKey', label: 'Key', width: '100px', key: COLUMN_KEYS.KEY },
|
|
||||||
{ id: 'title', label: 'Title', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME },
|
|
||||||
{ id: 'status', label: 'Status', width: '120px', key: COLUMN_KEYS.STATUS },
|
|
||||||
{ id: 'assignees', label: 'Assignees', width: '150px', key: COLUMN_KEYS.ASSIGNEES },
|
|
||||||
{ id: 'priority', label: 'Priority', width: '120px', key: COLUMN_KEYS.PRIORITY },
|
|
||||||
{ id: 'dueDate', label: 'Due Date', width: '120px', key: COLUMN_KEYS.DUE_DATE },
|
|
||||||
{ id: 'progress', label: 'Progress', width: '120px', key: COLUMN_KEYS.PROGRESS },
|
|
||||||
{ id: 'labels', label: 'Labels', width: 'auto', key: COLUMN_KEYS.LABELS },
|
|
||||||
{ id: 'phase', label: 'Phase', width: '120px', key: COLUMN_KEYS.PHASE },
|
|
||||||
{ id: 'timeTracking', label: 'Time Tracking', width: '120px', key: COLUMN_KEYS.TIME_TRACKING },
|
|
||||||
{ id: 'estimation', label: 'Estimation', width: '120px', key: COLUMN_KEYS.ESTIMATION },
|
|
||||||
{ id: 'startDate', label: 'Start Date', width: '120px', key: COLUMN_KEYS.START_DATE },
|
|
||||||
{ id: 'dueTime', label: 'Due Time', width: '120px', key: COLUMN_KEYS.DUE_TIME },
|
|
||||||
{ id: 'completedDate', label: 'Completed Date', width: '120px', key: COLUMN_KEYS.COMPLETED_DATE },
|
|
||||||
{ id: 'createdDate', label: 'Created Date', width: '120px', key: COLUMN_KEYS.CREATED_DATE },
|
|
||||||
{ id: 'lastUpdated', label: 'Last Updated', width: '120px', key: COLUMN_KEYS.LAST_UPDATED },
|
|
||||||
{ id: 'reporter', label: 'Reporter', width: '120px', key: COLUMN_KEYS.REPORTER },
|
|
||||||
];
|
|
||||||
|
|
||||||
type ColumnStyle = {
|
|
||||||
width: string;
|
|
||||||
position?: 'static' | 'relative' | 'absolute' | 'sticky' | 'fixed';
|
|
||||||
left?: number;
|
|
||||||
backgroundColor?: string;
|
|
||||||
zIndex?: number;
|
|
||||||
flexShrink?: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
const TaskListV2: React.FC = () => {
|
const TaskListV2: React.FC = () => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const { projectId: urlProjectId } = useParams();
|
const { projectId: urlProjectId } = useParams();
|
||||||
|
const { t } = useTranslation('task-list-table');
|
||||||
|
const { socket, connected } = useSocket();
|
||||||
|
|
||||||
// Drag and drop state
|
// Redux state selectors
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
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);
|
||||||
|
|
||||||
// Configure sensors for drag and drop
|
// Configure sensors for drag and drop
|
||||||
const sensors = useSensors(
|
const sensors = useSensors(
|
||||||
@@ -116,51 +118,110 @@ const TaskListV2: React.FC = () => {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
// Using Redux state for collapsedGroups instead of local state
|
// Custom hooks
|
||||||
const collapsedGroups = useAppSelector(selectCollapsedGroups);
|
const { activeId, handleDragStart, handleDragOver, handleDragEnd } = useDragAndDrop(allTasks, groups);
|
||||||
|
const bulkActions = useBulkActions();
|
||||||
// Selectors
|
|
||||||
const allTasks = useAppSelector(selectAllTasksArray); // Renamed to allTasks for clarity
|
|
||||||
const groups = useAppSelector(selectGroups);
|
|
||||||
const grouping = useAppSelector(selectGrouping);
|
|
||||||
const loading = useAppSelector(selectLoading);
|
|
||||||
const error = useAppSelector(selectError);
|
|
||||||
const selectedPriorities = useAppSelector(selectSelectedPriorities);
|
|
||||||
const searchQuery = useAppSelector(selectSearch);
|
|
||||||
const currentGrouping = useAppSelector(selectCurrentGrouping);
|
|
||||||
const selectedTaskIds = useAppSelector(selectSelectedTaskIds);
|
|
||||||
const lastSelectedTaskId = useAppSelector(selectLastSelectedTaskId);
|
|
||||||
|
|
||||||
const fields = useAppSelector(state => state.taskManagementFields) || [];
|
|
||||||
|
|
||||||
// Enable real-time updates via socket handlers
|
// Enable real-time updates via socket handlers
|
||||||
useTaskSocketHandlers();
|
useTaskSocketHandlers();
|
||||||
|
|
||||||
// Filter visible columns based on fields
|
// Filter visible columns based on local fields (primary) and backend columns (fallback)
|
||||||
const visibleColumns = useMemo(() => {
|
const visibleColumns = useMemo(() => {
|
||||||
return BASE_COLUMNS.filter(column => {
|
// Start with base columns
|
||||||
|
const baseVisibleColumns = BASE_COLUMNS.filter(column => {
|
||||||
// Always show drag handle and title (sticky columns)
|
// Always show drag handle and title (sticky columns)
|
||||||
if (column.isSticky) return true;
|
if (column.isSticky) return true;
|
||||||
// Check if field is visible for all other columns (including task key)
|
|
||||||
|
// Primary: Check local fields configuration
|
||||||
const field = fields.find(f => f.key === column.key);
|
const field = fields.find(f => f.key === column.key);
|
||||||
return field?.visible ?? false;
|
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;
|
||||||
});
|
});
|
||||||
}, [fields]);
|
|
||||||
|
// 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 = 180; // Extra width for selection dropdowns
|
||||||
|
} else if (fieldType === 'people') {
|
||||||
|
defaultWidth = 170; // Extra width for people with avatars
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: column.key || column.id || 'unknown',
|
||||||
|
label: column.name || t('customColumns.customColumnHeader'),
|
||||||
|
width: `${(column as any).width || defaultWidth}px`,
|
||||||
|
key: column.key || column.id || 'unknown',
|
||||||
|
custom_column: true,
|
||||||
|
custom_column_obj: column.custom_column_obj || (column as any).configuration,
|
||||||
|
isCustom: true,
|
||||||
|
name: column.name,
|
||||||
|
uuid: column.id,
|
||||||
|
};
|
||||||
|
}) || [];
|
||||||
|
|
||||||
|
return [...baseVisibleColumns, ...visibleCustomColumns];
|
||||||
|
}, [fields, columns, customColumns, t]);
|
||||||
|
|
||||||
|
// Sync local field changes with backend column configuration (debounced)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!urlProjectId || columns.length === 0 || fields.length === 0) return;
|
||||||
|
|
||||||
|
const timeoutId = setTimeout(() => {
|
||||||
|
const changedFields = fields.filter(field => {
|
||||||
|
const backendColumn = columns.find(c => c.key === field.key);
|
||||||
|
if (backendColumn) {
|
||||||
|
return (backendColumn.pinned ?? false) !== field.visible;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
changedFields.forEach(field => {
|
||||||
|
const backendColumn = columns.find(c => c.key === field.key);
|
||||||
|
if (backendColumn) {
|
||||||
|
dispatch(updateColumnVisibility({
|
||||||
|
projectId: urlProjectId,
|
||||||
|
item: {
|
||||||
|
...backendColumn,
|
||||||
|
pinned: field.visible
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId);
|
||||||
|
}, [fields, columns, urlProjectId, dispatch]);
|
||||||
|
|
||||||
// Effects
|
// Effects
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (urlProjectId) {
|
if (urlProjectId) {
|
||||||
dispatch(fetchTasksV3(urlProjectId));
|
dispatch(fetchTasksV3(urlProjectId));
|
||||||
|
dispatch(fetchTaskListColumns(urlProjectId));
|
||||||
}
|
}
|
||||||
}, [dispatch, urlProjectId]);
|
}, [dispatch, urlProjectId]);
|
||||||
|
|
||||||
// Handlers
|
// Event handlers
|
||||||
const handleTaskSelect = useCallback(
|
const handleTaskSelect = useCallback(
|
||||||
(taskId: string, event: React.MouseEvent) => {
|
(taskId: string, event: React.MouseEvent) => {
|
||||||
if (event.ctrlKey || event.metaKey) {
|
if (event.ctrlKey || event.metaKey) {
|
||||||
dispatch(toggleTaskSelection(taskId));
|
dispatch(toggleTaskSelection(taskId));
|
||||||
} else if (event.shiftKey && lastSelectedTaskId) {
|
} else if (event.shiftKey && lastSelectedTaskId) {
|
||||||
const taskIds = allTasks.map(t => t.id); // Use allTasks here
|
const taskIds = allTasks.map(t => t.id);
|
||||||
const startIdx = taskIds.indexOf(lastSelectedTaskId);
|
const startIdx = taskIds.indexOf(lastSelectedTaskId);
|
||||||
const endIdx = taskIds.indexOf(taskId);
|
const endIdx = taskIds.indexOf(taskId);
|
||||||
const rangeIds = taskIds.slice(Math.min(startIdx, endIdx), Math.max(startIdx, endIdx) + 1);
|
const rangeIds = taskIds.slice(Math.min(startIdx, endIdx), Math.max(startIdx, endIdx) + 1);
|
||||||
@@ -175,259 +236,98 @@ const TaskListV2: React.FC = () => {
|
|||||||
|
|
||||||
const handleGroupCollapse = useCallback(
|
const handleGroupCollapse = useCallback(
|
||||||
(groupId: string) => {
|
(groupId: string) => {
|
||||||
dispatch(toggleGroupCollapsed(groupId)); // Dispatch Redux action to toggle collapsed state
|
dispatch(toggleGroupCollapsed(groupId));
|
||||||
},
|
},
|
||||||
[dispatch]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
|
||||||
// Drag and drop handlers
|
// Function to update custom column values
|
||||||
const handleDragStart = useCallback((event: DragStartEvent) => {
|
const updateTaskCustomColumnValue = useCallback((taskId: string, columnKey: string, value: string) => {
|
||||||
setActiveId(event.active.id as string);
|
try {
|
||||||
}, []);
|
if (!urlProjectId) {
|
||||||
|
console.error('Project ID is missing');
|
||||||
const handleDragOver = useCallback(
|
|
||||||
(event: DragOverEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
|
|
||||||
if (!over) return;
|
|
||||||
|
|
||||||
const activeId = active.id;
|
|
||||||
const overId = over.id;
|
|
||||||
|
|
||||||
// Find the active task and the item being dragged over
|
|
||||||
const activeTask = allTasks.find(task => task.id === activeId);
|
|
||||||
if (!activeTask) return;
|
|
||||||
|
|
||||||
// Check if we're dragging over a task or a group
|
|
||||||
const overTask = allTasks.find(task => task.id === overId);
|
|
||||||
const overGroup = groups.find(group => group.id === overId);
|
|
||||||
|
|
||||||
// Find the groups
|
|
||||||
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
|
||||||
let targetGroup = overGroup;
|
|
||||||
|
|
||||||
if (overTask) {
|
|
||||||
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!activeGroup || !targetGroup) return;
|
|
||||||
|
|
||||||
// If dragging to a different group, we need to handle cross-group movement
|
|
||||||
if (activeGroup.id !== targetGroup.id) {
|
|
||||||
console.log('Cross-group drag detected:', {
|
|
||||||
activeTask: activeTask.id,
|
|
||||||
fromGroup: activeGroup.id,
|
|
||||||
toGroup: targetGroup.id,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[allTasks, groups]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
|
||||||
(event: DragEndEvent) => {
|
|
||||||
const { active, over } = event;
|
|
||||||
setActiveId(null);
|
|
||||||
|
|
||||||
if (!over || active.id === over.id) {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const activeId = active.id;
|
const body = {
|
||||||
const overId = over.id;
|
task_id: taskId,
|
||||||
|
column_key: columnKey,
|
||||||
|
value: value,
|
||||||
|
project_id: urlProjectId,
|
||||||
|
};
|
||||||
|
|
||||||
// Find the active task
|
if (socket && connected) {
|
||||||
const activeTask = allTasks.find(task => task.id === activeId);
|
socket.emit(SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), JSON.stringify(body));
|
||||||
if (!activeTask) {
|
|
||||||
console.error('Active task not found:', activeId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find the groups
|
|
||||||
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
|
||||||
if (!activeGroup) {
|
|
||||||
console.error('Could not find active group for task:', activeId);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we're dropping on a task or a group
|
|
||||||
const overTask = allTasks.find(task => task.id === overId);
|
|
||||||
const overGroup = groups.find(group => group.id === overId);
|
|
||||||
|
|
||||||
let targetGroup = overGroup;
|
|
||||||
let insertIndex = 0;
|
|
||||||
|
|
||||||
if (overTask) {
|
|
||||||
// Dropping on a task
|
|
||||||
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
|
||||||
if (targetGroup) {
|
|
||||||
insertIndex = targetGroup.taskIds.indexOf(overTask.id);
|
|
||||||
}
|
|
||||||
} else if (overGroup) {
|
|
||||||
// Dropping on a group (at the end)
|
|
||||||
targetGroup = overGroup;
|
|
||||||
insertIndex = targetGroup.taskIds.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targetGroup) {
|
|
||||||
console.error('Could not find target group');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isCrossGroup = activeGroup.id !== targetGroup.id;
|
|
||||||
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
|
|
||||||
|
|
||||||
console.log('Drag operation:', {
|
|
||||||
activeId,
|
|
||||||
overId,
|
|
||||||
activeTask: activeTask.name || activeTask.title,
|
|
||||||
activeGroup: activeGroup.id,
|
|
||||||
targetGroup: targetGroup.id,
|
|
||||||
activeIndex,
|
|
||||||
insertIndex,
|
|
||||||
isCrossGroup,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (isCrossGroup) {
|
|
||||||
// Moving task between groups
|
|
||||||
console.log('Moving task between groups:', {
|
|
||||||
task: activeTask.name || activeTask.title,
|
|
||||||
from: activeGroup.title,
|
|
||||||
to: targetGroup.title,
|
|
||||||
newPosition: insertIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Move task to the target group
|
|
||||||
dispatch(
|
|
||||||
moveTaskBetweenGroups({
|
|
||||||
taskId: activeId as string,
|
|
||||||
sourceGroupId: activeGroup.id,
|
|
||||||
targetGroupId: targetGroup.id,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
// Reorder task within target group at drop position
|
|
||||||
dispatch(
|
|
||||||
reorderTasksInGroup({
|
|
||||||
sourceTaskId: activeId as string,
|
|
||||||
destinationTaskId: over.id as string,
|
|
||||||
sourceGroupId: activeGroup.id,
|
|
||||||
destinationGroupId: targetGroup.id,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
// Reordering within the same group
|
console.warn('Socket not connected, unable to emit TASK_CUSTOM_COLUMN_UPDATE event');
|
||||||
console.log('Reordering task within same group:', {
|
|
||||||
task: activeTask.name || activeTask.title,
|
|
||||||
group: activeGroup.title,
|
|
||||||
from: activeIndex,
|
|
||||||
to: insertIndex,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (activeIndex !== insertIndex) {
|
|
||||||
// Reorder task within same group at drop position
|
|
||||||
dispatch(
|
|
||||||
reorderTasksInGroup({
|
|
||||||
sourceTaskId: activeId as string,
|
|
||||||
destinationTaskId: over.id as string,
|
|
||||||
sourceGroupId: activeGroup.id,
|
|
||||||
destinationGroupId: activeGroup.id,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
} catch (error) {
|
||||||
[allTasks, groups]
|
console.error('Error updating custom column value:', error);
|
||||||
);
|
}
|
||||||
|
}, [urlProjectId, socket, connected]);
|
||||||
|
|
||||||
// Bulk action handlers
|
// Custom column settings handler
|
||||||
const handleClearSelection = useCallback(() => {
|
const handleCustomColumnSettings = useCallback((columnKey: string) => {
|
||||||
dispatch(clearSelection());
|
if (!columnKey) return;
|
||||||
}, [dispatch]);
|
|
||||||
|
|
||||||
const handleBulkStatusChange = useCallback(async (statusId: string) => {
|
const columnData = visibleColumns.find(col => col.key === columnKey || col.id === columnKey);
|
||||||
// TODO: Implement bulk status change
|
|
||||||
console.log('Bulk status change:', statusId);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBulkPriorityChange = useCallback(async (priorityId: string) => {
|
dispatch(setCustomColumnModalAttributes({
|
||||||
// TODO: Implement bulk priority change
|
modalType: 'edit',
|
||||||
console.log('Bulk priority change:', priorityId);
|
columnId: columnKey,
|
||||||
}, []);
|
columnData: columnData
|
||||||
|
}));
|
||||||
|
dispatch(toggleCustomColumnModalOpen(true));
|
||||||
|
}, [dispatch, visibleColumns]);
|
||||||
|
|
||||||
const handleBulkPhaseChange = useCallback(async (phaseId: string) => {
|
// Add callback for task added
|
||||||
// TODO: Implement bulk phase change
|
const handleTaskAdded = useCallback(() => {
|
||||||
console.log('Bulk phase change:', phaseId);
|
// Task is now added in real-time via socket, no need to refetch
|
||||||
}, []);
|
// The global socket handler will handle the real-time update
|
||||||
|
|
||||||
const handleBulkAssignToMe = useCallback(async () => {
|
|
||||||
// TODO: Implement bulk assign to me
|
|
||||||
console.log('Bulk assign to me');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBulkAssignMembers = useCallback(async (memberIds: string[]) => {
|
|
||||||
// TODO: Implement bulk assign members
|
|
||||||
console.log('Bulk assign members:', memberIds);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBulkAddLabels = useCallback(async (labelIds: string[]) => {
|
|
||||||
// TODO: Implement bulk add labels
|
|
||||||
console.log('Bulk add labels:', labelIds);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBulkArchive = useCallback(async () => {
|
|
||||||
// TODO: Implement bulk archive
|
|
||||||
console.log('Bulk archive');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBulkDelete = useCallback(async () => {
|
|
||||||
// TODO: Implement bulk delete
|
|
||||||
console.log('Bulk delete');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBulkDuplicate = useCallback(async () => {
|
|
||||||
// TODO: Implement bulk duplicate
|
|
||||||
console.log('Bulk duplicate');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBulkExport = useCallback(async () => {
|
|
||||||
// TODO: Implement bulk export
|
|
||||||
console.log('Bulk export');
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleBulkSetDueDate = useCallback(async (date: string) => {
|
|
||||||
// TODO: Implement bulk set due date
|
|
||||||
console.log('Bulk set due date:', date);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Memoized values for GroupedVirtuoso
|
// Memoized values for GroupedVirtuoso
|
||||||
const virtuosoGroups = useMemo(() => {
|
const virtuosoGroups = useMemo(() => {
|
||||||
let currentTaskIndex = 0;
|
let currentTaskIndex = 0;
|
||||||
|
|
||||||
return groups.map(group => {
|
return groups.map(group => {
|
||||||
const isCurrentGroupCollapsed = collapsedGroups.has(group.id);
|
const isCurrentGroupCollapsed = collapsedGroups.has(group.id);
|
||||||
|
|
||||||
// Order tasks according to group.taskIds array to maintain proper order
|
|
||||||
const visibleTasksInGroup = isCurrentGroupCollapsed
|
const visibleTasksInGroup = isCurrentGroupCollapsed
|
||||||
? []
|
? []
|
||||||
: group.taskIds
|
: group.taskIds
|
||||||
.map(taskId => allTasks.find(task => task.id === taskId))
|
.map(taskId => allTasks.find(task => task.id === taskId))
|
||||||
.filter((task): task is Task => task !== undefined); // Type guard to filter out undefined tasks
|
.filter((task): task is Task => task !== undefined);
|
||||||
|
|
||||||
const tasksForVirtuoso = visibleTasksInGroup.map(task => ({
|
const tasksForVirtuoso = visibleTasksInGroup.map(task => ({
|
||||||
...task,
|
...task,
|
||||||
originalIndex: allTasks.indexOf(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 = {
|
const groupData = {
|
||||||
...group,
|
...group,
|
||||||
tasks: tasksForVirtuoso,
|
tasks: itemsWithAddTask,
|
||||||
startIndex: currentTaskIndex,
|
startIndex: currentTaskIndex,
|
||||||
count: tasksForVirtuoso.length,
|
count: itemsWithAddTask.length,
|
||||||
|
actualCount: group.taskIds.length,
|
||||||
|
groupValue: group.groupValue || group.title,
|
||||||
};
|
};
|
||||||
currentTaskIndex += tasksForVirtuoso.length;
|
currentTaskIndex += itemsWithAddTask.length;
|
||||||
return groupData;
|
return groupData;
|
||||||
});
|
});
|
||||||
}, [groups, allTasks, collapsedGroups]);
|
}, [groups, allTasks, collapsedGroups, currentGrouping, urlProjectId]);
|
||||||
|
|
||||||
const virtuosoGroupCounts = useMemo(() => {
|
const virtuosoGroupCounts = useMemo(() => {
|
||||||
return virtuosoGroups.map(group => group.count);
|
return virtuosoGroups.map(group => group.count);
|
||||||
@@ -437,49 +337,12 @@ const TaskListV2: React.FC = () => {
|
|||||||
return virtuosoGroups.flatMap(group => group.tasks);
|
return virtuosoGroups.flatMap(group => group.tasks);
|
||||||
}, [virtuosoGroups]);
|
}, [virtuosoGroups]);
|
||||||
|
|
||||||
// Memoize column headers to prevent unnecessary re-renders
|
|
||||||
const columnHeaders = useMemo(
|
|
||||||
() => (
|
|
||||||
<div className="flex items-center px-4 py-2" style={{ minWidth: 'max-content' }}>
|
|
||||||
{visibleColumns.map(column => {
|
|
||||||
const columnStyle: ColumnStyle = {
|
|
||||||
width: column.width,
|
|
||||||
flexShrink: 0, // Prevent columns from shrinking
|
|
||||||
// Add specific styling for labels column with auto width
|
|
||||||
...(column.id === 'labels' && column.width === 'auto'
|
|
||||||
? {
|
|
||||||
minWidth: '200px', // Ensure minimum width for labels
|
|
||||||
flexGrow: 1, // Allow it to grow
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={column.id}
|
|
||||||
className="text-xs font-medium text-gray-500 dark:text-gray-400"
|
|
||||||
style={columnStyle}
|
|
||||||
>
|
|
||||||
{column.id === 'dragHandle' ? (
|
|
||||||
<HolderOutlined className="text-gray-400" />
|
|
||||||
) : column.id === 'checkbox' ? (
|
|
||||||
<span></span> // Empty for checkbox column header
|
|
||||||
) : (
|
|
||||||
column.label
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
[visibleColumns]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Render functions
|
// Render functions
|
||||||
const renderGroup = useCallback(
|
const renderGroup = useCallback(
|
||||||
(groupIndex: number) => {
|
(groupIndex: number) => {
|
||||||
const group = virtuosoGroups[groupIndex];
|
const group = virtuosoGroups[groupIndex];
|
||||||
const isGroupEmpty = group.count === 0;
|
const isGroupCollapsed = collapsedGroups.has(group.id);
|
||||||
|
const isGroupEmpty = group.actualCount === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={groupIndex > 0 ? 'mt-2' : ''}>
|
<div className={groupIndex > 0 ? 'mt-2' : ''}>
|
||||||
@@ -487,40 +350,114 @@ const TaskListV2: React.FC = () => {
|
|||||||
group={{
|
group={{
|
||||||
id: group.id,
|
id: group.id,
|
||||||
name: group.title,
|
name: group.title,
|
||||||
count: group.count,
|
count: group.actualCount,
|
||||||
color: group.color,
|
color: group.color,
|
||||||
}}
|
}}
|
||||||
isCollapsed={collapsedGroups.has(group.id)}
|
isCollapsed={isGroupCollapsed}
|
||||||
onToggle={() => handleGroupCollapse(group.id)}
|
onToggle={() => handleGroupCollapse(group.id)}
|
||||||
|
projectId={urlProjectId || ''}
|
||||||
/>
|
/>
|
||||||
{/* Empty group drop zone */}
|
{isGroupEmpty && !isGroupCollapsed && (
|
||||||
{isGroupEmpty && !collapsedGroups.has(group.id) && (
|
<div className="relative w-full">
|
||||||
<div className="px-4 py-8 text-center text-gray-400 dark:text-gray-500 border-2 border-dashed border-transparent hover:border-blue-300 transition-colors">
|
<div className="flex items-center min-w-max px-1 py-3">
|
||||||
<div className="text-sm">Drop tasks here</div>
|
{visibleColumns.map((column) => (
|
||||||
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[virtuosoGroups, collapsedGroups, handleGroupCollapse]
|
[virtuosoGroups, collapsedGroups, handleGroupCollapse, visibleColumns, t]
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderTask = useCallback(
|
const renderTask = useCallback(
|
||||||
(taskIndex: number) => {
|
(taskIndex: number) => {
|
||||||
const task = virtuosoItems[taskIndex]; // Get task from the flattened virtuosoItems
|
const item = virtuosoItems[taskIndex];
|
||||||
if (!task || !urlProjectId) return null; // Should not happen if logic is correct
|
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 (
|
return (
|
||||||
<TaskRowWithSubtasks
|
<TaskRowWithSubtasks
|
||||||
taskId={task.id}
|
taskId={item.id}
|
||||||
projectId={urlProjectId}
|
projectId={urlProjectId}
|
||||||
visibleColumns={visibleColumns}
|
visibleColumns={visibleColumns}
|
||||||
|
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[virtuosoItems, visibleColumns]
|
[virtuosoItems, visibleColumns, urlProjectId, handleTaskAdded, updateTaskCustomColumnValue]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (loading) return <div>Loading...</div>;
|
// Render column headers
|
||||||
|
const renderColumnHeaders = useCallback(() => (
|
||||||
|
<div className="sticky top-0 z-30 bg-gray-50 dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center px-1 py-3 w-full" style={{ minWidth: 'max-content', height: '44px' }}>
|
||||||
|
{visibleColumns.map(column => {
|
||||||
|
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 === 'taskKey' ? 'pl-3' : ''
|
||||||
|
}`}
|
||||||
|
style={columnStyle}
|
||||||
|
>
|
||||||
|
{column.id === 'dragHandle' || column.id === 'checkbox' ? (
|
||||||
|
<span></span>
|
||||||
|
) : (column as any).isCustom ? (
|
||||||
|
<CustomColumnHeader
|
||||||
|
column={column}
|
||||||
|
onSettingsClick={handleCustomColumnSettings}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
t(column.label || '')
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<div className="flex items-center justify-center" style={{ width: '60px', flexShrink: 0 }}>
|
||||||
|
<AddCustomColumnButton />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
), [visibleColumns, t, handleCustomColumnSettings]);
|
||||||
|
|
||||||
|
// Loading and error states
|
||||||
|
if (loading || loadingColumns) return <Skeleton active />;
|
||||||
if (error) return <div>Error: {error}</div>;
|
if (error) return <div>Error: {error}</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -531,31 +468,35 @@ const TaskListV2: React.FC = () => {
|
|||||||
onDragOver={handleDragOver}
|
onDragOver={handleDragOver}
|
||||||
onDragEnd={handleDragEnd}
|
onDragEnd={handleDragEnd}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col h-screen bg-white dark:bg-gray-900">
|
<div className="flex flex-col bg-white dark:bg-gray-900" style={{ height: '100vh', overflow: 'hidden' }}>
|
||||||
{/* Task Filters */}
|
{/* Task Filters */}
|
||||||
<div className="flex-none px-4 py-3">
|
<div className="flex-none px-4 py-3" style={{ height: '66px', flexShrink: 0 }}>
|
||||||
<ImprovedTaskFilters position="list" />
|
<ImprovedTaskFilters position="list" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Table Container with synchronized horizontal scrolling */}
|
{/* Table Container */}
|
||||||
<div className="flex-1 overflow-x-auto">
|
<div
|
||||||
<div className="min-w-max flex flex-col h-full">
|
className="flex-1 overflow-auto border border-gray-200 dark:border-gray-700"
|
||||||
{/* Column Headers - Fixed at top */}
|
style={{
|
||||||
<div className="flex-none border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
height: '600px',
|
||||||
{columnHeaders}
|
maxHeight: '600px'
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ minWidth: 'max-content' }}>
|
||||||
|
{/* Column Headers */}
|
||||||
|
{renderColumnHeaders()}
|
||||||
|
|
||||||
{/* Task List - Scrollable content */}
|
{/* Task List Content */}
|
||||||
<div className="flex-1">
|
<div className="bg-white dark:bg-gray-900">
|
||||||
<SortableContext
|
<SortableContext
|
||||||
items={virtuosoItems
|
items={virtuosoItems
|
||||||
.filter(task => !task.parent_task_id)
|
.filter(item => !('isAddTaskRow' in item) && !item.parent_task_id)
|
||||||
.map(task => task.id)
|
.map(item => item.id)
|
||||||
.filter((id): id is string => id !== undefined)}
|
.filter((id): id is string => id !== undefined)}
|
||||||
strategy={verticalListSortingStrategy}
|
strategy={verticalListSortingStrategy}
|
||||||
>
|
>
|
||||||
<GroupedVirtuoso
|
<GroupedVirtuoso
|
||||||
style={{ height: 'calc(100vh - 200px)' }}
|
style={{ height: '550px' }}
|
||||||
groupCounts={virtuosoGroupCounts}
|
groupCounts={virtuosoGroupCounts}
|
||||||
groupContent={renderGroup}
|
groupContent={renderGroup}
|
||||||
itemContent={renderTask}
|
itemContent={renderTask}
|
||||||
@@ -564,7 +505,7 @@ const TaskListV2: React.FC = () => {
|
|||||||
HTMLDivElement,
|
HTMLDivElement,
|
||||||
{ style?: React.CSSProperties; children?: React.ReactNode }
|
{ style?: React.CSSProperties; children?: React.ReactNode }
|
||||||
>(({ style, children }, ref) => (
|
>(({ style, children }, ref) => (
|
||||||
<div ref={ref} style={style || {}} className="virtuoso-list-container">
|
<div ref={ref} style={style || {}} className="virtuoso-list-container bg-white dark:bg-gray-900">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
)),
|
)),
|
||||||
@@ -600,24 +541,29 @@ const TaskListV2: React.FC = () => {
|
|||||||
|
|
||||||
{/* Bulk Action Bar */}
|
{/* Bulk Action Bar */}
|
||||||
{selectedTaskIds.length > 0 && urlProjectId && (
|
{selectedTaskIds.length > 0 && urlProjectId && (
|
||||||
<OptimizedBulkActionBar
|
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50">
|
||||||
selectedTaskIds={selectedTaskIds}
|
<OptimizedBulkActionBar
|
||||||
totalSelected={selectedTaskIds.length}
|
selectedTaskIds={selectedTaskIds}
|
||||||
projectId={urlProjectId}
|
totalSelected={selectedTaskIds.length}
|
||||||
onClearSelection={handleClearSelection}
|
projectId={urlProjectId}
|
||||||
onBulkStatusChange={handleBulkStatusChange}
|
onClearSelection={bulkActions.handleClearSelection}
|
||||||
onBulkPriorityChange={handleBulkPriorityChange}
|
onBulkStatusChange={(statusId) => bulkActions.handleBulkStatusChange(statusId, selectedTaskIds)}
|
||||||
onBulkPhaseChange={handleBulkPhaseChange}
|
onBulkPriorityChange={(priorityId) => bulkActions.handleBulkPriorityChange(priorityId, selectedTaskIds)}
|
||||||
onBulkAssignToMe={handleBulkAssignToMe}
|
onBulkPhaseChange={(phaseId) => bulkActions.handleBulkPhaseChange(phaseId, selectedTaskIds)}
|
||||||
onBulkAssignMembers={handleBulkAssignMembers}
|
onBulkAssignToMe={() => bulkActions.handleBulkAssignToMe(selectedTaskIds)}
|
||||||
onBulkAddLabels={handleBulkAddLabels}
|
onBulkAssignMembers={(memberIds) => bulkActions.handleBulkAssignMembers(memberIds, selectedTaskIds)}
|
||||||
onBulkArchive={handleBulkArchive}
|
onBulkAddLabels={(labelIds) => bulkActions.handleBulkAddLabels(labelIds, selectedTaskIds)}
|
||||||
onBulkDelete={handleBulkDelete}
|
onBulkArchive={() => bulkActions.handleBulkArchive(selectedTaskIds)}
|
||||||
onBulkDuplicate={handleBulkDuplicate}
|
onBulkDelete={() => bulkActions.handleBulkDelete(selectedTaskIds)}
|
||||||
onBulkExport={handleBulkExport}
|
onBulkDuplicate={() => bulkActions.handleBulkDuplicate(selectedTaskIds)}
|
||||||
onBulkSetDueDate={handleBulkSetDueDate}
|
onBulkExport={() => bulkActions.handleBulkExport(selectedTaskIds)}
|
||||||
/>
|
onBulkSetDueDate={(date) => bulkActions.handleBulkSetDueDate(date, selectedTaskIds)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Custom Column Modal */}
|
||||||
|
{createPortal(<CustomColumnModal />, document.body, 'custom-column-modal')}
|
||||||
</div>
|
</div>
|
||||||
</DndContext>
|
</DndContext>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { memo, useMemo, useCallback, useState } from 'react';
|
import React, { memo, useMemo, useCallback, useState } from 'react';
|
||||||
import { useSortable } from '@dnd-kit/sortable';
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
import { CheckCircleOutlined, HolderOutlined, CloseOutlined, DownOutlined, RightOutlined, DoubleRightOutlined } from '@ant-design/icons';
|
import { CheckCircleOutlined, HolderOutlined, CloseOutlined, DownOutlined, RightOutlined, DoubleRightOutlined, ArrowsAltOutlined } from '@ant-design/icons';
|
||||||
import { Checkbox, DatePicker } from 'antd';
|
import { Checkbox, DatePicker } from 'antd';
|
||||||
import { dayjs, taskManagementAntdConfig } from '@/shared/antd-imports';
|
import { dayjs, taskManagementAntdConfig } from '@/shared/antd-imports';
|
||||||
import { Task } from '@/types/task-management.types';
|
import { Task } from '@/types/task-management.types';
|
||||||
@@ -25,6 +25,7 @@ import TaskTimeTracking from './TaskTimeTracking';
|
|||||||
import { CustomNumberLabel, CustomColordLabel } from '@/components';
|
import { CustomNumberLabel, CustomColordLabel } from '@/components';
|
||||||
import LabelsSelector from '@/components/LabelsSelector';
|
import LabelsSelector from '@/components/LabelsSelector';
|
||||||
import TaskPhaseDropdown from '@/components/task-management/task-phase-dropdown';
|
import TaskPhaseDropdown from '@/components/task-management/task-phase-dropdown';
|
||||||
|
import { CustomColumnCell } from './components/CustomColumnComponents';
|
||||||
|
|
||||||
interface TaskRowProps {
|
interface TaskRowProps {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -33,8 +34,13 @@ interface TaskRowProps {
|
|||||||
id: string;
|
id: string;
|
||||||
width: string;
|
width: string;
|
||||||
isSticky?: boolean;
|
isSticky?: boolean;
|
||||||
|
key?: string;
|
||||||
|
custom_column?: boolean;
|
||||||
|
custom_column_obj?: any;
|
||||||
|
isCustom?: boolean;
|
||||||
}>;
|
}>;
|
||||||
isSubtask?: boolean;
|
isSubtask?: boolean;
|
||||||
|
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface TaskLabelsCellProps {
|
interface TaskLabelsCellProps {
|
||||||
@@ -91,7 +97,7 @@ const formatDate = (dateString: string): string => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumns, isSubtask = false }) => {
|
const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumns, isSubtask = false, updateTaskCustomColumnValue }) => {
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const task = useAppSelector(state => selectTaskById(state, taskId));
|
const task = useAppSelector(state => selectTaskById(state, taskId));
|
||||||
const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId));
|
const isSelected = useAppSelector(state => selectIsTaskSelected(state, taskId));
|
||||||
@@ -276,8 +282,8 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
|
|
||||||
case 'taskKey':
|
case 'taskKey':
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center" style={baseStyle}>
|
<div className="flex items-center pl-3" style={baseStyle}>
|
||||||
<span className="text-sm font-medium text-gray-900 dark:text-white whitespace-nowrap">
|
<span className="text-xs font-medium px-2 py-1 rounded-md bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 whitespace-nowrap border border-gray-200 dark:border-gray-600">
|
||||||
{task.task_key || 'N/A'}
|
{task.task_key || 'N/A'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,33 +293,33 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between group" style={baseStyle}>
|
<div className="flex items-center justify-between group" style={baseStyle}>
|
||||||
<div className="flex items-center flex-1">
|
<div className="flex items-center flex-1">
|
||||||
{/* Indentation for subtasks - increased padding */}
|
{/* Indentation for subtasks - tighter spacing */}
|
||||||
{isSubtask && <div className="w-8" />}
|
{isSubtask && <div className="w-4" />}
|
||||||
|
|
||||||
{/* Expand/Collapse button - only show for parent tasks */}
|
{/* Expand/Collapse button - only show for parent tasks */}
|
||||||
{!isSubtask && (
|
{!isSubtask && (
|
||||||
<button
|
<button
|
||||||
onClick={handleToggleExpansion}
|
onClick={handleToggleExpansion}
|
||||||
className={`flex h-4 w-4 items-center justify-center rounded-sm text-xs mr-2 hover:border hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors ${
|
className={`flex h-4 w-4 items-center justify-center rounded-sm text-xs mr-1 hover:border hover:border-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 hover:scale-110 transition-all duration-300 ease-out ${
|
||||||
task.sub_tasks_count && task.sub_tasks_count > 0
|
task.sub_tasks_count && Number(task.sub_tasks_count) > 0
|
||||||
? 'opacity-100'
|
? 'opacity-100'
|
||||||
: 'opacity-0 group-hover:opacity-100'
|
: 'opacity-0 group-hover:opacity-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{task.sub_tasks_count && task.sub_tasks_count > 0 ? (
|
<div
|
||||||
task.show_sub_tasks ? (
|
className="transition-transform duration-300 ease-out"
|
||||||
<DownOutlined className="text-gray-600 dark:text-gray-400" />
|
style={{
|
||||||
) : (
|
transform: task.show_sub_tasks ? 'rotate(90deg)' : 'rotate(0deg)',
|
||||||
<RightOutlined className="text-gray-600 dark:text-gray-400" />
|
transformOrigin: 'center'
|
||||||
)
|
}}
|
||||||
) : (
|
>
|
||||||
<RightOutlined className="text-gray-600 dark:text-gray-400" />
|
<RightOutlined className="text-gray-600 dark:text-gray-400" />
|
||||||
)}
|
</div>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Additional indentation for subtasks after the expand button space */}
|
{/* Additional indentation for subtasks after the expand button space */}
|
||||||
{isSubtask && <div className="w-4" />}
|
{isSubtask && <div className="w-2" />}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">
|
<span className="text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||||
@@ -321,7 +327,7 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
</span>
|
</span>
|
||||||
|
|
||||||
{/* Subtask count indicator */}
|
{/* Subtask count indicator */}
|
||||||
{!isSubtask && task.sub_tasks_count && task.sub_tasks_count > 0 && (
|
{!isSubtask && task.sub_tasks_count && Number(task.sub_tasks_count) > 0 && (
|
||||||
<div className="flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
<div className="flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded-md">
|
||||||
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
|
<span className="text-xs text-blue-600 dark:text-blue-400 font-medium">
|
||||||
{task.sub_tasks_count}
|
{task.sub_tasks_count}
|
||||||
@@ -333,13 +339,14 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
className="opacity-0 group-hover:opacity-100 transition-opacity duration-200 ml-2 px-2 py-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 border-none bg-transparent cursor-pointer"
|
className="opacity-0 group-hover:opacity-100 transition-all duration-200 ml-2 mr-2 px-3 py-1.5 text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 hover:bg-blue-50 dark:hover:bg-blue-900/20 border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 cursor-pointer rounded-md shadow-sm hover:shadow-md flex items-center gap-1"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
dispatch(setSelectedTaskId(task.id));
|
dispatch(setSelectedTaskId(task.id));
|
||||||
dispatch(setShowTaskDrawer(true));
|
dispatch(setShowTaskDrawer(true));
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
<ArrowsAltOutlined />
|
||||||
{t('openButton')}
|
{t('openButton')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -604,6 +611,19 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
);
|
);
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
// Handle custom columns
|
||||||
|
const column = visibleColumns.find(col => col.id === columnId);
|
||||||
|
if (column && (column.custom_column || column.isCustom) && updateTaskCustomColumnValue) {
|
||||||
|
return (
|
||||||
|
<div style={baseStyle}>
|
||||||
|
<CustomColumnCell
|
||||||
|
column={column}
|
||||||
|
task={task}
|
||||||
|
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
@@ -632,19 +652,25 @@ const TaskRow: React.FC<TaskRowProps> = memo(({ taskId, projectId, visibleColumn
|
|||||||
|
|
||||||
// Translation
|
// Translation
|
||||||
t,
|
t,
|
||||||
|
|
||||||
|
// Custom columns
|
||||||
|
visibleColumns,
|
||||||
|
updateTaskCustomColumnValue,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setNodeRef}
|
ref={setNodeRef}
|
||||||
style={style}
|
style={style}
|
||||||
className={`flex items-center min-w-max px-4 py-2 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
className={`flex items-center min-w-max px-1 py-2 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 ${
|
||||||
isDragging ? 'shadow-lg border border-blue-300' : ''
|
isDragging ? 'shadow-lg border border-blue-300' : ''
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{visibleColumns.map((column, index) =>
|
{visibleColumns.map((column, index) => (
|
||||||
renderColumn(column.id, column.width, column.isSticky, index)
|
<React.Fragment key={column.id}>
|
||||||
)}
|
{renderColumn(column.id, column.width, column.isSticky, index)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { PlusOutlined } from '@ant-design/icons';
|
|||||||
import { useSocket } from '@/socket/socketContext';
|
import { useSocket } from '@/socket/socketContext';
|
||||||
import { SocketEvents } from '@/shared/socket-events';
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
|
||||||
interface TaskRowWithSubtasksProps {
|
interface TaskRowWithSubtasksProps {
|
||||||
taskId: string;
|
taskId: string;
|
||||||
@@ -19,6 +20,7 @@ interface TaskRowWithSubtasksProps {
|
|||||||
width: string;
|
width: string;
|
||||||
isSticky?: boolean;
|
isSticky?: boolean;
|
||||||
}>;
|
}>;
|
||||||
|
updateTaskCustomColumnValue?: (taskId: string, columnKey: string, value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AddSubtaskRowProps {
|
interface AddSubtaskRowProps {
|
||||||
@@ -44,8 +46,11 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
|||||||
const { t } = useTranslation('task-list-table');
|
const { t } = useTranslation('task-list-table');
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
|
// Get session data for reporter_id and team_id
|
||||||
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
|
||||||
const handleAddSubtask = useCallback(() => {
|
const handleAddSubtask = useCallback(() => {
|
||||||
if (!subtaskName.trim()) return;
|
if (!subtaskName.trim() || !currentSession) return;
|
||||||
|
|
||||||
// Create optimistic subtask immediately for better UX
|
// Create optimistic subtask immediately for better UX
|
||||||
dispatch(createSubtask({
|
dispatch(createSubtask({
|
||||||
@@ -62,6 +67,8 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
|||||||
name: subtaskName.trim(),
|
name: subtaskName.trim(),
|
||||||
project_id: projectId,
|
project_id: projectId,
|
||||||
parent_task_id: parentTaskId,
|
parent_task_id: parentTaskId,
|
||||||
|
reporter_id: currentSession.id,
|
||||||
|
team_id: currentSession.team_id,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -69,7 +76,7 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
|||||||
setSubtaskName('');
|
setSubtaskName('');
|
||||||
setIsAdding(false);
|
setIsAdding(false);
|
||||||
onSubtaskAdded();
|
onSubtaskAdded();
|
||||||
}, [subtaskName, dispatch, parentTaskId, projectId, connected, socket, onSubtaskAdded]);
|
}, [subtaskName, dispatch, parentTaskId, projectId, connected, socket, currentSession, onSubtaskAdded]);
|
||||||
|
|
||||||
const handleCancel = useCallback(() => {
|
const handleCancel = useCallback(() => {
|
||||||
setSubtaskName('');
|
setSubtaskName('');
|
||||||
@@ -90,8 +97,9 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center h-full" style={baseStyle}>
|
<div className="flex items-center h-full" style={baseStyle}>
|
||||||
<div className="flex items-center w-full h-full">
|
<div className="flex items-center w-full h-full">
|
||||||
{/* Match subtask indentation pattern - same as TaskRow for subtasks */}
|
{/* Match subtask indentation pattern - tighter spacing */}
|
||||||
<div className="w-8" />
|
<div className="w-4" />
|
||||||
|
<div className="w-2" />
|
||||||
|
|
||||||
{!isAdding ? (
|
{!isAdding ? (
|
||||||
<button
|
<button
|
||||||
@@ -127,7 +135,7 @@ const AddSubtaskRow: React.FC<AddSubtaskRowProps> = memo(({
|
|||||||
}, [isAdding, subtaskName, handleAddSubtask, handleCancel, t]);
|
}, [isAdding, subtaskName, handleAddSubtask, handleCancel, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center min-w-max px-4 py-2 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[42px]">
|
<div className="flex items-center min-w-max px-1 py-2 border-b border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[42px]">
|
||||||
{visibleColumns.map((column) =>
|
{visibleColumns.map((column) =>
|
||||||
renderColumn(column.id, column.width)
|
renderColumn(column.id, column.width)
|
||||||
)}
|
)}
|
||||||
@@ -140,7 +148,8 @@ AddSubtaskRow.displayName = 'AddSubtaskRow';
|
|||||||
const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
|
const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
|
||||||
taskId,
|
taskId,
|
||||||
projectId,
|
projectId,
|
||||||
visibleColumns
|
visibleColumns,
|
||||||
|
updateTaskCustomColumnValue
|
||||||
}) => {
|
}) => {
|
||||||
const task = useAppSelector(state => selectTaskById(state, taskId));
|
const task = useAppSelector(state => selectTaskById(state, taskId));
|
||||||
const isLoadingSubtasks = useAppSelector(state => selectSubtaskLoading(state, taskId));
|
const isLoadingSubtasks = useAppSelector(state => selectSubtaskLoading(state, taskId));
|
||||||
@@ -162,6 +171,7 @@ const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
|
|||||||
taskId={taskId}
|
taskId={taskId}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
visibleColumns={visibleColumns}
|
visibleColumns={visibleColumns}
|
||||||
|
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Subtasks and add subtask row when expanded */}
|
{/* Subtasks and add subtask row when expanded */}
|
||||||
@@ -182,6 +192,7 @@ const TaskRowWithSubtasks: React.FC<TaskRowWithSubtasksProps> = memo(({
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
visibleColumns={visibleColumns}
|
visibleColumns={visibleColumns}
|
||||||
isSubtask={true}
|
isSubtask={true}
|
||||||
|
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
import React, { useState, useCallback, memo } from 'react';
|
||||||
|
import { Input } from 'antd';
|
||||||
|
import { PlusOutlined } from '@ant-design/icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useSocket } from '@/socket/socketContext';
|
||||||
|
import { SocketEvents } from '@/shared/socket-events';
|
||||||
|
import { useAuthService } from '@/hooks/useAuth';
|
||||||
|
|
||||||
|
interface AddTaskRowProps {
|
||||||
|
groupId: string;
|
||||||
|
groupType: string;
|
||||||
|
groupValue: string;
|
||||||
|
projectId: string;
|
||||||
|
visibleColumns: Array<{
|
||||||
|
id: string;
|
||||||
|
width: string;
|
||||||
|
isSticky?: boolean;
|
||||||
|
}>;
|
||||||
|
onTaskAdded: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AddTaskRow: React.FC<AddTaskRowProps> = memo(({
|
||||||
|
groupId,
|
||||||
|
groupType,
|
||||||
|
groupValue,
|
||||||
|
projectId,
|
||||||
|
visibleColumns,
|
||||||
|
onTaskAdded
|
||||||
|
}) => {
|
||||||
|
const [isAdding, setIsAdding] = useState(false);
|
||||||
|
const [taskName, setTaskName] = useState('');
|
||||||
|
const { socket, connected } = useSocket();
|
||||||
|
const { t } = useTranslation('task-list-table');
|
||||||
|
|
||||||
|
// Get session data for reporter_id and team_id
|
||||||
|
const currentSession = useAuthService().getCurrentSession();
|
||||||
|
|
||||||
|
// The global socket handler (useTaskSocketHandlers) will handle task addition
|
||||||
|
// No need for local socket listener to avoid duplicate additions
|
||||||
|
|
||||||
|
const handleAddTask = useCallback(() => {
|
||||||
|
if (!taskName.trim() || !currentSession) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: any = {
|
||||||
|
name: taskName.trim(),
|
||||||
|
project_id: projectId,
|
||||||
|
reporter_id: currentSession.id,
|
||||||
|
team_id: currentSession.team_id,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map grouping type to correct field name expected by backend
|
||||||
|
switch (groupType) {
|
||||||
|
case 'status':
|
||||||
|
body.status_id = groupValue;
|
||||||
|
break;
|
||||||
|
case 'priority':
|
||||||
|
body.priority_id = groupValue;
|
||||||
|
break;
|
||||||
|
case 'phase':
|
||||||
|
body.phase_id = groupValue;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// For any other grouping types, use the groupType as is
|
||||||
|
body[groupType] = groupValue;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (socket && connected) {
|
||||||
|
|
||||||
|
socket.emit(SocketEvents.QUICK_TASK.toString(), JSON.stringify(body));
|
||||||
|
setTaskName('');
|
||||||
|
setIsAdding(false);
|
||||||
|
// Task refresh will be handled by socket response listener
|
||||||
|
} else {
|
||||||
|
console.warn('Socket not connected, unable to create task');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating task:', error);
|
||||||
|
}
|
||||||
|
}, [taskName, projectId, groupType, groupValue, socket, connected, currentSession]);
|
||||||
|
|
||||||
|
const handleCancel = useCallback(() => {
|
||||||
|
setTaskName('');
|
||||||
|
setIsAdding(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const renderColumn = useCallback((columnId: string, width: string) => {
|
||||||
|
const baseStyle = { width };
|
||||||
|
|
||||||
|
switch (columnId) {
|
||||||
|
case 'dragHandle':
|
||||||
|
case 'checkbox':
|
||||||
|
case 'taskKey':
|
||||||
|
return <div style={baseStyle} />;
|
||||||
|
case 'title':
|
||||||
|
return (
|
||||||
|
<div className="flex items-center h-full" style={baseStyle}>
|
||||||
|
<div className="flex items-center w-full h-full">
|
||||||
|
<div className="w-4 mr-1" />
|
||||||
|
|
||||||
|
{!isAdding ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setIsAdding(true)}
|
||||||
|
className="flex items-center gap-2 text-sm text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors h-full"
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-xs" />
|
||||||
|
{t('addTaskText')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<Input
|
||||||
|
value={taskName}
|
||||||
|
onChange={(e) => setTaskName(e.target.value)}
|
||||||
|
onPressEnter={handleAddTask}
|
||||||
|
onBlur={handleCancel}
|
||||||
|
placeholder="Type task name and press Enter to save"
|
||||||
|
className="w-full h-full border-none shadow-none bg-transparent"
|
||||||
|
style={{
|
||||||
|
height: '100%',
|
||||||
|
minHeight: '32px',
|
||||||
|
padding: '0',
|
||||||
|
fontSize: '14px'
|
||||||
|
}}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <div style={baseStyle} />;
|
||||||
|
}
|
||||||
|
}, [isAdding, taskName, handleAddTask, handleCancel, t]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center min-w-max px-1 py-0.5 hover:bg-gray-50 dark:hover:bg-gray-800 min-h-[36px] border-b border-gray-200 dark:border-gray-700">
|
||||||
|
{visibleColumns.map((column) =>
|
||||||
|
renderColumn(column.id, column.width)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
AddTaskRow.displayName = 'AddTaskRow';
|
||||||
|
|
||||||
|
export default AddTaskRow;
|
||||||
@@ -0,0 +1,585 @@
|
|||||||
|
import React, { useState, useCallback, useMemo, memo, useEffect } from 'react';
|
||||||
|
import { Button, Tooltip, Flex, Dropdown, DatePicker, Input } from 'antd';
|
||||||
|
import { PlusOutlined, SettingOutlined, UsergroupAddOutlined } from '@ant-design/icons';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import {
|
||||||
|
setCustomColumnModalAttributes,
|
||||||
|
toggleCustomColumnModalOpen,
|
||||||
|
} from '@/features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
||||||
|
import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice';
|
||||||
|
import PeopleDropdown from '@/components/common/people-dropdown/PeopleDropdown';
|
||||||
|
import AvatarGroup from '@/components/AvatarGroup';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
// Add Custom Column Button Component
|
||||||
|
export const AddCustomColumnButton: React.FC = memo(() => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
|
||||||
|
const { t } = useTranslation('task-list-table');
|
||||||
|
|
||||||
|
const handleModalOpen = useCallback(() => {
|
||||||
|
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
|
||||||
|
dispatch(toggleCustomColumnModalOpen(true));
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip title={t('customColumns.addCustomColumn')} placement="top">
|
||||||
|
<button
|
||||||
|
onClick={handleModalOpen}
|
||||||
|
className={`
|
||||||
|
group relative w-8 h-8 rounded-lg border-2 border-dashed transition-all duration-200
|
||||||
|
flex items-center justify-center
|
||||||
|
${isDarkMode
|
||||||
|
? 'border-gray-600 hover:border-blue-500 hover:bg-blue-500/10 text-gray-500 hover:text-blue-400'
|
||||||
|
: 'border-gray-300 hover:border-blue-500 hover:bg-blue-50 text-gray-400 hover:text-blue-600'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<PlusOutlined className="text-xs transition-transform duration-200 group-hover:scale-110" />
|
||||||
|
|
||||||
|
{/* Subtle glow effect on hover */}
|
||||||
|
<div className={`
|
||||||
|
absolute inset-0 rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200
|
||||||
|
${isDarkMode
|
||||||
|
? 'bg-blue-500/5 shadow-lg shadow-blue-500/20'
|
||||||
|
: 'bg-blue-500/5 shadow-lg shadow-blue-500/10'
|
||||||
|
}
|
||||||
|
`} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
AddCustomColumnButton.displayName = 'AddCustomColumnButton';
|
||||||
|
|
||||||
|
// Custom Column Header Component
|
||||||
|
export const CustomColumnHeader: React.FC<{
|
||||||
|
column: any;
|
||||||
|
onSettingsClick: (columnId: string) => void;
|
||||||
|
}> = ({ column, onSettingsClick }) => {
|
||||||
|
const { t } = useTranslation('task-list-table');
|
||||||
|
|
||||||
|
const displayName = column.name ||
|
||||||
|
column.label ||
|
||||||
|
column.custom_column_obj?.fieldTitle ||
|
||||||
|
column.custom_column_obj?.field_title ||
|
||||||
|
t('customColumns.customColumnHeader');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Flex align="center" justify="space-between" className="w-full px-2">
|
||||||
|
<span title={displayName}>{displayName}</span>
|
||||||
|
<Tooltip title={t('customColumns.customColumnSettings')}>
|
||||||
|
<SettingOutlined
|
||||||
|
className="cursor-pointer hover:text-primary"
|
||||||
|
onClick={e => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onSettingsClick(column.key || column.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Flex>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Custom Column Cell Component with Interactive Inputs
|
||||||
|
export const CustomColumnCell: React.FC<{
|
||||||
|
column: any;
|
||||||
|
task: any;
|
||||||
|
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||||
|
}> = memo(({ column, task, updateTaskCustomColumnValue }) => {
|
||||||
|
const { t } = useTranslation('task-list-table');
|
||||||
|
|
||||||
|
const customValue = task.custom_column_values?.[column.key];
|
||||||
|
const fieldType = column.custom_column_obj?.fieldType;
|
||||||
|
|
||||||
|
if (!fieldType || !column.custom_column) {
|
||||||
|
return <span className="text-gray-400 text-sm">-</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render different input types based on field type
|
||||||
|
switch (fieldType) {
|
||||||
|
case 'people':
|
||||||
|
return (
|
||||||
|
<PeopleCustomColumnCell
|
||||||
|
task={task}
|
||||||
|
columnKey={column.key}
|
||||||
|
customValue={customValue}
|
||||||
|
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'date':
|
||||||
|
return (
|
||||||
|
<DateCustomColumnCell
|
||||||
|
task={task}
|
||||||
|
columnKey={column.key}
|
||||||
|
customValue={customValue}
|
||||||
|
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'number':
|
||||||
|
return (
|
||||||
|
<NumberCustomColumnCell
|
||||||
|
task={task}
|
||||||
|
columnKey={column.key}
|
||||||
|
customValue={customValue}
|
||||||
|
columnObj={column.custom_column_obj}
|
||||||
|
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'selection':
|
||||||
|
return (
|
||||||
|
<SelectionCustomColumnCell
|
||||||
|
task={task}
|
||||||
|
columnKey={column.key}
|
||||||
|
customValue={customValue}
|
||||||
|
columnObj={column.custom_column_obj}
|
||||||
|
updateTaskCustomColumnValue={updateTaskCustomColumnValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
return <span className="text-sm text-gray-400 px-2">{t('customColumns.unsupportedField')}</span>;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
CustomColumnCell.displayName = 'CustomColumnCell';
|
||||||
|
|
||||||
|
// People Field Cell Component
|
||||||
|
export const PeopleCustomColumnCell: React.FC<{
|
||||||
|
task: any;
|
||||||
|
columnKey: string;
|
||||||
|
customValue: any;
|
||||||
|
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||||
|
}> = memo(({ task, columnKey, customValue, updateTaskCustomColumnValue }) => {
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [pendingChanges, setPendingChanges] = useState<Set<string>>(new Set());
|
||||||
|
const [optimisticSelectedIds, setOptimisticSelectedIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
const members = useAppSelector(state => state.teamMembersReducer.teamMembers);
|
||||||
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
|
const isDarkMode = themeMode === 'dark';
|
||||||
|
|
||||||
|
// Parse selected member IDs from custom value
|
||||||
|
const selectedMemberIds = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return customValue ? JSON.parse(customValue) : [];
|
||||||
|
} catch (e) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [customValue]);
|
||||||
|
|
||||||
|
// Use optimistic updates when there are pending changes, otherwise use actual value
|
||||||
|
const displayedMemberIds = useMemo(() => {
|
||||||
|
// If we have pending changes, use optimistic state
|
||||||
|
if (pendingChanges.size > 0) {
|
||||||
|
return optimisticSelectedIds;
|
||||||
|
}
|
||||||
|
// Otherwise use the actual value from the server
|
||||||
|
return selectedMemberIds;
|
||||||
|
}, [pendingChanges.size, optimisticSelectedIds, selectedMemberIds]);
|
||||||
|
|
||||||
|
// Initialize optimistic state and update when actual value changes (from socket updates)
|
||||||
|
useEffect(() => {
|
||||||
|
// Only update optimistic state if there are no pending changes
|
||||||
|
// This prevents the socket update from overriding our optimistic state
|
||||||
|
if (pendingChanges.size === 0) {
|
||||||
|
setOptimisticSelectedIds(selectedMemberIds);
|
||||||
|
}
|
||||||
|
}, [selectedMemberIds, pendingChanges.size]);
|
||||||
|
|
||||||
|
const selectedMembers = useMemo(() => {
|
||||||
|
if (!members?.data || !displayedMemberIds.length) return [];
|
||||||
|
return members.data.filter(member => displayedMemberIds.includes(member.id));
|
||||||
|
}, [members, displayedMemberIds]);
|
||||||
|
|
||||||
|
const handleMemberToggle = useCallback((memberId: string, checked: boolean) => {
|
||||||
|
// Add to pending changes for visual feedback
|
||||||
|
setPendingChanges(prev => new Set(prev).add(memberId));
|
||||||
|
|
||||||
|
const newSelectedIds = checked
|
||||||
|
? [...selectedMemberIds, memberId]
|
||||||
|
: selectedMemberIds.filter((id: string) => id !== memberId);
|
||||||
|
|
||||||
|
// Update optimistic state immediately for instant UI feedback
|
||||||
|
setOptimisticSelectedIds(newSelectedIds);
|
||||||
|
|
||||||
|
if (task.id) {
|
||||||
|
updateTaskCustomColumnValue(task.id, columnKey, JSON.stringify(newSelectedIds));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from pending changes after socket update is processed
|
||||||
|
// Use a longer timeout to ensure the socket update has been received and processed
|
||||||
|
setTimeout(() => {
|
||||||
|
setPendingChanges(prev => {
|
||||||
|
const newSet = new Set<string>(Array.from(prev));
|
||||||
|
newSet.delete(memberId);
|
||||||
|
return newSet;
|
||||||
|
});
|
||||||
|
}, 1500); // Even longer delay to ensure socket update is fully processed
|
||||||
|
}, [selectedMemberIds, task.id, columnKey, updateTaskCustomColumnValue]);
|
||||||
|
|
||||||
|
const loadMembers = useCallback(async () => {
|
||||||
|
if (members?.data?.length === 0) {
|
||||||
|
setIsLoading(true);
|
||||||
|
// The members are loaded through Redux, so we just need to wait
|
||||||
|
setTimeout(() => setIsLoading(false), 500);
|
||||||
|
}
|
||||||
|
}, [members]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-1 px-2 relative custom-column-cell">
|
||||||
|
{selectedMembers.length > 0 && (
|
||||||
|
<AvatarGroup
|
||||||
|
members={selectedMembers.map(member => ({
|
||||||
|
id: member.id,
|
||||||
|
team_member_id: member.id,
|
||||||
|
name: member.name,
|
||||||
|
avatar_url: member.avatar_url,
|
||||||
|
color_code: member.color_code,
|
||||||
|
}))}
|
||||||
|
maxCount={3}
|
||||||
|
size={24}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PeopleDropdown
|
||||||
|
selectedMemberIds={displayedMemberIds}
|
||||||
|
onMemberToggle={handleMemberToggle}
|
||||||
|
isDarkMode={isDarkMode}
|
||||||
|
isLoading={isLoading}
|
||||||
|
loadMembers={loadMembers}
|
||||||
|
pendingChanges={pendingChanges}
|
||||||
|
buttonClassName="w-6 h-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
PeopleCustomColumnCell.displayName = 'PeopleCustomColumnCell';
|
||||||
|
|
||||||
|
// Date Field Cell Component
|
||||||
|
export const DateCustomColumnCell: React.FC<{
|
||||||
|
task: any;
|
||||||
|
columnKey: string;
|
||||||
|
customValue: any;
|
||||||
|
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||||
|
}> = memo(({ task, columnKey, customValue, updateTaskCustomColumnValue }) => {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const dateValue = customValue ? dayjs(customValue) : null;
|
||||||
|
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
|
||||||
|
|
||||||
|
const handleDateChange = (date: dayjs.Dayjs | null) => {
|
||||||
|
if (task.id) {
|
||||||
|
updateTaskCustomColumnValue(task.id, columnKey, date ? date.toISOString() : '');
|
||||||
|
}
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`px-2 relative custom-column-cell ${isOpen ? 'focused' : ''}`}>
|
||||||
|
<div className="relative">
|
||||||
|
<DatePicker
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
value={dateValue}
|
||||||
|
onChange={handleDateChange}
|
||||||
|
placeholder={dateValue ? "" : "Click to set date"}
|
||||||
|
format="MMM DD, YYYY"
|
||||||
|
suffixIcon={null}
|
||||||
|
size="small"
|
||||||
|
variant="borderless"
|
||||||
|
className={`
|
||||||
|
w-full text-sm transition-colors duration-200 custom-column-date-picker
|
||||||
|
${isDarkMode ? 'dark-mode' : 'light-mode'}
|
||||||
|
`}
|
||||||
|
popupClassName={isDarkMode ? 'dark-date-picker' : 'light-date-picker'}
|
||||||
|
inputReadOnly
|
||||||
|
getPopupContainer={(trigger) => trigger.parentElement || document.body}
|
||||||
|
style={{
|
||||||
|
backgroundColor: 'transparent',
|
||||||
|
border: 'none',
|
||||||
|
boxShadow: 'none',
|
||||||
|
width: '100%',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
DateCustomColumnCell.displayName = 'DateCustomColumnCell';
|
||||||
|
|
||||||
|
// Number Field Cell Component
|
||||||
|
export const NumberCustomColumnCell: React.FC<{
|
||||||
|
task: any;
|
||||||
|
columnKey: string;
|
||||||
|
customValue: any;
|
||||||
|
columnObj: any;
|
||||||
|
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||||
|
}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => {
|
||||||
|
const [inputValue, setInputValue] = useState(String(customValue || ''));
|
||||||
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
|
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
|
||||||
|
|
||||||
|
const numberType = columnObj?.numberType || 'formatted';
|
||||||
|
const decimals = columnObj?.decimals || 0;
|
||||||
|
const label = columnObj?.label || '';
|
||||||
|
const labelPosition = columnObj?.labelPosition || 'left';
|
||||||
|
|
||||||
|
// Sync inputValue with customValue to prevent NaN issues
|
||||||
|
useEffect(() => {
|
||||||
|
setInputValue(String(customValue || ''));
|
||||||
|
}, [customValue]);
|
||||||
|
|
||||||
|
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
// Allow only numbers, decimal point, and minus sign
|
||||||
|
if (/^-?\d*\.?\d*$/.test(value) || value === '') {
|
||||||
|
setInputValue(value);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFocus = () => {
|
||||||
|
setIsEditing(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlur = () => {
|
||||||
|
setIsEditing(false);
|
||||||
|
// Only update if there's a valid value and it's different from the current value
|
||||||
|
if (task.id && inputValue !== customValue) {
|
||||||
|
// Safely convert inputValue to string to avoid .trim() errors
|
||||||
|
const stringValue = String(inputValue || '');
|
||||||
|
// Don't save empty values or invalid numbers
|
||||||
|
if (stringValue.trim() === '' || isNaN(parseFloat(stringValue))) {
|
||||||
|
setInputValue(customValue || ''); // Reset to original value
|
||||||
|
} else {
|
||||||
|
updateTaskCustomColumnValue(task.id, columnKey, stringValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleBlur();
|
||||||
|
}
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setInputValue(customValue || '');
|
||||||
|
setIsEditing(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getDisplayValue = () => {
|
||||||
|
if (isEditing) return inputValue;
|
||||||
|
|
||||||
|
// Safely convert inputValue to string to avoid .trim() errors
|
||||||
|
const stringValue = String(inputValue || '');
|
||||||
|
if (!stringValue || stringValue.trim() === '') return '';
|
||||||
|
|
||||||
|
const numValue = parseFloat(stringValue);
|
||||||
|
if (isNaN(numValue)) return ''; // Return empty string instead of showing NaN
|
||||||
|
|
||||||
|
switch (numberType) {
|
||||||
|
case 'formatted':
|
||||||
|
return numValue.toFixed(decimals);
|
||||||
|
case 'percentage':
|
||||||
|
return `${numValue.toFixed(decimals)}%`;
|
||||||
|
case 'withLabel':
|
||||||
|
return labelPosition === 'left' ? `${label} ${numValue.toFixed(decimals)}` : `${numValue.toFixed(decimals)} ${label}`;
|
||||||
|
default:
|
||||||
|
return numValue.toString();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const addonBefore = numberType === 'withLabel' && labelPosition === 'left' ? label : undefined;
|
||||||
|
const addonAfter = numberType === 'withLabel' && labelPosition === 'right' ? label : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-2">
|
||||||
|
<Input
|
||||||
|
value={getDisplayValue()}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={handleFocus}
|
||||||
|
onBlur={handleBlur}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={numberType === 'percentage' ? '0%' : '0'}
|
||||||
|
size="small"
|
||||||
|
variant="borderless"
|
||||||
|
addonBefore={addonBefore}
|
||||||
|
addonAfter={addonAfter}
|
||||||
|
style={{
|
||||||
|
textAlign: 'right',
|
||||||
|
width: '100%',
|
||||||
|
minWidth: 0,
|
||||||
|
}}
|
||||||
|
className={`
|
||||||
|
custom-column-number-input
|
||||||
|
${isDarkMode ? 'dark-mode' : 'light-mode'}
|
||||||
|
`}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
NumberCustomColumnCell.displayName = 'NumberCustomColumnCell';
|
||||||
|
|
||||||
|
// Selection Field Cell Component
|
||||||
|
export const SelectionCustomColumnCell: React.FC<{
|
||||||
|
task: any;
|
||||||
|
columnKey: string;
|
||||||
|
customValue: any;
|
||||||
|
columnObj: any;
|
||||||
|
updateTaskCustomColumnValue: (taskId: string, columnKey: string, value: string) => void;
|
||||||
|
}> = memo(({ task, columnKey, customValue, columnObj, updateTaskCustomColumnValue }) => {
|
||||||
|
const [isDropdownOpen, setIsDropdownOpen] = useState(false);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const isDarkMode = useAppSelector(state => state.themeReducer.mode === 'dark');
|
||||||
|
const selectionsList = columnObj?.selectionsList || [];
|
||||||
|
|
||||||
|
const selectedOption = selectionsList.find((option: any) => option.selection_name === customValue);
|
||||||
|
|
||||||
|
const handleOptionSelect = async (option: any) => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
if (task.id) {
|
||||||
|
updateTaskCustomColumnValue(task.id, columnKey, option.selection_name);
|
||||||
|
}
|
||||||
|
setIsDropdownOpen(false);
|
||||||
|
} finally {
|
||||||
|
// Small delay to show loading state
|
||||||
|
setTimeout(() => setIsLoading(false), 200);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dropdownContent = (
|
||||||
|
<div className={`
|
||||||
|
rounded-lg shadow-xl border min-w-[180px] max-h-64 overflow-y-auto custom-column-dropdown
|
||||||
|
${isDarkMode
|
||||||
|
? 'bg-gray-800 border-gray-600'
|
||||||
|
: 'bg-white border-gray-200'
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
{/* Header */}
|
||||||
|
<div className={`
|
||||||
|
px-3 py-2 border-b text-xs font-medium
|
||||||
|
${isDarkMode
|
||||||
|
? 'border-gray-600 text-gray-300 bg-gray-750'
|
||||||
|
: 'border-gray-200 text-gray-600 bg-gray-50'
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
Select an option
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Options */}
|
||||||
|
<div className="p-1">
|
||||||
|
{selectionsList.map((option: any) => (
|
||||||
|
<div
|
||||||
|
key={option.selection_id}
|
||||||
|
onClick={() => handleOptionSelect(option)}
|
||||||
|
className={`
|
||||||
|
flex items-center gap-3 p-2 rounded-md cursor-pointer transition-all duration-200
|
||||||
|
${selectedOption?.selection_id === option.selection_id
|
||||||
|
? isDarkMode
|
||||||
|
? 'bg-blue-900/50 text-blue-200'
|
||||||
|
: 'bg-blue-50 text-blue-700'
|
||||||
|
: isDarkMode
|
||||||
|
? 'hover:bg-gray-700 text-gray-200'
|
||||||
|
: 'hover:bg-gray-100 text-gray-900'
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full border border-white/20 shadow-sm"
|
||||||
|
style={{ backgroundColor: option.selection_color || '#6b7280' }}
|
||||||
|
/>
|
||||||
|
<span className="text-sm font-medium flex-1">{option.selection_name}</span>
|
||||||
|
{selectedOption?.selection_id === option.selection_id && (
|
||||||
|
<div className={`
|
||||||
|
w-4 h-4 rounded-full flex items-center justify-center
|
||||||
|
${isDarkMode ? 'bg-blue-600' : 'bg-blue-500'}
|
||||||
|
`}>
|
||||||
|
<svg className="w-2.5 h-2.5 text-white" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path fillRule="evenodd" d="M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z" clipRule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{selectionsList.length === 0 && (
|
||||||
|
<div className={`
|
||||||
|
text-center py-8 text-sm
|
||||||
|
${isDarkMode ? 'text-gray-500' : 'text-gray-400'}
|
||||||
|
`}>
|
||||||
|
<div className="mb-2">📋</div>
|
||||||
|
<div>No options available</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`px-2 relative custom-column-cell ${isDropdownOpen ? 'focused' : ''}`}>
|
||||||
|
<Dropdown
|
||||||
|
open={isDropdownOpen}
|
||||||
|
onOpenChange={setIsDropdownOpen}
|
||||||
|
dropdownRender={() => dropdownContent}
|
||||||
|
trigger={['click']}
|
||||||
|
placement="bottomLeft"
|
||||||
|
overlayClassName="custom-selection-dropdown"
|
||||||
|
getPopupContainer={(trigger) => trigger.parentElement || document.body}
|
||||||
|
>
|
||||||
|
<div className={`
|
||||||
|
flex items-center gap-2 cursor-pointer rounded-md px-2 py-1 min-h-[28px] transition-all duration-200 relative
|
||||||
|
${isDropdownOpen
|
||||||
|
? isDarkMode
|
||||||
|
? 'bg-gray-700 ring-1 ring-blue-500/50'
|
||||||
|
: 'bg-gray-100 ring-1 ring-blue-500/50'
|
||||||
|
: isDarkMode
|
||||||
|
? 'hover:bg-gray-700/50'
|
||||||
|
: 'hover:bg-gray-100/50'
|
||||||
|
}
|
||||||
|
`}>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className={`
|
||||||
|
w-3 h-3 rounded-full animate-spin border-2 border-transparent
|
||||||
|
${isDarkMode ? 'border-t-gray-400' : 'border-t-gray-600'}
|
||||||
|
`} />
|
||||||
|
<span className={`text-sm ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`}>
|
||||||
|
Updating...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : selectedOption ? (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="w-3 h-3 rounded-full border border-white/20 shadow-sm"
|
||||||
|
style={{ backgroundColor: selectedOption.selection_color || '#6b7280' }}
|
||||||
|
/>
|
||||||
|
<span className={`text-sm font-medium ${isDarkMode ? 'text-gray-200' : 'text-gray-900'}`}>
|
||||||
|
{selectedOption.selection_name}
|
||||||
|
</span>
|
||||||
|
<svg className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-400' : 'text-gray-500'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className={`w-3 h-3 rounded-full border-2 border-dashed ${isDarkMode ? 'border-gray-600' : 'border-gray-300'}`} />
|
||||||
|
<span className={`text-sm ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`}>
|
||||||
|
Select option
|
||||||
|
</span>
|
||||||
|
<svg className={`w-4 h-4 ml-auto transition-transform duration-200 ${isDropdownOpen ? 'rotate-180' : ''} ${isDarkMode ? 'text-gray-500' : 'text-gray-400'}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Dropdown>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
SelectionCustomColumnCell.displayName = 'SelectionCustomColumnCell';
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { COLUMN_KEYS } from '@/features/tasks/tasks.slice';
|
||||||
|
|
||||||
|
export type ColumnStyle = {
|
||||||
|
width: string;
|
||||||
|
position?: 'static' | 'relative' | 'absolute' | 'sticky' | 'fixed';
|
||||||
|
left?: number;
|
||||||
|
backgroundColor?: string;
|
||||||
|
zIndex?: number;
|
||||||
|
flexShrink?: number;
|
||||||
|
minWidth?: string;
|
||||||
|
maxWidth?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Base column configuration
|
||||||
|
export const BASE_COLUMNS = [
|
||||||
|
{ id: 'dragHandle', label: '', width: '20px', isSticky: true, key: 'dragHandle' },
|
||||||
|
{ id: 'checkbox', label: '', width: '28px', isSticky: true, key: 'checkbox' },
|
||||||
|
{ id: 'taskKey', label: 'keyColumn', width: '100px', key: COLUMN_KEYS.KEY, minWidth: '100px', maxWidth: '150px' },
|
||||||
|
{ id: 'title', label: 'taskColumn', width: '470px', isSticky: true, key: COLUMN_KEYS.NAME },
|
||||||
|
{ id: 'status', label: 'statusColumn', width: '120px', key: COLUMN_KEYS.STATUS },
|
||||||
|
{ id: 'assignees', label: 'assigneesColumn', width: '150px', key: COLUMN_KEYS.ASSIGNEES },
|
||||||
|
{ id: 'priority', label: 'priorityColumn', width: '120px', key: COLUMN_KEYS.PRIORITY },
|
||||||
|
{ id: 'dueDate', label: 'dueDateColumn', width: '120px', key: COLUMN_KEYS.DUE_DATE },
|
||||||
|
{ id: 'progress', label: 'progressColumn', width: '120px', key: COLUMN_KEYS.PROGRESS },
|
||||||
|
{ id: 'labels', label: 'labelsColumn', width: 'auto', key: COLUMN_KEYS.LABELS },
|
||||||
|
{ id: 'phase', label: 'phaseColumn', width: '120px', key: COLUMN_KEYS.PHASE },
|
||||||
|
{ id: 'timeTracking', label: 'timeTrackingColumn', width: '120px', key: COLUMN_KEYS.TIME_TRACKING },
|
||||||
|
{ id: 'estimation', label: 'estimationColumn', width: '120px', key: COLUMN_KEYS.ESTIMATION },
|
||||||
|
{ id: 'startDate', label: 'startDateColumn', width: '120px', key: COLUMN_KEYS.START_DATE },
|
||||||
|
{ id: 'dueTime', label: 'dueTimeColumn', width: '120px', key: COLUMN_KEYS.DUE_TIME },
|
||||||
|
{ id: 'completedDate', label: 'completedDateColumn', width: '120px', key: COLUMN_KEYS.COMPLETED_DATE },
|
||||||
|
{ id: 'createdDate', label: 'createdDateColumn', width: '120px', key: COLUMN_KEYS.CREATED_DATE },
|
||||||
|
{ id: 'lastUpdated', label: 'lastUpdatedColumn', width: '120px', key: COLUMN_KEYS.LAST_UPDATED },
|
||||||
|
{ id: 'reporter', label: 'reporterColumn', width: '120px', key: COLUMN_KEYS.REPORTER },
|
||||||
|
];
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
import { useCallback, useState } from 'react';
|
||||||
|
import { useParams } from 'react-router-dom';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { useAppSelector } from '@/hooks/useAppSelector';
|
||||||
|
import { clearSelection } from '@/features/task-management/selection.slice';
|
||||||
|
import { fetchTasksV3 } from '@/features/task-management/task-management.slice';
|
||||||
|
import { useMixpanelTracking } from '@/hooks/useMixpanelTracking';
|
||||||
|
import { taskListBulkActionsApiService } from '@/api/tasks/task-list-bulk-actions.api.service';
|
||||||
|
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
||||||
|
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||||
|
import alertService from '@/services/alerts/alertService';
|
||||||
|
import logger from '@/utils/errorLogger';
|
||||||
|
import {
|
||||||
|
evt_project_task_list_bulk_archive,
|
||||||
|
evt_project_task_list_bulk_assign_me,
|
||||||
|
evt_project_task_list_bulk_assign_members,
|
||||||
|
evt_project_task_list_bulk_change_phase,
|
||||||
|
evt_project_task_list_bulk_change_priority,
|
||||||
|
evt_project_task_list_bulk_change_status,
|
||||||
|
evt_project_task_list_bulk_delete,
|
||||||
|
evt_project_task_list_bulk_update_labels,
|
||||||
|
} from '@/shared/worklenz-analytics-events';
|
||||||
|
import {
|
||||||
|
IBulkTasksLabelsRequest,
|
||||||
|
IBulkTasksPhaseChangeRequest,
|
||||||
|
IBulkTasksPriorityChangeRequest,
|
||||||
|
IBulkTasksStatusChangeRequest,
|
||||||
|
} from '@/types/tasks/bulk-action-bar.types';
|
||||||
|
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
||||||
|
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
||||||
|
import { ITaskAssignee } from '@/types/tasks/task.types';
|
||||||
|
|
||||||
|
export const useBulkActions = () => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const { projectId } = useParams();
|
||||||
|
const { trackMixpanelEvent } = useMixpanelTracking();
|
||||||
|
const archived = useAppSelector(state => state.taskReducer.archived);
|
||||||
|
|
||||||
|
// Loading states for individual actions
|
||||||
|
const [loadingStates, setLoadingStates] = useState({
|
||||||
|
status: false,
|
||||||
|
priority: false,
|
||||||
|
phase: false,
|
||||||
|
assignToMe: false,
|
||||||
|
assignMembers: false,
|
||||||
|
labels: false,
|
||||||
|
archive: false,
|
||||||
|
delete: false,
|
||||||
|
duplicate: false,
|
||||||
|
export: false,
|
||||||
|
dueDate: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Helper function to update loading state
|
||||||
|
const updateLoadingState = useCallback((action: keyof typeof loadingStates, loading: boolean) => {
|
||||||
|
setLoadingStates(prev => ({ ...prev, [action]: loading }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Helper function to refetch tasks after bulk action
|
||||||
|
const refetchTasks = useCallback(() => {
|
||||||
|
if (projectId) {
|
||||||
|
dispatch(fetchTasksV3(projectId));
|
||||||
|
}
|
||||||
|
}, [dispatch, projectId]);
|
||||||
|
|
||||||
|
const handleClearSelection = useCallback(() => {
|
||||||
|
dispatch(clearSelection());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleBulkStatusChange = useCallback(async (statusId: string, selectedTaskIds: string[]) => {
|
||||||
|
if (!statusId || !projectId || !selectedTaskIds.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateLoadingState('status', true);
|
||||||
|
|
||||||
|
// Check task dependencies before proceeding
|
||||||
|
for (const taskId of selectedTaskIds) {
|
||||||
|
const canContinue = await checkTaskDependencyStatus(taskId, statusId);
|
||||||
|
if (!canContinue) {
|
||||||
|
if (selectedTaskIds.length > 1) {
|
||||||
|
alertService.warning(
|
||||||
|
'Incomplete Dependencies!',
|
||||||
|
'Some tasks were not updated. Please ensure all dependent tasks are completed before proceeding.'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
alertService.error(
|
||||||
|
'Task is not completed',
|
||||||
|
'Please complete the task dependencies before proceeding'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const body: IBulkTasksStatusChangeRequest = {
|
||||||
|
tasks: selectedTaskIds,
|
||||||
|
status_id: statusId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await taskListBulkActionsApiService.changeStatus(body, projectId);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_task_list_bulk_change_status);
|
||||||
|
dispatch(clearSelection());
|
||||||
|
refetchTasks();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error changing status:', error);
|
||||||
|
} finally {
|
||||||
|
updateLoadingState('status', false);
|
||||||
|
}
|
||||||
|
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||||
|
|
||||||
|
const handleBulkPriorityChange = useCallback(async (priorityId: string, selectedTaskIds: string[]) => {
|
||||||
|
if (!priorityId || !projectId || !selectedTaskIds.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateLoadingState('priority', true);
|
||||||
|
|
||||||
|
const body: IBulkTasksPriorityChangeRequest = {
|
||||||
|
tasks: selectedTaskIds,
|
||||||
|
priority_id: priorityId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await taskListBulkActionsApiService.changePriority(body, projectId);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_task_list_bulk_change_priority);
|
||||||
|
dispatch(clearSelection());
|
||||||
|
refetchTasks();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error changing priority:', error);
|
||||||
|
} finally {
|
||||||
|
updateLoadingState('priority', false);
|
||||||
|
}
|
||||||
|
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||||
|
|
||||||
|
const handleBulkPhaseChange = useCallback(async (phaseId: string, selectedTaskIds: string[]) => {
|
||||||
|
if (!phaseId || !projectId || !selectedTaskIds.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateLoadingState('phase', true);
|
||||||
|
|
||||||
|
const body: IBulkTasksPhaseChangeRequest = {
|
||||||
|
tasks: selectedTaskIds,
|
||||||
|
phase_id: phaseId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await taskListBulkActionsApiService.changePhase(body, projectId);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_task_list_bulk_change_phase);
|
||||||
|
dispatch(clearSelection());
|
||||||
|
refetchTasks();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error changing phase:', error);
|
||||||
|
} finally {
|
||||||
|
updateLoadingState('phase', false);
|
||||||
|
}
|
||||||
|
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||||
|
|
||||||
|
const handleBulkAssignToMe = useCallback(async (selectedTaskIds: string[]) => {
|
||||||
|
if (!projectId || !selectedTaskIds.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateLoadingState('assignToMe', true);
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
tasks: selectedTaskIds,
|
||||||
|
project_id: projectId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await taskListBulkActionsApiService.assignToMe(body);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_task_list_bulk_assign_me);
|
||||||
|
dispatch(clearSelection());
|
||||||
|
refetchTasks();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error assigning to me:', error);
|
||||||
|
} finally {
|
||||||
|
updateLoadingState('assignToMe', false);
|
||||||
|
}
|
||||||
|
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||||
|
|
||||||
|
const handleBulkAssignMembers = useCallback(async (memberIds: string[], selectedTaskIds: string[]) => {
|
||||||
|
if (!projectId || !selectedTaskIds.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateLoadingState('assignMembers', true);
|
||||||
|
|
||||||
|
// Convert memberIds to member objects - this would need to be handled by the component
|
||||||
|
// For now, we'll just pass the IDs and let the API handle it
|
||||||
|
const body = {
|
||||||
|
tasks: selectedTaskIds,
|
||||||
|
project_id: projectId,
|
||||||
|
members: memberIds.map(id => ({
|
||||||
|
id: id,
|
||||||
|
name: '',
|
||||||
|
team_member_id: id,
|
||||||
|
project_member_id: id,
|
||||||
|
})) as ITaskAssignee[],
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await taskListBulkActionsApiService.assignTasks(body);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_task_list_bulk_assign_members);
|
||||||
|
dispatch(clearSelection());
|
||||||
|
refetchTasks();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error assigning tasks:', error);
|
||||||
|
} finally {
|
||||||
|
updateLoadingState('assignMembers', false);
|
||||||
|
}
|
||||||
|
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||||
|
|
||||||
|
const handleBulkAddLabels = useCallback(async (labelIds: string[], selectedTaskIds: string[]) => {
|
||||||
|
if (!projectId || !selectedTaskIds.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateLoadingState('labels', true);
|
||||||
|
|
||||||
|
// Convert labelIds to label objects - this would need to be handled by the component
|
||||||
|
// For now, we'll just pass the IDs and let the API handle it
|
||||||
|
const body: IBulkTasksLabelsRequest = {
|
||||||
|
tasks: selectedTaskIds,
|
||||||
|
labels: labelIds.map(id => ({ id, name: '', color: '' })) as ITaskLabel[],
|
||||||
|
text: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await taskListBulkActionsApiService.assignLabels(body, projectId);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_task_list_bulk_update_labels);
|
||||||
|
dispatch(clearSelection());
|
||||||
|
dispatch(fetchLabels()); // Refetch labels in case new ones were created
|
||||||
|
refetchTasks();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating labels:', error);
|
||||||
|
} finally {
|
||||||
|
updateLoadingState('labels', false);
|
||||||
|
}
|
||||||
|
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||||
|
|
||||||
|
const handleBulkArchive = useCallback(async (selectedTaskIds: string[]) => {
|
||||||
|
if (!projectId || !selectedTaskIds.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateLoadingState('archive', true);
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
tasks: selectedTaskIds,
|
||||||
|
project_id: projectId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await taskListBulkActionsApiService.archiveTasks(body, archived);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_task_list_bulk_archive);
|
||||||
|
dispatch(clearSelection());
|
||||||
|
refetchTasks();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error archiving tasks:', error);
|
||||||
|
} finally {
|
||||||
|
updateLoadingState('archive', false);
|
||||||
|
}
|
||||||
|
}, [projectId, archived, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||||
|
|
||||||
|
const handleBulkDelete = useCallback(async (selectedTaskIds: string[]) => {
|
||||||
|
if (!projectId || !selectedTaskIds.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateLoadingState('delete', true);
|
||||||
|
|
||||||
|
const body = {
|
||||||
|
tasks: selectedTaskIds,
|
||||||
|
project_id: projectId,
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await taskListBulkActionsApiService.deleteTasks(body, projectId);
|
||||||
|
if (res.done) {
|
||||||
|
trackMixpanelEvent(evt_project_task_list_bulk_delete);
|
||||||
|
dispatch(clearSelection());
|
||||||
|
refetchTasks();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting tasks:', error);
|
||||||
|
} finally {
|
||||||
|
updateLoadingState('delete', false);
|
||||||
|
}
|
||||||
|
}, [projectId, trackMixpanelEvent, dispatch, refetchTasks, updateLoadingState]);
|
||||||
|
|
||||||
|
const handleBulkDuplicate = useCallback(async (selectedTaskIds: string[]) => {
|
||||||
|
if (!projectId || !selectedTaskIds.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateLoadingState('duplicate', true);
|
||||||
|
// TODO: Implement bulk duplicate API call when available
|
||||||
|
console.log('Bulk duplicate:', selectedTaskIds);
|
||||||
|
// For now, just clear selection and refetch
|
||||||
|
dispatch(clearSelection());
|
||||||
|
refetchTasks();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error duplicating tasks:', error);
|
||||||
|
} finally {
|
||||||
|
updateLoadingState('duplicate', false);
|
||||||
|
}
|
||||||
|
}, [projectId, dispatch, refetchTasks, updateLoadingState]);
|
||||||
|
|
||||||
|
const handleBulkExport = useCallback(async (selectedTaskIds: string[]) => {
|
||||||
|
if (!projectId || !selectedTaskIds.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateLoadingState('export', true);
|
||||||
|
// TODO: Implement bulk export API call when available
|
||||||
|
console.log('Bulk export:', selectedTaskIds);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error exporting tasks:', error);
|
||||||
|
} finally {
|
||||||
|
updateLoadingState('export', false);
|
||||||
|
}
|
||||||
|
}, [projectId, updateLoadingState]);
|
||||||
|
|
||||||
|
const handleBulkSetDueDate = useCallback(async (date: string, selectedTaskIds: string[]) => {
|
||||||
|
if (!projectId || !selectedTaskIds.length) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateLoadingState('dueDate', true);
|
||||||
|
// TODO: Implement bulk set due date API call when available
|
||||||
|
console.log('Bulk set due date:', date, selectedTaskIds);
|
||||||
|
// For now, just clear selection and refetch
|
||||||
|
dispatch(clearSelection());
|
||||||
|
refetchTasks();
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error setting due date:', error);
|
||||||
|
} finally {
|
||||||
|
updateLoadingState('dueDate', false);
|
||||||
|
}
|
||||||
|
}, [projectId, dispatch, refetchTasks, updateLoadingState]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
handleClearSelection,
|
||||||
|
handleBulkStatusChange,
|
||||||
|
handleBulkPriorityChange,
|
||||||
|
handleBulkPhaseChange,
|
||||||
|
handleBulkAssignToMe,
|
||||||
|
handleBulkAssignMembers,
|
||||||
|
handleBulkAddLabels,
|
||||||
|
handleBulkArchive,
|
||||||
|
handleBulkDelete,
|
||||||
|
handleBulkDuplicate,
|
||||||
|
handleBulkExport,
|
||||||
|
handleBulkSetDueDate,
|
||||||
|
loadingStates,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
import { DragEndEvent, DragOverEvent, DragStartEvent } from '@dnd-kit/core';
|
||||||
|
import { useAppDispatch } from '@/hooks/useAppDispatch';
|
||||||
|
import { reorderTasksInGroup, moveTaskBetweenGroups } from '@/features/task-management/task-management.slice';
|
||||||
|
import { Task, TaskGroup } from '@/types/task-management.types';
|
||||||
|
|
||||||
|
export const useDragAndDrop = (allTasks: Task[], groups: TaskGroup[]) => {
|
||||||
|
const dispatch = useAppDispatch();
|
||||||
|
const [activeId, setActiveId] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const handleDragStart = useCallback((event: DragStartEvent) => {
|
||||||
|
setActiveId(event.active.id as string);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleDragOver = useCallback(
|
||||||
|
(event: DragOverEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (!over) return;
|
||||||
|
|
||||||
|
const activeId = active.id;
|
||||||
|
const overId = over.id;
|
||||||
|
|
||||||
|
// Find the active task and the item being dragged over
|
||||||
|
const activeTask = allTasks.find(task => task.id === activeId);
|
||||||
|
if (!activeTask) return;
|
||||||
|
|
||||||
|
// Check if we're dragging over a task or a group
|
||||||
|
const overTask = allTasks.find(task => task.id === overId);
|
||||||
|
const overGroup = groups.find(group => group.id === overId);
|
||||||
|
|
||||||
|
// Find the groups
|
||||||
|
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||||
|
let targetGroup = overGroup;
|
||||||
|
|
||||||
|
if (overTask) {
|
||||||
|
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!activeGroup || !targetGroup) return;
|
||||||
|
|
||||||
|
// If dragging to a different group, we need to handle cross-group movement
|
||||||
|
if (activeGroup.id !== targetGroup.id) {
|
||||||
|
console.log('Cross-group drag detected:', {
|
||||||
|
activeTask: activeTask.id,
|
||||||
|
fromGroup: activeGroup.id,
|
||||||
|
toGroup: targetGroup.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[allTasks, groups]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleDragEnd = useCallback(
|
||||||
|
(event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
setActiveId(null);
|
||||||
|
|
||||||
|
if (!over || active.id === over.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeId = active.id;
|
||||||
|
const overId = over.id;
|
||||||
|
|
||||||
|
// Find the active task
|
||||||
|
const activeTask = allTasks.find(task => task.id === activeId);
|
||||||
|
if (!activeTask) {
|
||||||
|
console.error('Active task not found:', activeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the groups
|
||||||
|
const activeGroup = groups.find(group => group.taskIds.includes(activeTask.id));
|
||||||
|
if (!activeGroup) {
|
||||||
|
console.error('Could not find active group for task:', activeId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we're dropping on a task or a group
|
||||||
|
const overTask = allTasks.find(task => task.id === overId);
|
||||||
|
const overGroup = groups.find(group => group.id === overId);
|
||||||
|
|
||||||
|
let targetGroup = overGroup;
|
||||||
|
let insertIndex = 0;
|
||||||
|
|
||||||
|
if (overTask) {
|
||||||
|
// Dropping on a task
|
||||||
|
targetGroup = groups.find(group => group.taskIds.includes(overTask.id));
|
||||||
|
if (targetGroup) {
|
||||||
|
insertIndex = targetGroup.taskIds.indexOf(overTask.id);
|
||||||
|
}
|
||||||
|
} else if (overGroup) {
|
||||||
|
// Dropping on a group (at the end)
|
||||||
|
targetGroup = overGroup;
|
||||||
|
insertIndex = targetGroup.taskIds.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!targetGroup) {
|
||||||
|
console.error('Could not find target group');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isCrossGroup = activeGroup.id !== targetGroup.id;
|
||||||
|
const activeIndex = activeGroup.taskIds.indexOf(activeTask.id);
|
||||||
|
|
||||||
|
console.log('Drag operation:', {
|
||||||
|
activeId,
|
||||||
|
overId,
|
||||||
|
activeTask: activeTask.name || activeTask.title,
|
||||||
|
activeGroup: activeGroup.id,
|
||||||
|
targetGroup: targetGroup.id,
|
||||||
|
activeIndex,
|
||||||
|
insertIndex,
|
||||||
|
isCrossGroup,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isCrossGroup) {
|
||||||
|
// Moving task between groups
|
||||||
|
console.log('Moving task between groups:', {
|
||||||
|
task: activeTask.name || activeTask.title,
|
||||||
|
from: activeGroup.title,
|
||||||
|
to: targetGroup.title,
|
||||||
|
newPosition: insertIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Move task to the target group
|
||||||
|
dispatch(
|
||||||
|
moveTaskBetweenGroups({
|
||||||
|
taskId: activeId as string,
|
||||||
|
sourceGroupId: activeGroup.id,
|
||||||
|
targetGroupId: targetGroup.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reorder task within target group at drop position
|
||||||
|
dispatch(
|
||||||
|
reorderTasksInGroup({
|
||||||
|
sourceTaskId: activeId as string,
|
||||||
|
destinationTaskId: over.id as string,
|
||||||
|
sourceGroupId: activeGroup.id,
|
||||||
|
destinationGroupId: targetGroup.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Reordering within the same group
|
||||||
|
console.log('Reordering task within same group:', {
|
||||||
|
task: activeTask.name || activeTask.title,
|
||||||
|
group: activeGroup.title,
|
||||||
|
from: activeIndex,
|
||||||
|
to: insertIndex,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (activeIndex !== insertIndex) {
|
||||||
|
// Reorder task within same group at drop position
|
||||||
|
dispatch(
|
||||||
|
reorderTasksInGroup({
|
||||||
|
sourceTaskId: activeId as string,
|
||||||
|
destinationTaskId: over.id as string,
|
||||||
|
sourceGroupId: activeGroup.id,
|
||||||
|
destinationGroupId: activeGroup.id,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[allTasks, groups, dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeId,
|
||||||
|
handleDragStart,
|
||||||
|
handleDragOver,
|
||||||
|
handleDragEnd,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -26,6 +26,9 @@ import { toggleField } from '@/features/task-management/taskListFields.slice';
|
|||||||
import {
|
import {
|
||||||
fetchTasksV3,
|
fetchTasksV3,
|
||||||
setSearch as setTaskManagementSearch,
|
setSearch as setTaskManagementSearch,
|
||||||
|
setArchived as setTaskManagementArchived,
|
||||||
|
toggleArchived as toggleTaskManagementArchived,
|
||||||
|
selectArchived,
|
||||||
} from '@/features/task-management/task-management.slice';
|
} from '@/features/task-management/task-management.slice';
|
||||||
import {
|
import {
|
||||||
setCurrentGrouping,
|
setCurrentGrouping,
|
||||||
@@ -443,11 +446,11 @@ const FilterDropdown: React.FC<{
|
|||||||
${
|
${
|
||||||
selectedCount > 0
|
selectedCount > 0
|
||||||
? isDarkMode
|
? isDarkMode
|
||||||
? 'bg-blue-600 text-white border-blue-500'
|
? 'bg-gray-600 text-white border-gray-500'
|
||||||
: 'bg-blue-50 text-blue-800 border-blue-300 font-semibold'
|
: 'bg-blue-50 text-blue-800 border-blue-300 font-semibold'
|
||||||
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
|
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
|
||||||
}
|
}
|
||||||
hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2
|
hover:shadow-sm focus:outline-none focus:ring-2 focus:ring-gray-500 focus:ring-offset-2
|
||||||
${isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'}
|
${isDarkMode ? 'focus:ring-offset-gray-900' : 'focus:ring-offset-white'}
|
||||||
`}
|
`}
|
||||||
aria-expanded={isOpen}
|
aria-expanded={isOpen}
|
||||||
@@ -456,7 +459,7 @@ const FilterDropdown: React.FC<{
|
|||||||
<IconComponent className="w-3.5 h-3.5" />
|
<IconComponent className="w-3.5 h-3.5" />
|
||||||
<span>{section.label}</span>
|
<span>{section.label}</span>
|
||||||
{selectedCount > 0 && (
|
{selectedCount > 0 && (
|
||||||
<span className="inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-blue-500 rounded-full">
|
<span className="inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-gray-500 rounded-full">
|
||||||
{selectedCount}
|
{selectedCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -518,7 +521,7 @@ const FilterDropdown: React.FC<{
|
|||||||
${
|
${
|
||||||
isSelected
|
isSelected
|
||||||
? isDarkMode
|
? isDarkMode
|
||||||
? 'bg-blue-600 text-white'
|
? 'bg-gray-600 text-white'
|
||||||
: 'bg-blue-50 text-blue-800 font-semibold'
|
: 'bg-blue-50 text-blue-800 font-semibold'
|
||||||
: `${themeClasses.optionText} ${themeClasses.optionHover}`
|
: `${themeClasses.optionText} ${themeClasses.optionHover}`
|
||||||
}
|
}
|
||||||
@@ -530,7 +533,7 @@ const FilterDropdown: React.FC<{
|
|||||||
flex items-center justify-center w-3.5 h-3.5 border rounded
|
flex items-center justify-center w-3.5 h-3.5 border rounded
|
||||||
${
|
${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-blue-500 border-blue-500 text-white'
|
? 'bg-gray-600 border-gray-800 text-white'
|
||||||
: 'border-gray-300 dark:border-gray-600'
|
: 'border-gray-300 dark:border-gray-600'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
@@ -730,7 +733,7 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
|||||||
${
|
${
|
||||||
visibleCount > 0
|
visibleCount > 0
|
||||||
? isDarkMode
|
? isDarkMode
|
||||||
? 'bg-blue-600 text-white border-blue-500'
|
? 'bg-gray-600 text-white border-gray-500'
|
||||||
: 'bg-blue-50 text-blue-800 border-blue-300 font-semibold'
|
: 'bg-blue-50 text-blue-800 border-blue-300 font-semibold'
|
||||||
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
|
: `${themeClasses.buttonBg} ${themeClasses.buttonBorder} ${themeClasses.buttonText}`
|
||||||
}
|
}
|
||||||
@@ -743,7 +746,9 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
|||||||
<EyeOutlined className="w-3.5 h-3.5" />
|
<EyeOutlined className="w-3.5 h-3.5" />
|
||||||
<span>Fields</span>
|
<span>Fields</span>
|
||||||
{visibleCount > 0 && (
|
{visibleCount > 0 && (
|
||||||
<span className="inline-flex items-center justify-center w-4 h-4 text-xs font-bold text-white bg-blue-500 rounded-full">
|
<span
|
||||||
|
className={`inline-flex items-center justify-center w-4 h-4 text-xs font-bold ${isDarkMode ? 'text-white bg-gray-500' : 'text-gray-800 bg-gray-300'} rounded-full`}
|
||||||
|
>
|
||||||
{visibleCount}
|
{visibleCount}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@@ -778,8 +783,8 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
|||||||
${
|
${
|
||||||
isSelected
|
isSelected
|
||||||
? isDarkMode
|
? isDarkMode
|
||||||
? 'bg-blue-600 text-white'
|
? 'text-white font-semibold'
|
||||||
: 'bg-blue-50 text-blue-800 font-semibold'
|
: 'text-gray-800 font-semibold'
|
||||||
: `${themeClasses.optionText} ${themeClasses.optionHover}`
|
: `${themeClasses.optionText} ${themeClasses.optionHover}`
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
@@ -790,7 +795,7 @@ const FieldsDropdown: React.FC<{ themeClasses: any; isDarkMode: boolean }> = ({
|
|||||||
flex items-center justify-center w-3.5 h-3.5 border rounded
|
flex items-center justify-center w-3.5 h-3.5 border rounded
|
||||||
${
|
${
|
||||||
isSelected
|
isSelected
|
||||||
? 'bg-blue-500 border-blue-500 text-white'
|
? 'bg-gray-600 border-gray-600 text-white'
|
||||||
: 'border-gray-300 dark:border-gray-600'
|
: 'border-gray-300 dark:border-gray-600'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
@@ -826,13 +831,17 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
// Enhanced Kanban state
|
// Enhanced Kanban state
|
||||||
const kanbanState = useAppSelector((state: RootState) => state.enhancedKanbanReducer);
|
const kanbanState = useAppSelector((state: RootState) => state.enhancedKanbanReducer);
|
||||||
|
|
||||||
|
// Get archived state from the appropriate slice based on position
|
||||||
|
const taskManagementArchived = useAppSelector(selectArchived);
|
||||||
|
const taskReducerArchived = useAppSelector(state => state.taskReducer.archived);
|
||||||
|
const showArchived = position === 'list' ? taskManagementArchived : taskReducerArchived;
|
||||||
|
|
||||||
// Use the filter data loader hook
|
// Use the filter data loader hook
|
||||||
useFilterDataLoader();
|
useFilterDataLoader();
|
||||||
|
|
||||||
// Local state for filter sections
|
// Local state for filter sections
|
||||||
const [filterSections, setFilterSections] = useState<FilterSection[]>([]);
|
const [filterSections, setFilterSections] = useState<FilterSection[]>([]);
|
||||||
const [searchValue, setSearchValue] = useState('');
|
const [searchValue, setSearchValue] = useState('');
|
||||||
const [showArchived, setShowArchived] = useState(false);
|
|
||||||
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
const [openDropdown, setOpenDropdown] = useState<string | null>(null);
|
||||||
const [activeFiltersCount, setActiveFiltersCount] = useState(0);
|
const [activeFiltersCount, setActiveFiltersCount] = useState(0);
|
||||||
const [clearingFilters, setClearingFilters] = useState(false);
|
const [clearingFilters, setClearingFilters] = useState(false);
|
||||||
@@ -1077,7 +1086,6 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
const batchUpdates = () => {
|
const batchUpdates = () => {
|
||||||
// Clear local state immediately for UI feedback
|
// Clear local state immediately for UI feedback
|
||||||
setSearchValue('');
|
setSearchValue('');
|
||||||
setShowArchived(false);
|
|
||||||
|
|
||||||
// Update local filter sections state immediately
|
// Update local filter sections state immediately
|
||||||
setFilterSections(prev =>
|
setFilterSections(prev =>
|
||||||
@@ -1116,6 +1124,13 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
|
|
||||||
// Clear priority filters
|
// Clear priority filters
|
||||||
dispatch(setPriorities([]));
|
dispatch(setPriorities([]));
|
||||||
|
|
||||||
|
// Clear archived state based on position
|
||||||
|
if (position === 'list') {
|
||||||
|
dispatch(setTaskManagementArchived(false));
|
||||||
|
} else {
|
||||||
|
dispatch(setKanbanArchived(false));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Execute Redux updates
|
// Execute Redux updates
|
||||||
@@ -1137,14 +1152,17 @@ const ImprovedTaskFilters: React.FC<ImprovedTaskFiltersProps> = ({ position, cla
|
|||||||
}, [projectId, projectView, dispatch, currentTaskLabels, currentTaskAssignees, clearingFilters]);
|
}, [projectId, projectView, dispatch, currentTaskLabels, currentTaskAssignees, clearingFilters]);
|
||||||
|
|
||||||
const toggleArchived = useCallback(() => {
|
const toggleArchived = useCallback(() => {
|
||||||
setShowArchived(!showArchived);
|
|
||||||
if (position === 'board') {
|
if (position === 'board') {
|
||||||
dispatch(setKanbanArchived(!showArchived));
|
dispatch(setKanbanArchived(!showArchived));
|
||||||
if (projectId) {
|
if (projectId) {
|
||||||
dispatch(fetchEnhancedKanbanGroups(projectId));
|
dispatch(fetchEnhancedKanbanGroups(projectId));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// ... existing logic ...
|
// For TaskListV2, use the task management slice
|
||||||
|
dispatch(toggleTaskManagementArchived());
|
||||||
|
if (projectId) {
|
||||||
|
dispatch(fetchTasksV3(projectId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [dispatch, projectId, position, showArchived]);
|
}, [dispatch, projectId, position, showArchived]);
|
||||||
|
|
||||||
|
|||||||
@@ -18,43 +18,21 @@ import { Card, Spin, Empty, Alert } from 'antd';
|
|||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import {
|
import {
|
||||||
selectAllTasks,
|
selectAllTasks,
|
||||||
selectGroups,
|
|
||||||
selectGrouping,
|
|
||||||
selectLoading,
|
selectLoading,
|
||||||
selectError,
|
selectError,
|
||||||
selectSelectedPriorities,
|
|
||||||
selectSearch,
|
|
||||||
reorderTasks,
|
|
||||||
moveTaskToGroup,
|
|
||||||
moveTaskBetweenGroups,
|
|
||||||
optimisticTaskMove,
|
|
||||||
reorderTasksInGroup,
|
reorderTasksInGroup,
|
||||||
setLoading,
|
|
||||||
setError,
|
|
||||||
setSelectedPriorities,
|
|
||||||
setSearch,
|
|
||||||
resetTaskManagement,
|
|
||||||
toggleTaskExpansion,
|
toggleTaskExpansion,
|
||||||
addSubtaskToParent,
|
|
||||||
fetchTasksV3,
|
fetchTasksV3,
|
||||||
|
selectTaskGroupsV3,
|
||||||
|
fetchSubTasks,
|
||||||
} from '@/features/task-management/task-management.slice';
|
} from '@/features/task-management/task-management.slice';
|
||||||
import {
|
import {
|
||||||
selectCurrentGrouping,
|
selectCurrentGrouping,
|
||||||
selectCollapsedGroups,
|
|
||||||
selectIsGroupCollapsed,
|
|
||||||
toggleGroupCollapsed,
|
|
||||||
expandAllGroups,
|
|
||||||
collapseAllGroups,
|
|
||||||
} from '@/features/task-management/grouping.slice';
|
} from '@/features/task-management/grouping.slice';
|
||||||
import {
|
import {
|
||||||
selectSelectedTaskIds,
|
selectSelectedTaskIds,
|
||||||
selectLastSelectedTaskId,
|
|
||||||
selectIsTaskSelected,
|
|
||||||
selectTask,
|
|
||||||
deselectTask,
|
|
||||||
toggleTaskSelection,
|
|
||||||
selectRange,
|
|
||||||
clearSelection,
|
clearSelection,
|
||||||
|
selectTask,
|
||||||
} from '@/features/task-management/selection.slice';
|
} from '@/features/task-management/selection.slice';
|
||||||
import {
|
import {
|
||||||
selectTasks,
|
selectTasks,
|
||||||
@@ -89,18 +67,11 @@ import {
|
|||||||
IBulkTasksPriorityChangeRequest,
|
IBulkTasksPriorityChangeRequest,
|
||||||
IBulkTasksStatusChangeRequest,
|
IBulkTasksStatusChangeRequest,
|
||||||
} from '@/types/tasks/bulk-action-bar.types';
|
} from '@/types/tasks/bulk-action-bar.types';
|
||||||
import { ITaskStatus } from '@/types/tasks/taskStatus.types';
|
|
||||||
import { ITaskPriority } from '@/types/tasks/taskPriority.types';
|
|
||||||
import { ITaskPhase } from '@/types/tasks/taskPhase.types';
|
|
||||||
import { ITaskLabel } from '@/types/tasks/taskLabel.types';
|
|
||||||
import { ITeamMemberViewModel } from '@/types/teamMembers/teamMembersGetResponse.types';
|
|
||||||
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
import { IProjectTask } from '@/types/project/projectTasksViewModel.types';
|
||||||
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status';
|
||||||
import alertService from '@/services/alerts/alertService';
|
import alertService from '@/services/alerts/alertService';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
import { fetchLabels } from '@/features/taskAttributes/taskLabelSlice';
|
||||||
import { performanceMonitor } from '@/utils/performance-monitor';
|
|
||||||
import debugPerformance from '@/utils/debug-performance';
|
|
||||||
|
|
||||||
// Import the improved TaskListFilters component synchronously to avoid suspense
|
// Import the improved TaskListFilters component synchronously to avoid suspense
|
||||||
import ImprovedTaskFilters from './improved-task-filters';
|
import ImprovedTaskFilters from './improved-task-filters';
|
||||||
@@ -173,18 +144,11 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
|
|
||||||
// Redux selectors using V3 API (pre-processed data, minimal loops)
|
// Redux selectors using V3 API (pre-processed data, minimal loops)
|
||||||
const tasks = useSelector(selectAllTasks);
|
const tasks = useSelector(selectAllTasks);
|
||||||
const groups = useSelector(selectGroups);
|
|
||||||
const grouping = useSelector(selectGrouping);
|
|
||||||
const loading = useSelector(selectLoading);
|
const loading = useSelector(selectLoading);
|
||||||
const error = useSelector(selectError);
|
const error = useSelector(selectError);
|
||||||
const selectedPriorities = useSelector(selectSelectedPriorities);
|
|
||||||
const searchQuery = useSelector(selectSearch);
|
|
||||||
const taskGroups = useSelector(selectTaskGroupsV3, shallowEqual);
|
const taskGroups = useSelector(selectTaskGroupsV3, shallowEqual);
|
||||||
const currentGrouping = useSelector(selectCurrentGrouping);
|
const currentGrouping = useSelector(selectCurrentGrouping);
|
||||||
const collapsedGroups = useSelector(selectCollapsedGroups);
|
|
||||||
const selectedTaskIds = useSelector(selectSelectedTaskIds);
|
const selectedTaskIds = useSelector(selectSelectedTaskIds);
|
||||||
const lastSelectedTaskId = useSelector(selectLastSelectedTaskId);
|
|
||||||
const selectedTasks = useSelector((state: RootState) => state.bulkActionReducer.selectedTasks);
|
|
||||||
|
|
||||||
// Bulk action selectors
|
// Bulk action selectors
|
||||||
const statusList = useSelector((state: RootState) => state.taskStatusReducer.status);
|
const statusList = useSelector((state: RootState) => state.taskStatusReducer.status);
|
||||||
@@ -202,9 +166,11 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const tasksById = useMemo(() => {
|
const tasksById = useMemo(() => {
|
||||||
const map: Record<string, Task> = {};
|
const map: Record<string, Task> = {};
|
||||||
// Cache all tasks for full functionality - performance optimizations are handled at the virtualization level
|
// Cache all tasks for full functionality - performance optimizations are handled at the virtualization level
|
||||||
tasks.forEach(task => {
|
if (Array.isArray(tasks)) {
|
||||||
map[task.id] = task;
|
tasks.forEach((task: Task) => {
|
||||||
});
|
map[task.id] = task;
|
||||||
|
});
|
||||||
|
}
|
||||||
return map;
|
return map;
|
||||||
}, [tasks]);
|
}, [tasks]);
|
||||||
|
|
||||||
@@ -262,14 +228,6 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
|
|
||||||
const hasAnyTasks = useMemo(() => totalTasks > 0, [totalTasks]);
|
const hasAnyTasks = useMemo(() => totalTasks > 0, [totalTasks]);
|
||||||
|
|
||||||
// Memoized handlers for better performance
|
|
||||||
const handleGroupingChange = useCallback(
|
|
||||||
(newGroupBy: 'status' | 'priority' | 'phase') => {
|
|
||||||
dispatch(setCurrentGrouping(newGroupBy));
|
|
||||||
},
|
|
||||||
[dispatch]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Add isDragging state
|
// Add isDragging state
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
@@ -280,7 +238,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const taskId = active.id as string;
|
const taskId = active.id as string;
|
||||||
|
|
||||||
// Find the task and its group
|
// Find the task and its group
|
||||||
const activeTask = tasks.find(t => t.id === taskId) || null;
|
const activeTask = Array.isArray(tasks) ? tasks.find((t: Task) => t.id === taskId) || null : null;
|
||||||
let activeGroupId: string | null = null;
|
let activeGroupId: string | null = null;
|
||||||
|
|
||||||
if (activeTask) {
|
if (activeTask) {
|
||||||
@@ -312,7 +270,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const overId = over.id as string;
|
const overId = over.id as string;
|
||||||
|
|
||||||
// Check if we're hovering over a task or a group container
|
// Check if we're hovering over a task or a group container
|
||||||
const targetTask = tasks.find(t => t.id === overId);
|
const targetTask = Array.isArray(tasks) ? tasks.find((t: Task) => t.id === overId) : undefined;
|
||||||
let targetGroupId = overId;
|
let targetGroupId = overId;
|
||||||
|
|
||||||
if (targetTask) {
|
if (targetTask) {
|
||||||
@@ -362,7 +320,7 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
let targetIndex = -1;
|
let targetIndex = -1;
|
||||||
|
|
||||||
// Check if dropping on a task or a group
|
// Check if dropping on a task or a group
|
||||||
const targetTask = tasks.find(t => t.id === overId);
|
const targetTask = Array.isArray(tasks) ? tasks.find((t: Task) => t.id === overId) : undefined;
|
||||||
if (targetTask) {
|
if (targetTask) {
|
||||||
// Dropping on a task, find which group contains this task
|
// Dropping on a task, find which group contains this task
|
||||||
for (const group of taskGroups) {
|
for (const group of taskGroups) {
|
||||||
@@ -398,13 +356,10 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
// Use the new reorderTasksInGroup action that properly handles group arrays
|
// Use the new reorderTasksInGroup action that properly handles group arrays
|
||||||
dispatch(
|
dispatch(
|
||||||
reorderTasksInGroup({
|
reorderTasksInGroup({
|
||||||
taskId: activeTaskId,
|
sourceTaskId: activeTaskId,
|
||||||
fromGroupId: currentDragState.activeGroupId,
|
destinationTaskId: targetTask?.id || '',
|
||||||
toGroupId: targetGroupId,
|
sourceGroupId: currentDragState.activeGroupId,
|
||||||
fromIndex: sourceIndex,
|
destinationGroupId: targetGroupId,
|
||||||
toIndex: finalTargetIndex,
|
|
||||||
groupType: targetGroup.groupType,
|
|
||||||
groupValue: targetGroup.groupValue,
|
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -448,10 +403,10 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
const newSelectedIds = Array.from(currentSelectedIds);
|
const newSelectedIds = Array.from(currentSelectedIds);
|
||||||
|
|
||||||
// Map selected tasks to the required format
|
// Map selected tasks to the required format
|
||||||
const newSelectedTasks = tasks
|
const newSelectedTasks = Array.isArray(tasks) ? tasks
|
||||||
.filter((t) => newSelectedIds.includes(t.id))
|
.filter((t: Task) => newSelectedIds.includes(t.id))
|
||||||
.map(
|
.map(
|
||||||
(task): IProjectTask => ({
|
(task: Task): IProjectTask => ({
|
||||||
id: task.id,
|
id: task.id,
|
||||||
name: task.title,
|
name: task.title,
|
||||||
task_key: task.task_key,
|
task_key: task.task_key,
|
||||||
@@ -463,11 +418,11 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
description: task.description,
|
description: task.description,
|
||||||
start_date: task.startDate,
|
start_date: task.startDate,
|
||||||
end_date: task.dueDate,
|
end_date: task.dueDate,
|
||||||
total_hours: task.timeTracking.estimated || 0,
|
total_hours: task.timeTracking?.estimated || 0,
|
||||||
total_minutes: task.timeTracking.logged || 0,
|
total_minutes: task.timeTracking?.logged || 0,
|
||||||
progress: task.progress,
|
progress: task.progress,
|
||||||
sub_tasks_count: task.sub_tasks_count || 0,
|
sub_tasks_count: task.sub_tasks_count || 0,
|
||||||
assignees: task.assignees.map((assigneeId) => ({
|
assignees: task.assignees?.map((assigneeId: string) => ({
|
||||||
id: assigneeId,
|
id: assigneeId,
|
||||||
name: '',
|
name: '',
|
||||||
email: '',
|
email: '',
|
||||||
@@ -477,15 +432,16 @@ const TaskListBoard: React.FC<TaskListBoardProps> = ({ projectId, className = ''
|
|||||||
})),
|
})),
|
||||||
labels: task.labels,
|
labels: task.labels,
|
||||||
manual_progress: false,
|
manual_progress: false,
|
||||||
created_at: task.createdAt,
|
created_at: (task as any).createdAt || (task as any).created_at,
|
||||||
updated_at: task.updatedAt,
|
updated_at: (task as any).updatedAt || (task as any).updated_at,
|
||||||
sort_order: task.order,
|
sort_order: task.order,
|
||||||
})
|
})
|
||||||
);
|
) : [];
|
||||||
|
|
||||||
// Dispatch both actions to update the Redux state
|
// Dispatch both actions to update the Redux state
|
||||||
dispatch(selectTasks(newSelectedTasks));
|
dispatch(selectTasks(newSelectedTasks));
|
||||||
dispatch(selectTaskIds(newSelectedIds));
|
// Update selection state with the new task IDs
|
||||||
|
newSelectedIds.forEach(taskId => dispatch(selectTask(taskId)));
|
||||||
},
|
},
|
||||||
[dispatch, selectedTaskIds, tasks]
|
[dispatch, selectedTaskIds, tasks]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ const ConfigPhaseButton = () => {
|
|||||||
onClick={() => dispatch(toggleDrawer())}
|
onClick={() => dispatch(toggleDrawer())}
|
||||||
icon={
|
icon={
|
||||||
<SettingOutlined
|
<SettingOutlined
|
||||||
style={{ color: themeMode === 'dark' ? colors.white : colors.skyBlue }}
|
style={{ color: themeMode === 'dark' ? colors.white : 'black' }}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
|
||||||
import { CustomTableColumnsType } from '../taskListColumns/taskColumnsSlice';
|
import { CustomTableColumnsType } from '../taskListColumns/taskColumnsSlice';
|
||||||
import { LabelType } from '../../../../types/label.type';
|
import { LabelType } from '../../../../pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/label-type-column/label-type-column';
|
||||||
import { SelectionType } from '../../../../pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/selection-type-column/selection-type-column';
|
import { SelectionType } from '../../../../pages/projects/projectView/taskList/task-list-table/custom-columns/custom-column-modal/selection-type-column/selection-type-column';
|
||||||
|
|
||||||
export type CustomFieldsTypes =
|
export type CustomFieldsTypes =
|
||||||
@@ -21,6 +21,7 @@ type TaskListCustomColumnsState = {
|
|||||||
isCustomColumnModalOpen: boolean;
|
isCustomColumnModalOpen: boolean;
|
||||||
customColumnModalType: 'create' | 'edit';
|
customColumnModalType: 'create' | 'edit';
|
||||||
customColumnId: string | null;
|
customColumnId: string | null;
|
||||||
|
currentColumnData: any | null; // Store the current column data for editing
|
||||||
|
|
||||||
customFieldType: CustomFieldsTypes;
|
customFieldType: CustomFieldsTypes;
|
||||||
customFieldNumberType: CustomFieldNumberTypes;
|
customFieldNumberType: CustomFieldNumberTypes;
|
||||||
@@ -39,6 +40,7 @@ const initialState: TaskListCustomColumnsState = {
|
|||||||
isCustomColumnModalOpen: false,
|
isCustomColumnModalOpen: false,
|
||||||
customColumnModalType: 'create',
|
customColumnModalType: 'create',
|
||||||
customColumnId: null,
|
customColumnId: null,
|
||||||
|
currentColumnData: null,
|
||||||
|
|
||||||
customFieldType: 'people',
|
customFieldType: 'people',
|
||||||
customFieldNumberType: 'formatted',
|
customFieldNumberType: 'formatted',
|
||||||
@@ -62,10 +64,11 @@ const taskListCustomColumnsSlice = createSlice({
|
|||||||
},
|
},
|
||||||
setCustomColumnModalAttributes: (
|
setCustomColumnModalAttributes: (
|
||||||
state,
|
state,
|
||||||
action: PayloadAction<{ modalType: 'create' | 'edit'; columnId: string | null }>
|
action: PayloadAction<{ modalType: 'create' | 'edit'; columnId: string | null; columnData?: any }>
|
||||||
) => {
|
) => {
|
||||||
state.customColumnModalType = action.payload.modalType;
|
state.customColumnModalType = action.payload.modalType;
|
||||||
state.customColumnId = action.payload.columnId;
|
state.customColumnId = action.payload.columnId;
|
||||||
|
state.currentColumnData = action.payload.columnData || null;
|
||||||
},
|
},
|
||||||
setCustomFieldType: (state, action: PayloadAction<CustomFieldsTypes>) => {
|
setCustomFieldType: (state, action: PayloadAction<CustomFieldsTypes>) => {
|
||||||
state.customFieldType = action.payload;
|
state.customFieldType = action.payload;
|
||||||
@@ -98,7 +101,19 @@ const taskListCustomColumnsSlice = createSlice({
|
|||||||
state.selectionsList = action.payload;
|
state.selectionsList = action.payload;
|
||||||
},
|
},
|
||||||
resetCustomFieldValues: state => {
|
resetCustomFieldValues: state => {
|
||||||
state = initialState;
|
// Reset all field values to initial state while keeping modal state
|
||||||
|
state.customFieldType = initialState.customFieldType;
|
||||||
|
state.customFieldNumberType = initialState.customFieldNumberType;
|
||||||
|
state.decimals = initialState.decimals;
|
||||||
|
state.label = initialState.label;
|
||||||
|
state.labelPosition = initialState.labelPosition;
|
||||||
|
state.previewValue = initialState.previewValue;
|
||||||
|
state.expression = initialState.expression;
|
||||||
|
state.firstNumericColumn = initialState.firstNumericColumn;
|
||||||
|
state.secondNumericColumn = initialState.secondNumericColumn;
|
||||||
|
state.labelsList = initialState.labelsList;
|
||||||
|
state.selectionsList = initialState.selectionsList;
|
||||||
|
state.currentColumnData = initialState.currentColumnData;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -137,6 +137,14 @@ export const selectTaskGroups = createSelector(
|
|||||||
tasks.map(task => {
|
tasks.map(task => {
|
||||||
if (currentGrouping === 'status') return task.status;
|
if (currentGrouping === 'status') return task.status;
|
||||||
if (currentGrouping === 'priority') return task.priority;
|
if (currentGrouping === 'priority') return task.priority;
|
||||||
|
if (currentGrouping === 'phase') {
|
||||||
|
// For phase grouping, use 'Unmapped' for tasks without a phase
|
||||||
|
if (!task.phase || task.phase.trim() === '') {
|
||||||
|
return 'Unmapped';
|
||||||
|
} else {
|
||||||
|
return task.phase;
|
||||||
|
}
|
||||||
|
}
|
||||||
return task.phase;
|
return task.phase;
|
||||||
})
|
})
|
||||||
));
|
));
|
||||||
@@ -148,6 +156,13 @@ export const selectTaskGroups = createSelector(
|
|||||||
.filter(task => {
|
.filter(task => {
|
||||||
if (currentGrouping === 'status') return task.status === value;
|
if (currentGrouping === 'status') return task.status === value;
|
||||||
if (currentGrouping === 'priority') return task.priority === value;
|
if (currentGrouping === 'priority') return task.priority === value;
|
||||||
|
if (currentGrouping === 'phase') {
|
||||||
|
if (value === 'Unmapped') {
|
||||||
|
return !task.phase || task.phase.trim() === '';
|
||||||
|
} else {
|
||||||
|
return task.phase === value;
|
||||||
|
}
|
||||||
|
}
|
||||||
return task.phase === value;
|
return task.phase === value;
|
||||||
})
|
})
|
||||||
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
.sort((a, b) => (a.order || 0) - (b.order || 0));
|
||||||
@@ -178,9 +193,20 @@ export const selectTasksByCurrentGrouping = createSelector(
|
|||||||
|
|
||||||
tasks.forEach(task => {
|
tasks.forEach(task => {
|
||||||
let key: string;
|
let key: string;
|
||||||
if (currentGrouping === 'status') key = task.status;
|
if (currentGrouping === 'status') {
|
||||||
else if (currentGrouping === 'priority') key = task.priority;
|
key = task.status;
|
||||||
else key = task.phase || 'Development';
|
} else if (currentGrouping === 'priority') {
|
||||||
|
key = task.priority;
|
||||||
|
} else if (currentGrouping === 'phase') {
|
||||||
|
// For phase grouping, use 'Unmapped' for tasks without a phase
|
||||||
|
if (!task.phase || task.phase.trim() === '') {
|
||||||
|
key = 'Unmapped';
|
||||||
|
} else {
|
||||||
|
key = task.phase;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
key = task.phase || 'Development';
|
||||||
|
}
|
||||||
|
|
||||||
if (!grouped[key]) grouped[key] = [];
|
if (!grouped[key]) grouped[key] = [];
|
||||||
grouped[key].push(task);
|
grouped[key].push(task);
|
||||||
@@ -214,6 +240,7 @@ const getGroupColor = (groupType: GroupingType, value: string): string => {
|
|||||||
Development: '#1890ff',
|
Development: '#1890ff',
|
||||||
Testing: '#faad14',
|
Testing: '#faad14',
|
||||||
Deployment: '#52c41a',
|
Deployment: '#52c41a',
|
||||||
|
Unmapped: '#fbc84c69',
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import {
|
|||||||
EntityId,
|
EntityId,
|
||||||
} from '@reduxjs/toolkit';
|
} from '@reduxjs/toolkit';
|
||||||
import { Task, TaskManagementState, TaskGroup, TaskGrouping } from '@/types/task-management.types';
|
import { Task, TaskManagementState, TaskGroup, TaskGrouping } from '@/types/task-management.types';
|
||||||
|
import { ITaskListColumn } from '@/types/tasks/taskList.types';
|
||||||
import { RootState } from '@/app/store';
|
import { RootState } from '@/app/store';
|
||||||
import {
|
import {
|
||||||
tasksApiService,
|
tasksApiService,
|
||||||
ITaskListConfigV2,
|
ITaskListConfigV2,
|
||||||
ITaskListV3Response,
|
ITaskListV3Response,
|
||||||
} from '@/api/tasks/tasks.api.service';
|
} from '@/api/tasks/tasks.api.service';
|
||||||
|
import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
import { DEFAULT_TASK_NAME } from '@/shared/constants';
|
||||||
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
import { InlineMember } from '@/types/teamMembers/inlineMember.types';
|
||||||
@@ -55,7 +57,12 @@ const initialState: TaskManagementState = {
|
|||||||
grouping: undefined,
|
grouping: undefined,
|
||||||
selectedPriorities: [],
|
selectedPriorities: [],
|
||||||
search: '',
|
search: '',
|
||||||
|
archived: false,
|
||||||
loadingSubtasks: {},
|
loadingSubtasks: {},
|
||||||
|
// Add column-related state
|
||||||
|
loadingColumns: false,
|
||||||
|
columns: [],
|
||||||
|
customColumns: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
// Async thunk to fetch tasks from API
|
// Async thunk to fetch tasks from API
|
||||||
@@ -221,9 +228,12 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
// Get search value from taskReducer
|
// Get search value from taskReducer
|
||||||
const searchValue = state.taskReducer.search || '';
|
const searchValue = state.taskReducer.search || '';
|
||||||
|
|
||||||
|
// Get archived state from task management slice
|
||||||
|
const archivedState = state.taskManagement.archived;
|
||||||
|
|
||||||
const config: ITaskListConfigV2 = {
|
const config: ITaskListConfigV2 = {
|
||||||
id: projectId,
|
id: projectId,
|
||||||
archived: false,
|
archived: archivedState,
|
||||||
group: currentGrouping || '',
|
group: currentGrouping || '',
|
||||||
field: '',
|
field: '',
|
||||||
order: '',
|
order: '',
|
||||||
@@ -234,12 +244,11 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
isSubtasksInclude: false,
|
isSubtasksInclude: false,
|
||||||
labels: selectedLabels,
|
labels: selectedLabels,
|
||||||
priorities: selectedPriorities,
|
priorities: selectedPriorities,
|
||||||
|
customColumns: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await tasksApiService.getTaskListV3(config);
|
const response = await tasksApiService.getTaskListV3(config);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Ensure tasks are properly normalized
|
// Ensure tasks are properly normalized
|
||||||
const tasks: Task[] = response.body.allTasks.map((task: any) => {
|
const tasks: Task[] = response.body.allTasks.map((task: any) => {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -258,7 +267,7 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
labels: task.labels?.map((l: { id: string; label_id: string; name: string; color_code: string; end: boolean; names: string[] }) => ({
|
labels: task.labels?.map((l: { id: string; label_id: string; name: string; color_code: string; end: boolean; names: string[] }) => ({
|
||||||
id: l.id || l.label_id,
|
id: l.id || l.label_id,
|
||||||
name: l.name,
|
name: l.name,
|
||||||
color: l.color || '#1890ff',
|
color: l.color_code || '#1890ff',
|
||||||
end: l.end,
|
end: l.end,
|
||||||
names: l.names,
|
names: l.names,
|
||||||
})) || [],
|
})) || [],
|
||||||
@@ -269,6 +278,7 @@ export const fetchTasksV3 = createAsyncThunk(
|
|||||||
logged: convertTimeValue(task.time_spent),
|
logged: convertTimeValue(task.time_spent),
|
||||||
},
|
},
|
||||||
customFields: {},
|
customFields: {},
|
||||||
|
custom_column_values: task.custom_column_values || {},
|
||||||
createdAt: task.created_at || now,
|
createdAt: task.created_at || now,
|
||||||
updatedAt: task.updated_at || now,
|
updatedAt: task.updated_at || now,
|
||||||
created_at: task.created_at || now,
|
created_at: task.created_at || now,
|
||||||
@@ -435,6 +445,59 @@ export const updateTaskWithSubtasks = createAsyncThunk(
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Add async thunk to fetch task list columns
|
||||||
|
export const fetchTaskListColumns = createAsyncThunk(
|
||||||
|
'taskManagement/fetchTaskListColumns',
|
||||||
|
async (projectId: string, { dispatch }) => {
|
||||||
|
const [standardColumns, customColumns] = await Promise.all([
|
||||||
|
tasksApiService.fetchTaskListColumns(projectId),
|
||||||
|
dispatch(fetchCustomColumns(projectId)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
standard: standardColumns.body,
|
||||||
|
custom: customColumns.payload,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add async thunk to fetch custom columns
|
||||||
|
export const fetchCustomColumns = createAsyncThunk(
|
||||||
|
'taskManagement/fetchCustomColumns',
|
||||||
|
async (projectId: string, { rejectWithValue }) => {
|
||||||
|
try {
|
||||||
|
const response = await tasksCustomColumnsService.getCustomColumns(projectId);
|
||||||
|
return response.body;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Fetch Custom Columns', error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
return rejectWithValue('Failed to fetch custom columns');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add async thunk to update column visibility
|
||||||
|
export const updateColumnVisibility = createAsyncThunk(
|
||||||
|
'taskManagement/updateColumnVisibility',
|
||||||
|
async (
|
||||||
|
{ projectId, item }: { projectId: string; item: ITaskListColumn },
|
||||||
|
{ rejectWithValue }
|
||||||
|
) => {
|
||||||
|
try {
|
||||||
|
const response = await tasksApiService.toggleColumnVisibility(projectId, item);
|
||||||
|
return response.body;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Update Column Visibility', error);
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return rejectWithValue(error.message);
|
||||||
|
}
|
||||||
|
return rejectWithValue('Failed to update column visibility');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Create the slice
|
// Create the slice
|
||||||
const taskManagementSlice = createSlice({
|
const taskManagementSlice = createSlice({
|
||||||
name: 'taskManagement',
|
name: 'taskManagement',
|
||||||
@@ -627,7 +690,12 @@ const taskManagementSlice = createSlice({
|
|||||||
updatedTask.priority = destinationGroup.id;
|
updatedTask.priority = destinationGroup.id;
|
||||||
break;
|
break;
|
||||||
case IGroupBy.PHASE:
|
case IGroupBy.PHASE:
|
||||||
updatedTask.phase = destinationGroup.id;
|
// Handle unmapped group specially
|
||||||
|
if (destinationGroup.id === 'Unmapped' || destinationGroup.title === 'Unmapped') {
|
||||||
|
updatedTask.phase = ''; // Clear phase for unmapped group
|
||||||
|
} else {
|
||||||
|
updatedTask.phase = destinationGroup.id;
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case IGroupBy.MEMBERS:
|
case IGroupBy.MEMBERS:
|
||||||
// If moving to a member group, ensure task is assigned to that member
|
// If moving to a member group, ensure task is assigned to that member
|
||||||
@@ -672,6 +740,12 @@ const taskManagementSlice = createSlice({
|
|||||||
setSearch: (state, action: PayloadAction<string>) => {
|
setSearch: (state, action: PayloadAction<string>) => {
|
||||||
state.search = action.payload;
|
state.search = action.payload;
|
||||||
},
|
},
|
||||||
|
setArchived: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.archived = action.payload;
|
||||||
|
},
|
||||||
|
toggleArchived: (state) => {
|
||||||
|
state.archived = !state.archived;
|
||||||
|
},
|
||||||
resetTaskManagement: state => {
|
resetTaskManagement: state => {
|
||||||
state.loading = false;
|
state.loading = false;
|
||||||
state.error = null;
|
state.error = null;
|
||||||
@@ -679,6 +753,7 @@ const taskManagementSlice = createSlice({
|
|||||||
state.grouping = undefined;
|
state.grouping = undefined;
|
||||||
state.selectedPriorities = [];
|
state.selectedPriorities = [];
|
||||||
state.search = '';
|
state.search = '';
|
||||||
|
state.archived = false;
|
||||||
state.ids = [];
|
state.ids = [];
|
||||||
state.entities = {};
|
state.entities = {};
|
||||||
},
|
},
|
||||||
@@ -782,7 +857,57 @@ const taskManagementSlice = createSlice({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
// Add column-related reducers
|
||||||
|
toggleColumnVisibility: (state, action: PayloadAction<string>) => {
|
||||||
|
const column = state.columns.find(col => col.key === action.payload);
|
||||||
|
if (column) {
|
||||||
|
column.pinned = !column.pinned;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
addCustomColumn: (state, action: PayloadAction<ITaskListColumn>) => {
|
||||||
|
state.customColumns.push(action.payload);
|
||||||
|
// Also add to columns array to maintain visibility
|
||||||
|
state.columns.push({
|
||||||
|
...action.payload,
|
||||||
|
pinned: true, // New columns are visible by default
|
||||||
|
});
|
||||||
|
},
|
||||||
|
updateCustomColumn: (
|
||||||
|
state,
|
||||||
|
action: PayloadAction<{ key: string; column: ITaskListColumn }>
|
||||||
|
) => {
|
||||||
|
const { key, column } = action.payload;
|
||||||
|
const index = state.customColumns.findIndex(col => col.key === key);
|
||||||
|
if (index !== -1) {
|
||||||
|
state.customColumns[index] = column;
|
||||||
|
// Update in columns array as well
|
||||||
|
const colIndex = state.columns.findIndex(col => col.key === key);
|
||||||
|
if (colIndex !== -1) {
|
||||||
|
state.columns[colIndex] = { ...column, pinned: state.columns[colIndex].pinned };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
deleteCustomColumn: (state, action: PayloadAction<string>) => {
|
||||||
|
const key = action.payload;
|
||||||
|
state.customColumns = state.customColumns.filter(col => col.key !== key);
|
||||||
|
// Remove from columns array as well
|
||||||
|
state.columns = state.columns.filter(col => col.key !== key);
|
||||||
|
},
|
||||||
|
// Add action to sync backend columns with local fields
|
||||||
|
syncColumnsWithFields: (state, action: PayloadAction<{ projectId: string; fields: any[] }>) => {
|
||||||
|
const { fields } = action.payload;
|
||||||
|
// Update columns based on local fields
|
||||||
|
state.columns = state.columns.map(column => {
|
||||||
|
const field = fields.find(f => f.key === column.key);
|
||||||
|
if (field) {
|
||||||
|
return {
|
||||||
|
...column,
|
||||||
|
pinned: field.visible
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return column;
|
||||||
|
});
|
||||||
|
},
|
||||||
},
|
},
|
||||||
extraReducers: builder => {
|
extraReducers: builder => {
|
||||||
builder
|
builder
|
||||||
@@ -885,6 +1010,60 @@ const taskManagementSlice = createSlice({
|
|||||||
state.ids = [];
|
state.ids = [];
|
||||||
state.entities = {};
|
state.entities = {};
|
||||||
state.groups = [];
|
state.groups = [];
|
||||||
|
})
|
||||||
|
// Add column-related extraReducers
|
||||||
|
.addCase(fetchTaskListColumns.pending, state => {
|
||||||
|
state.loadingColumns = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchTaskListColumns.fulfilled, (state, action) => {
|
||||||
|
state.loadingColumns = false;
|
||||||
|
|
||||||
|
// Process standard columns
|
||||||
|
const standardColumns = action.payload.standard;
|
||||||
|
standardColumns.splice(1, 0, {
|
||||||
|
key: 'TASK',
|
||||||
|
name: 'Task',
|
||||||
|
index: 1,
|
||||||
|
pinned: true,
|
||||||
|
});
|
||||||
|
// Process custom columns
|
||||||
|
const customColumns = (action.payload as { custom: any[] }).custom.map((col: any) => ({
|
||||||
|
...col,
|
||||||
|
isCustom: true,
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Merge columns
|
||||||
|
state.columns = [...standardColumns, ...customColumns];
|
||||||
|
state.customColumns = customColumns;
|
||||||
|
})
|
||||||
|
.addCase(fetchTaskListColumns.rejected, (state, action) => {
|
||||||
|
state.loadingColumns = false;
|
||||||
|
state.error = action.error.message || 'Failed to fetch task list columns';
|
||||||
|
})
|
||||||
|
.addCase(fetchCustomColumns.pending, state => {
|
||||||
|
state.loadingColumns = true;
|
||||||
|
state.error = null;
|
||||||
|
})
|
||||||
|
.addCase(fetchCustomColumns.fulfilled, (state, action) => {
|
||||||
|
state.loadingColumns = false;
|
||||||
|
state.customColumns = action.payload;
|
||||||
|
// Add custom columns to the columns array
|
||||||
|
const customColumnsForVisibility = action.payload;
|
||||||
|
state.columns = [...state.columns, ...customColumnsForVisibility];
|
||||||
|
})
|
||||||
|
.addCase(fetchCustomColumns.rejected, (state, action) => {
|
||||||
|
state.loadingColumns = false;
|
||||||
|
state.error = action.error.message || 'Failed to fetch custom columns';
|
||||||
|
})
|
||||||
|
.addCase(updateColumnVisibility.fulfilled, (state, action) => {
|
||||||
|
const column = state.columns.find(col => col.key === action.payload.key);
|
||||||
|
if (column) {
|
||||||
|
column.pinned = action.payload.pinned;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.addCase(updateColumnVisibility.rejected, (state, action) => {
|
||||||
|
state.error = action.payload as string;
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -907,12 +1086,20 @@ export const {
|
|||||||
setError,
|
setError,
|
||||||
setSelectedPriorities,
|
setSelectedPriorities,
|
||||||
setSearch,
|
setSearch,
|
||||||
|
setArchived,
|
||||||
|
toggleArchived,
|
||||||
resetTaskManagement,
|
resetTaskManagement,
|
||||||
toggleTaskExpansion,
|
toggleTaskExpansion,
|
||||||
addSubtaskToParent,
|
addSubtaskToParent,
|
||||||
updateTaskAssignees,
|
updateTaskAssignees,
|
||||||
createSubtask,
|
createSubtask,
|
||||||
removeTemporarySubtask,
|
removeTemporarySubtask,
|
||||||
|
// Add column-related actions
|
||||||
|
toggleColumnVisibility,
|
||||||
|
addCustomColumn,
|
||||||
|
updateCustomColumn,
|
||||||
|
deleteCustomColumn,
|
||||||
|
syncColumnsWithFields,
|
||||||
} = taskManagementSlice.actions;
|
} = taskManagementSlice.actions;
|
||||||
|
|
||||||
// Export the selectors
|
// Export the selectors
|
||||||
@@ -938,9 +1125,33 @@ export const selectTasksByPriority = (state: RootState, priority: string) =>
|
|||||||
export const selectTasksByPhase = (state: RootState, phase: string) =>
|
export const selectTasksByPhase = (state: RootState, phase: string) =>
|
||||||
Object.values(state.taskManagement.entities).filter(task => task.phase === phase);
|
Object.values(state.taskManagement.entities).filter(task => task.phase === phase);
|
||||||
|
|
||||||
|
// Add archived selector
|
||||||
|
export const selectArchived = (state: RootState) => state.taskManagement.archived;
|
||||||
|
|
||||||
// Export the reducer as default
|
// Export the reducer as default
|
||||||
export default taskManagementSlice.reducer;
|
export default taskManagementSlice.reducer;
|
||||||
|
|
||||||
// V3 API selectors - no processing needed, data is pre-processed by backend
|
// V3 API selectors - no processing needed, data is pre-processed by backend
|
||||||
export const selectTaskGroupsV3 = (state: RootState) => state.taskManagement.groups;
|
export const selectTaskGroupsV3 = (state: RootState) => state.taskManagement.groups;
|
||||||
export const selectCurrentGroupingV3 = (state: RootState) => state.taskManagement.grouping;
|
export const selectCurrentGroupingV3 = (state: RootState) => state.taskManagement.grouping;
|
||||||
|
|
||||||
|
// Column-related selectors
|
||||||
|
export const selectColumns = (state: RootState) => state.taskManagement.columns;
|
||||||
|
export const selectCustomColumns = (state: RootState) => state.taskManagement.customColumns;
|
||||||
|
export const selectLoadingColumns = (state: RootState) => state.taskManagement.loadingColumns;
|
||||||
|
|
||||||
|
// Helper selector to check if columns are in sync with local fields
|
||||||
|
export const selectColumnsInSync = (state: RootState) => {
|
||||||
|
const columns = state.taskManagement.columns;
|
||||||
|
const fields = state.taskManagementFields || [];
|
||||||
|
|
||||||
|
if (columns.length === 0 || fields.length === 0) return true;
|
||||||
|
|
||||||
|
return !fields.some(field => {
|
||||||
|
const backendColumn = columns.find(c => c.key === field.key);
|
||||||
|
if (backendColumn) {
|
||||||
|
return (backendColumn.pinned ?? false) !== field.visible;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -59,13 +59,21 @@ const taskListFieldsSlice = createSlice({
|
|||||||
const field = state.find(f => f.key === action.payload);
|
const field = state.find(f => f.key === action.payload);
|
||||||
if (field) {
|
if (field) {
|
||||||
field.visible = !field.visible;
|
field.visible = !field.visible;
|
||||||
|
// Save to localStorage immediately after toggle
|
||||||
|
saveFields(state);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setFields(state, action: PayloadAction<TaskListField[]>) {
|
setFields(state, action: PayloadAction<TaskListField[]>) {
|
||||||
return action.payload;
|
const newState = action.payload;
|
||||||
|
// Save to localStorage when fields are set
|
||||||
|
saveFields(newState);
|
||||||
|
return newState;
|
||||||
},
|
},
|
||||||
resetFields() {
|
resetFields() {
|
||||||
return DEFAULT_FIELDS;
|
const defaultFields = DEFAULT_FIELDS;
|
||||||
|
// Save to localStorage when fields are reset
|
||||||
|
saveFields(defaultFields);
|
||||||
|
return defaultFields;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -481,18 +481,18 @@ export const useTaskSocketHandlers = () => {
|
|||||||
// Find target group based on new phase value
|
// Find target group based on new phase value
|
||||||
let targetGroup: any = null;
|
let targetGroup: any = null;
|
||||||
|
|
||||||
if (newPhaseValue) {
|
if (newPhaseValue && newPhaseValue.trim() !== '') {
|
||||||
// Find group by phase name
|
// Find group by phase name
|
||||||
targetGroup = groups.find(
|
targetGroup = groups.find(
|
||||||
group => group.groupValue === newPhaseValue || group.title === newPhaseValue
|
group => group.groupValue === newPhaseValue || group.title === newPhaseValue
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Find "No Phase" or similar group
|
// Find "Unmapped" group for tasks without a phase or with default phase
|
||||||
targetGroup = groups.find(
|
targetGroup = groups.find(
|
||||||
group =>
|
group =>
|
||||||
group.groupValue === '' ||
|
group.groupValue === 'Unmapped' ||
|
||||||
group.title.toLowerCase().includes('no phase') ||
|
group.title === 'Unmapped' ||
|
||||||
group.title.toLowerCase().includes('unassigned')
|
group.title.toLowerCase().includes('unmapped')
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,7 +575,9 @@ export const useTaskSocketHandlers = () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleNewTaskReceived = useCallback(
|
const handleNewTaskReceived = useCallback(
|
||||||
(data: IProjectTask) => {
|
(response: any) => {
|
||||||
|
// Handle array format response [index, taskData]
|
||||||
|
const data = Array.isArray(response) ? response[1] : response;
|
||||||
if (!data) return;
|
if (!data) return;
|
||||||
if (data.parent_task_id) {
|
if (data.parent_task_id) {
|
||||||
// Handle subtask creation
|
// Handle subtask creation
|
||||||
@@ -600,10 +602,10 @@ export const useTaskSocketHandlers = () => {
|
|||||||
: 'low') as 'critical' | 'high' | 'medium' | 'low',
|
: 'low') as 'critical' | 'high' | 'medium' | 'low',
|
||||||
phase: data.phase_name || 'Development',
|
phase: data.phase_name || 'Development',
|
||||||
progress: data.complete_ratio || 0,
|
progress: data.complete_ratio || 0,
|
||||||
assignees: data.assignees?.map(a => a.team_member_id) || [],
|
assignees: data.assignees?.map((a: any) => a.team_member_id) || [],
|
||||||
assignee_names: data.names || [],
|
assignee_names: data.names || [],
|
||||||
labels:
|
labels:
|
||||||
data.labels?.map(l => ({
|
data.labels?.map((l: any) => ({
|
||||||
id: l.id || '',
|
id: l.id || '',
|
||||||
name: l.name || '',
|
name: l.name || '',
|
||||||
color: l.color_code || '#1890ff',
|
color: l.color_code || '#1890ff',
|
||||||
@@ -615,9 +617,8 @@ export const useTaskSocketHandlers = () => {
|
|||||||
estimated: (data.total_hours || 0) + (data.total_minutes || 0) / 60,
|
estimated: (data.total_hours || 0) + (data.total_minutes || 0) / 60,
|
||||||
logged: (data.time_spent?.hours || 0) + (data.time_spent?.minutes || 0) / 60,
|
logged: (data.time_spent?.hours || 0) + (data.time_spent?.minutes || 0) / 60,
|
||||||
},
|
},
|
||||||
customFields: {},
|
created_at: data.created_at || new Date().toISOString(),
|
||||||
createdAt: data.created_at || new Date().toISOString(),
|
updated_at: data.updated_at || new Date().toISOString(),
|
||||||
updatedAt: data.updated_at || new Date().toISOString(),
|
|
||||||
order: data.sort_order || 0,
|
order: data.sort_order || 0,
|
||||||
parent_task_id: data.parent_task_id,
|
parent_task_id: data.parent_task_id,
|
||||||
is_sub_task: true,
|
is_sub_task: true,
|
||||||
@@ -634,7 +635,7 @@ export const useTaskSocketHandlers = () => {
|
|||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// Handle regular task creation - transform to Task format and add
|
// Handle regular task creation - transform to Task format and add
|
||||||
const task = {
|
const task: Task = {
|
||||||
id: data.id || '',
|
id: data.id || '',
|
||||||
task_key: data.task_key || '',
|
task_key: data.task_key || '',
|
||||||
title: data.name || '',
|
title: data.name || '',
|
||||||
@@ -655,10 +656,10 @@ export const useTaskSocketHandlers = () => {
|
|||||||
: 'low') as 'critical' | 'high' | 'medium' | 'low',
|
: 'low') as 'critical' | 'high' | 'medium' | 'low',
|
||||||
phase: data.phase_name || 'Development',
|
phase: data.phase_name || 'Development',
|
||||||
progress: data.complete_ratio || 0,
|
progress: data.complete_ratio || 0,
|
||||||
assignees: data.assignees?.map(a => a.team_member_id) || [],
|
assignees: data.assignees?.map((a: any) => a.team_member_id) || [],
|
||||||
assignee_names: data.names || [],
|
assignee_names: data.names || [],
|
||||||
labels:
|
labels:
|
||||||
data.labels?.map(l => ({
|
data.labels?.map((l: any) => ({
|
||||||
id: l.id || '',
|
id: l.id || '',
|
||||||
name: l.name || '',
|
name: l.name || '',
|
||||||
color: l.color_code || '#1890ff',
|
color: l.color_code || '#1890ff',
|
||||||
@@ -666,14 +667,17 @@ export const useTaskSocketHandlers = () => {
|
|||||||
names: l.names,
|
names: l.names,
|
||||||
})) || [],
|
})) || [],
|
||||||
dueDate: data.end_date,
|
dueDate: data.end_date,
|
||||||
|
startDate: data.start_date,
|
||||||
timeTracking: {
|
timeTracking: {
|
||||||
estimated: (data.total_hours || 0) + (data.total_minutes || 0) / 60,
|
estimated: (data.total_hours || 0) + (data.total_minutes || 0) / 60,
|
||||||
logged: (data.time_spent?.hours || 0) + (data.time_spent?.minutes || 0) / 60,
|
logged: (data.time_spent?.hours || 0) + (data.time_spent?.minutes || 0) / 60,
|
||||||
},
|
},
|
||||||
customFields: {},
|
created_at: data.created_at || new Date().toISOString(),
|
||||||
createdAt: data.created_at || new Date().toISOString(),
|
updated_at: data.updated_at || new Date().toISOString(),
|
||||||
updatedAt: data.updated_at || new Date().toISOString(),
|
|
||||||
order: data.sort_order || 0,
|
order: data.sort_order || 0,
|
||||||
|
sub_tasks: [],
|
||||||
|
sub_tasks_count: 0,
|
||||||
|
show_sub_tasks: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Extract the group UUID from the backend response based on current grouping
|
// Extract the group UUID from the backend response based on current grouping
|
||||||
@@ -695,7 +699,7 @@ export const useTaskSocketHandlers = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Use addTaskToGroup with the actual group UUID
|
// Use addTaskToGroup with the actual group UUID
|
||||||
dispatch(addTaskToGroup({ task, groupId }));
|
dispatch(addTaskToGroup({ task, groupId: groupId || '' }));
|
||||||
|
|
||||||
// Also update enhanced kanban slice for regular task creation
|
// Also update enhanced kanban slice for regular task creation
|
||||||
dispatch(
|
dispatch(
|
||||||
@@ -733,6 +737,30 @@ export const useTaskSocketHandlers = () => {
|
|||||||
[dispatch, taskGroups]
|
[dispatch, taskGroups]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCustomColumnUpdate = useCallback(
|
||||||
|
(data: { task_id: string; column_key: string; value: string }) => {
|
||||||
|
if (!data || !data.task_id || !data.column_key) return;
|
||||||
|
|
||||||
|
// Update the task-management slice for task-list-v2 components
|
||||||
|
const currentTask = store.getState().taskManagement.entities[data.task_id];
|
||||||
|
if (currentTask) {
|
||||||
|
const updatedCustomColumnValues = {
|
||||||
|
...currentTask.custom_column_values,
|
||||||
|
[data.column_key]: data.value,
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedTask: Task = {
|
||||||
|
...currentTask,
|
||||||
|
custom_column_values: updatedCustomColumnValues,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
dispatch(updateTask(updatedTask));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
// Handler for TASK_ASSIGNEES_CHANGE (fallback event with limited data)
|
// Handler for TASK_ASSIGNEES_CHANGE (fallback event with limited data)
|
||||||
const handleTaskAssigneesChange = useCallback((data: { assigneeIds: string[] }) => {
|
const handleTaskAssigneesChange = useCallback((data: { assigneeIds: string[] }) => {
|
||||||
if (!data || !data.assigneeIds) return;
|
if (!data || !data.assigneeIds) return;
|
||||||
@@ -772,6 +800,7 @@ export const useTaskSocketHandlers = () => {
|
|||||||
},
|
},
|
||||||
{ event: SocketEvents.QUICK_TASK.toString(), handler: handleNewTaskReceived },
|
{ event: SocketEvents.QUICK_TASK.toString(), handler: handleNewTaskReceived },
|
||||||
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdated },
|
{ event: SocketEvents.TASK_PROGRESS_UPDATED.toString(), handler: handleTaskProgressUpdated },
|
||||||
|
{ event: SocketEvents.TASK_CUSTOM_COLUMN_UPDATE.toString(), handler: handleCustomColumnUpdate },
|
||||||
];
|
];
|
||||||
|
|
||||||
// Register all event listeners
|
// Register all event listeners
|
||||||
@@ -802,5 +831,6 @@ export const useTaskSocketHandlers = () => {
|
|||||||
handleTaskDescriptionChange,
|
handleTaskDescriptionChange,
|
||||||
handleNewTaskReceived,
|
handleNewTaskReceived,
|
||||||
handleTaskProgressUpdated,
|
handleTaskProgressUpdated,
|
||||||
|
handleCustomColumnUpdate,
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -146,3 +146,332 @@ Not supports in Firefox and IE */
|
|||||||
tr:hover .action-buttons {
|
tr:hover .action-buttons {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Custom column components z-index hierarchy */
|
||||||
|
.custom-column-cell {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-cell.focused {
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-dropdown {
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-selection-dropdown .ant-dropdown {
|
||||||
|
z-index: 1050 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure people dropdown has higher z-index */
|
||||||
|
.people-dropdown-portal {
|
||||||
|
z-index: 9999 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Number input focused state */
|
||||||
|
.number-input-container.focused {
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.number-input-container.focused input {
|
||||||
|
z-index: 21;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom column number input styles */
|
||||||
|
.custom-column-number-input {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-number-input .ant-input-group {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
display: flex !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-number-input .ant-input {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
padding: 2px 6px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-number-input.light-mode .ant-input {
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
color: #1f2937 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-number-input.light-mode .ant-input::placeholder {
|
||||||
|
color: #9ca3af !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-number-input.light-mode .ant-input:hover {
|
||||||
|
background-color: rgba(243, 244, 246, 0.5) !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-number-input.light-mode .ant-input:focus {
|
||||||
|
background-color: rgba(243, 244, 246, 0.8) !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-number-input.dark-mode .ant-input {
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
color: #e5e7eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-number-input.dark-mode .ant-input::placeholder {
|
||||||
|
color: #6b7280 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-number-input.dark-mode .ant-input:hover {
|
||||||
|
background-color: rgba(55, 65, 81, 0.3) !important;
|
||||||
|
border: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-number-input.dark-mode .ant-input:focus {
|
||||||
|
background-color: rgba(55, 65, 81, 0.5) !important;
|
||||||
|
border: none !important;
|
||||||
|
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Addon styles for light mode */
|
||||||
|
.custom-column-number-input.light-mode .ant-input-group-addon {
|
||||||
|
background-color: #f3f4f6 !important;
|
||||||
|
border: 1px solid #e5e7eb !important;
|
||||||
|
color: #6b7280 !important;
|
||||||
|
padding: 2px 6px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Addon styles for dark mode */
|
||||||
|
.custom-column-number-input.dark-mode .ant-input-group-addon {
|
||||||
|
background-color: #374151 !important;
|
||||||
|
border: 1px solid #4b5563 !important;
|
||||||
|
color: #9ca3af !important;
|
||||||
|
padding: 2px 6px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode styles for Ant Design components in custom columns */
|
||||||
|
[data-theme="dark"] .ant-picker,
|
||||||
|
[data-theme="dark"] .ant-picker-input > input,
|
||||||
|
.theme-dark .ant-picker,
|
||||||
|
.theme-dark .ant-picker-input > input {
|
||||||
|
background-color: transparent !important;
|
||||||
|
border-color: transparent !important;
|
||||||
|
color: #e5e7eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .ant-picker-input > input::placeholder,
|
||||||
|
.theme-dark .ant-picker-input > input::placeholder {
|
||||||
|
color: #6b7280 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .ant-picker:hover,
|
||||||
|
.theme-dark .ant-picker:hover {
|
||||||
|
border-color: transparent !important;
|
||||||
|
background-color: rgba(55, 65, 81, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .ant-picker-focused,
|
||||||
|
[data-theme="dark"] .ant-picker:focus,
|
||||||
|
.theme-dark .ant-picker-focused,
|
||||||
|
.theme-dark .ant-picker:focus {
|
||||||
|
border-color: rgba(59, 130, 246, 0.5) !important;
|
||||||
|
background-color: rgba(55, 65, 81, 0.5) !important;
|
||||||
|
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode dropdown styles */
|
||||||
|
[data-theme="dark"] .ant-dropdown,
|
||||||
|
.theme-dark .ant-dropdown {
|
||||||
|
background-color: #1f1f1f !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .ant-dropdown-menu,
|
||||||
|
.theme-dark .ant-dropdown-menu {
|
||||||
|
background-color: #1f1f1f !important;
|
||||||
|
border-color: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .ant-dropdown-menu-item,
|
||||||
|
.theme-dark .ant-dropdown-menu-item {
|
||||||
|
color: #e5e7eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .ant-dropdown-menu-item:hover,
|
||||||
|
.theme-dark .ant-dropdown-menu-item:hover {
|
||||||
|
background-color: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode date picker popup */
|
||||||
|
.dark-date-picker .ant-picker-panel,
|
||||||
|
.dark-date-picker .ant-picker-panel-container {
|
||||||
|
background-color: #1f1f1f !important;
|
||||||
|
border-color: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-date-picker .ant-picker-header {
|
||||||
|
background-color: #1f1f1f !important;
|
||||||
|
border-bottom-color: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-date-picker .ant-picker-header button {
|
||||||
|
color: #e5e7eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-date-picker .ant-picker-header button:hover {
|
||||||
|
color: #60a5fa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-date-picker .ant-picker-content {
|
||||||
|
background-color: #1f1f1f !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-date-picker .ant-picker-cell {
|
||||||
|
color: #e5e7eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-date-picker .ant-picker-cell:hover .ant-picker-cell-inner {
|
||||||
|
background-color: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-date-picker .ant-picker-cell-selected .ant-picker-cell-inner {
|
||||||
|
background-color: #3b82f6 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-date-picker .ant-picker-cell-today .ant-picker-cell-inner {
|
||||||
|
border-color: #60a5fa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-date-picker .ant-picker-footer {
|
||||||
|
background-color: #1f1f1f !important;
|
||||||
|
border-top-color: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-date-picker .ant-picker-footer .ant-btn {
|
||||||
|
color: #e5e7eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark-date-picker .ant-picker-footer .ant-btn:hover {
|
||||||
|
color: #60a5fa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global dark mode styles for date picker popups */
|
||||||
|
[data-theme="dark"] .ant-picker-dropdown .ant-picker-panel-container,
|
||||||
|
.theme-dark .ant-picker-dropdown .ant-picker-panel-container {
|
||||||
|
background-color: #1f1f1f !important;
|
||||||
|
border-color: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .ant-picker-dropdown .ant-picker-header,
|
||||||
|
.theme-dark .ant-picker-dropdown .ant-picker-header {
|
||||||
|
background-color: #1f1f1f !important;
|
||||||
|
border-bottom-color: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .ant-picker-dropdown .ant-picker-header button,
|
||||||
|
.theme-dark .ant-picker-dropdown .ant-picker-header button {
|
||||||
|
color: #e5e7eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .ant-picker-dropdown .ant-picker-header button:hover,
|
||||||
|
.theme-dark .ant-picker-dropdown .ant-picker-header button:hover {
|
||||||
|
color: #60a5fa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .ant-picker-dropdown .ant-picker-content,
|
||||||
|
.theme-dark .ant-picker-dropdown .ant-picker-content {
|
||||||
|
background-color: #1f1f1f !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .ant-picker-dropdown .ant-picker-cell,
|
||||||
|
.theme-dark .ant-picker-dropdown .ant-picker-cell {
|
||||||
|
color: #e5e7eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .ant-picker-dropdown .ant-picker-cell:hover .ant-picker-cell-inner,
|
||||||
|
.theme-dark .ant-picker-dropdown .ant-picker-cell:hover .ant-picker-cell-inner {
|
||||||
|
background-color: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .ant-picker-dropdown .ant-picker-cell-selected .ant-picker-cell-inner,
|
||||||
|
.theme-dark .ant-picker-dropdown .ant-picker-cell-selected .ant-picker-cell-inner {
|
||||||
|
background-color: #3b82f6 !important;
|
||||||
|
color: #ffffff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-theme="dark"] .ant-picker-dropdown .ant-picker-cell-today .ant-picker-cell-inner,
|
||||||
|
.theme-dark .ant-picker-dropdown .ant-picker-cell-today .ant-picker-cell-inner {
|
||||||
|
border-color: #60a5fa !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom column date picker styles */
|
||||||
|
.custom-column-date-picker.light-mode .ant-picker-input > input {
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
color: #1f2937 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-date-picker.light-mode .ant-picker-input > input::placeholder {
|
||||||
|
color: #9ca3af !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-date-picker.light-mode:hover {
|
||||||
|
background-color: rgba(243, 244, 246, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-date-picker.light-mode:focus,
|
||||||
|
.custom-column-date-picker.light-mode.ant-picker-focused {
|
||||||
|
background-color: rgba(243, 244, 246, 0.8) !important;
|
||||||
|
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-date-picker.dark-mode .ant-picker-input > input {
|
||||||
|
background-color: transparent !important;
|
||||||
|
border: none !important;
|
||||||
|
color: #e5e7eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-date-picker.dark-mode .ant-picker-input > input::placeholder {
|
||||||
|
color: #6b7280 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-date-picker.dark-mode:hover {
|
||||||
|
background-color: rgba(55, 65, 81, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-column-date-picker.dark-mode:focus,
|
||||||
|
.custom-column-date-picker.dark-mode.ant-picker-focused {
|
||||||
|
background-color: rgba(55, 65, 81, 0.5) !important;
|
||||||
|
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.5) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom column selection dropdown styles */
|
||||||
|
.custom-selection-dropdown [data-theme="dark"] .ant-dropdown-menu,
|
||||||
|
.custom-selection-dropdown .theme-dark .ant-dropdown-menu {
|
||||||
|
background-color: #1f1f1f !important;
|
||||||
|
border-color: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-selection-dropdown [data-theme="dark"] .ant-dropdown-menu-item,
|
||||||
|
.custom-selection-dropdown .theme-dark .ant-dropdown-menu-item {
|
||||||
|
color: #e5e7eb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.custom-selection-dropdown [data-theme="dark"] .ant-dropdown-menu-item:hover,
|
||||||
|
.custom-selection-dropdown .theme-dark .ant-dropdown-menu-item:hover {
|
||||||
|
background-color: #374151 !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,217 +5,26 @@
|
|||||||
width: 8px;
|
width: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Enhanced Project View Tab Styles - Compact */
|
/* Light mode - selected tab header bold */
|
||||||
.project-view-tabs {
|
[data-theme="light"] .project-view-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||||
margin-top: 16px;
|
font-weight: 700;
|
||||||
|
color: #000000 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Remove default tab border */
|
/* Dark mode - selected tab header bold and white */
|
||||||
.project-view-tabs .ant-tabs-nav::before {
|
[data-theme="dark"] .project-view-tabs .ant-tabs-tab-active .ant-tabs-tab-btn {
|
||||||
border: none !important;
|
font-weight: 900;
|
||||||
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tab bar container */
|
/* Light mode - selected tab underline black */
|
||||||
.project-view-tabs .ant-tabs-nav {
|
[data-theme="light"] .project-view-tabs .ant-tabs-ink-bar {
|
||||||
margin-bottom: 8px;
|
background-color: #000000 !important;
|
||||||
background: transparent;
|
|
||||||
padding: 0 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Individual tab styling - Compact */
|
/* Dark mode - selected tab underline white */
|
||||||
.project-view-tabs .ant-tabs-tab {
|
[data-theme="dark"] .project-view-tabs .ant-tabs-ink-bar {
|
||||||
position: relative;
|
background-color: #ffffff;
|
||||||
margin: 0 4px 0 0;
|
|
||||||
padding: 8px 16px;
|
|
||||||
border-radius: 6px 6px 0 0;
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
||||||
font-weight: 500;
|
|
||||||
font-size: 13px;
|
|
||||||
min-height: 36px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Light mode tab styles */
|
|
||||||
[data-theme="default"] .project-view-tabs .ant-tabs-tab {
|
|
||||||
color: #64748b;
|
|
||||||
background: #f8fafc;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="default"] .project-view-tabs .ant-tabs-tab:hover {
|
|
||||||
color: #3b82f6;
|
|
||||||
background: #eff6ff;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(59, 130, 246, 0.15);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="default"] .project-view-tabs .ant-tabs-tab-active {
|
|
||||||
color: #1e40af !important;
|
|
||||||
background: #ffffff !important;
|
|
||||||
box-shadow:
|
|
||||||
0 -2px 8px rgba(59, 130, 246, 0.1),
|
|
||||||
0 4px 16px rgba(59, 130, 246, 0.1);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Dark mode tab styles - matching task list row colors */
|
|
||||||
[data-theme="dark"] .project-view-tabs .ant-tabs-tab {
|
|
||||||
color: #94a3b8;
|
|
||||||
background: #141414;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .project-view-tabs .ant-tabs-tab:hover {
|
|
||||||
color: #60a5fa;
|
|
||||||
background: #262626;
|
|
||||||
transform: translateY(-1px);
|
|
||||||
box-shadow: 0 4px 12px rgba(96, 165, 250, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .project-view-tabs .ant-tabs-tab-active {
|
|
||||||
color: #60a5fa !important;
|
|
||||||
background: #1f1f1f !important;
|
|
||||||
box-shadow:
|
|
||||||
0 -2px 8px rgba(96, 165, 250, 0.15),
|
|
||||||
0 4px 16px rgba(96, 165, 250, 0.15);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tab content area - Compact */
|
|
||||||
.project-view-tabs .ant-tabs-content-holder {
|
|
||||||
background: transparent;
|
|
||||||
border-radius: 6px;
|
|
||||||
position: relative;
|
|
||||||
z-index: 0;
|
|
||||||
margin-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="default"] .project-view-tabs .ant-tabs-content-holder {
|
|
||||||
background: #ffffff;
|
|
||||||
border: 1px solid #e2e8f0;
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .project-view-tabs .ant-tabs-content-holder {
|
|
||||||
background: #1f1f1f;
|
|
||||||
border: 1px solid #303030;
|
|
||||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-view-tabs .ant-tabs-tabpane {
|
|
||||||
padding: 0;
|
|
||||||
min-height: 300px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pin button styling - Compact */
|
|
||||||
.project-view-tabs .borderless-icon-btn {
|
|
||||||
margin-left: 6px;
|
|
||||||
padding: 2px;
|
|
||||||
border-radius: 3px;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-view-tabs .borderless-icon-btn:hover {
|
|
||||||
opacity: 1;
|
|
||||||
transform: scale(1.05);
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="default"] .project-view-tabs .borderless-icon-btn:hover {
|
|
||||||
background: rgba(59, 130, 246, 0.1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .project-view-tabs .borderless-icon-btn:hover {
|
|
||||||
background: rgba(96, 165, 250, 0.1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Pinned tab indicator */
|
|
||||||
.project-view-tabs .ant-tabs-tab-active .borderless-icon-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="default"] .project-view-tabs .ant-tabs-tab-active .borderless-icon-btn {
|
|
||||||
background: rgba(59, 130, 246, 0.1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .project-view-tabs .ant-tabs-tab-active .borderless-icon-btn {
|
|
||||||
background: rgba(96, 165, 250, 0.1) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Tab label flex container */
|
|
||||||
.project-view-tabs .ant-tabs-tab .ant-tabs-tab-btn {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Responsive adjustments - Compact */
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.project-view-tabs .ant-tabs-nav {
|
|
||||||
padding: 0 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-view-tabs .ant-tabs-tab {
|
|
||||||
margin: 0 2px 0 0;
|
|
||||||
padding: 6px 12px;
|
|
||||||
font-size: 12px;
|
|
||||||
min-height: 32px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-view-tabs .borderless-icon-btn {
|
|
||||||
margin-left: 4px;
|
|
||||||
padding: 1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 480px) {
|
|
||||||
.project-view-tabs .ant-tabs-tab {
|
|
||||||
padding: 6px 10px;
|
|
||||||
font-size: 11px;
|
|
||||||
min-height: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-view-tabs .borderless-icon-btn {
|
|
||||||
display: none; /* Hide pin buttons on very small screens */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Animation for tab switching */
|
|
||||||
.project-view-tabs .ant-tabs-content {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.project-view-tabs .ant-tabs-tabpane-active {
|
|
||||||
animation: fadeInUp 0.3s ease-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes fadeInUp {
|
|
||||||
from {
|
|
||||||
opacity: 0;
|
|
||||||
transform: translateY(10px);
|
|
||||||
}
|
|
||||||
to {
|
|
||||||
opacity: 1;
|
|
||||||
transform: translateY(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Focus states for accessibility - Compact */
|
|
||||||
.project-view-tabs .ant-tabs-tab:focus-visible {
|
|
||||||
outline: 1px solid #3b82f6;
|
|
||||||
outline-offset: 1px;
|
|
||||||
border-radius: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-theme="dark"] .project-view-tabs .ant-tabs-tab:focus-visible {
|
|
||||||
outline-color: #60a5fa;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Loading state for tab content */
|
|
||||||
.project-view-tabs .ant-tabs-tabpane .suspense-fallback {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
min-height: 200px;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -351,20 +351,12 @@ const ProjectView = React.memo(() => {
|
|||||||
activeKey={activeTab}
|
activeKey={activeTab}
|
||||||
onChange={handleTabChange}
|
onChange={handleTabChange}
|
||||||
items={tabMenuItems}
|
items={tabMenuItems}
|
||||||
tabBarStyle={{
|
destroyOnHidden={true}
|
||||||
paddingInline: 0,
|
|
||||||
marginBottom: 8,
|
|
||||||
background: 'transparent',
|
|
||||||
minHeight: '36px',
|
|
||||||
}}
|
|
||||||
tabBarGutter={0}
|
|
||||||
destroyInactiveTabPane={true}
|
|
||||||
animated={{
|
animated={{
|
||||||
inkBar: true,
|
inkBar: true,
|
||||||
tabPane: false,
|
tabPane: false,
|
||||||
}}
|
}}
|
||||||
size="small"
|
size="small"
|
||||||
type="card"
|
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{portalElements}
|
{portalElements}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
Popconfirm,
|
Popconfirm,
|
||||||
} from 'antd';
|
} from 'antd';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import SelectionTypeColumn from './selection-type-column/selection-type-column';
|
import SelectionTypeColumn from './selection-type-column/selection-type-column';
|
||||||
import NumberTypeColumn from './number-type-column/number-type-column';
|
import NumberTypeColumn from './number-type-column/number-type-column';
|
||||||
import LabelTypeColumn from './label-type-column/label-type-column';
|
import LabelTypeColumn from './label-type-column/label-type-column';
|
||||||
@@ -31,6 +32,7 @@ import {
|
|||||||
setSecondNumericColumn,
|
setSecondNumericColumn,
|
||||||
setSelectionsList,
|
setSelectionsList,
|
||||||
setLabelsList,
|
setLabelsList,
|
||||||
|
resetCustomFieldValues,
|
||||||
} from '@features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
} from '@features/projects/singleProject/task-list-custom-columns/task-list-custom-columns-slice';
|
||||||
import CustomColumnHeader from '../custom-column-header/custom-column-header';
|
import CustomColumnHeader from '../custom-column-header/custom-column-header';
|
||||||
import { nanoid } from '@reduxjs/toolkit';
|
import { nanoid } from '@reduxjs/toolkit';
|
||||||
@@ -42,9 +44,11 @@ import { themeWiseColor } from '@/utils/themeWiseColor';
|
|||||||
import KeyTypeColumn from './key-type-column/key-type-column';
|
import KeyTypeColumn from './key-type-column/key-type-column';
|
||||||
import logger from '@/utils/errorLogger';
|
import logger from '@/utils/errorLogger';
|
||||||
import {
|
import {
|
||||||
|
fetchTasksV3,
|
||||||
|
fetchTaskListColumns,
|
||||||
addCustomColumn,
|
addCustomColumn,
|
||||||
deleteCustomColumn as deleteCustomColumnFromTasks,
|
deleteCustomColumn as deleteCustomColumnFromTaskManagement,
|
||||||
} from '@/features/tasks/tasks.slice';
|
} from '@/features/task-management/task-management.slice';
|
||||||
import { useParams } from 'react-router-dom';
|
import { useParams } from 'react-router-dom';
|
||||||
import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service';
|
import { tasksCustomColumnsService } from '@/api/tasks/tasks-custom-columns.service';
|
||||||
import { ExclamationCircleFilled } from '@ant-design/icons';
|
import { ExclamationCircleFilled } from '@ant-design/icons';
|
||||||
@@ -52,6 +56,7 @@ import { ExclamationCircleFilled } from '@ant-design/icons';
|
|||||||
const CustomColumnModal = () => {
|
const CustomColumnModal = () => {
|
||||||
const [mainForm] = Form.useForm();
|
const [mainForm] = Form.useForm();
|
||||||
const { projectId } = useParams();
|
const { projectId } = useParams();
|
||||||
|
const { t } = useTranslation('task-list-table');
|
||||||
|
|
||||||
// get theme details from theme reducer
|
// get theme details from theme reducer
|
||||||
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
const themeMode = useAppSelector(state => state.themeReducer.mode);
|
||||||
@@ -62,6 +67,7 @@ const CustomColumnModal = () => {
|
|||||||
customColumnId,
|
customColumnId,
|
||||||
customColumnModalType,
|
customColumnModalType,
|
||||||
isCustomColumnModalOpen,
|
isCustomColumnModalOpen,
|
||||||
|
currentColumnData,
|
||||||
decimals,
|
decimals,
|
||||||
label,
|
label,
|
||||||
labelPosition,
|
labelPosition,
|
||||||
@@ -82,35 +88,84 @@ const CustomColumnModal = () => {
|
|||||||
state => state.taskListCustomColumnsReducer.customFieldNumberType
|
state => state.taskListCustomColumnsReducer.customFieldNumberType
|
||||||
);
|
);
|
||||||
|
|
||||||
// if it is already created column get the column data
|
// Use the column data passed from TaskListV2
|
||||||
const openedColumn = useAppSelector(state => state.taskReducer.customColumns).find(
|
const openedColumn = currentColumnData;
|
||||||
col => col.id === customColumnId
|
|
||||||
);
|
// Debug logging
|
||||||
|
console.log('Modal Debug Info:', {
|
||||||
|
customColumnId,
|
||||||
|
customColumnModalType,
|
||||||
|
currentColumnData,
|
||||||
|
openedColumn,
|
||||||
|
openedColumnFound: !!openedColumn,
|
||||||
|
openedColumnId: openedColumn?.id
|
||||||
|
});
|
||||||
|
|
||||||
|
// Function to reset all form and Redux state
|
||||||
|
const resetModalData = () => {
|
||||||
|
mainForm.resetFields();
|
||||||
|
dispatch(resetCustomFieldValues());
|
||||||
|
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
|
||||||
|
};
|
||||||
|
|
||||||
// Function to handle deleting a custom column
|
// Function to handle deleting a custom column
|
||||||
const handleDeleteColumn = async () => {
|
const handleDeleteColumn = async () => {
|
||||||
if (!customColumnId) return;
|
console.log('Delete function called with:', {
|
||||||
|
customColumnId,
|
||||||
|
openedColumn,
|
||||||
|
openedColumnId: openedColumn?.id,
|
||||||
|
openedColumnKey: openedColumn?.key,
|
||||||
|
fullColumnData: openedColumn
|
||||||
|
});
|
||||||
|
|
||||||
|
// Try to get UUID from different possible locations in the column data
|
||||||
|
const columnUUID = openedColumn?.id ||
|
||||||
|
openedColumn?.uuid ||
|
||||||
|
openedColumn?.custom_column_obj?.id ||
|
||||||
|
openedColumn?.custom_column_obj?.uuid;
|
||||||
|
|
||||||
|
console.log('Extracted UUID candidates:', {
|
||||||
|
'openedColumn?.id': openedColumn?.id,
|
||||||
|
'openedColumn?.uuid': openedColumn?.uuid,
|
||||||
|
'openedColumn?.custom_column_obj?.id': openedColumn?.custom_column_obj?.id,
|
||||||
|
'openedColumn?.custom_column_obj?.uuid': openedColumn?.custom_column_obj?.uuid,
|
||||||
|
'finalColumnUUID': columnUUID
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!customColumnId || !columnUUID) {
|
||||||
|
console.error('Missing required data for deletion:', {
|
||||||
|
customColumnId,
|
||||||
|
columnUUID,
|
||||||
|
openedColumn
|
||||||
|
});
|
||||||
|
message.error('Cannot delete column: Missing UUID');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
console.log('Attempting to delete column with UUID:', columnUUID);
|
||||||
// Make API request to delete the custom column using the service
|
// Make API request to delete the custom column using the service
|
||||||
await tasksCustomColumnsService.deleteCustomColumn(openedColumn?.id || customColumnId);
|
await tasksCustomColumnsService.deleteCustomColumn(columnUUID);
|
||||||
|
|
||||||
// Dispatch actions to update the Redux store
|
// Dispatch actions to update the Redux store
|
||||||
dispatch(deleteCustomColumnFromTasks(customColumnId));
|
dispatch(deleteCustomColumnFromTaskManagement(customColumnId));
|
||||||
dispatch(deleteCustomColumnFromColumns(customColumnId));
|
dispatch(deleteCustomColumnFromColumns(customColumnId));
|
||||||
|
|
||||||
// Close the modal
|
// Close the modal and reset data
|
||||||
dispatch(toggleCustomColumnModalOpen(false));
|
dispatch(toggleCustomColumnModalOpen(false));
|
||||||
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
|
resetModalData();
|
||||||
|
|
||||||
// Show success message
|
// Show success message
|
||||||
message.success('Custom column deleted successfully');
|
message.success(t('customColumns.modal.deleteSuccessMessage'));
|
||||||
|
|
||||||
// Reload the page to reflect the changes
|
// Refresh tasks and columns to reflect the deleted custom column
|
||||||
window.location.reload();
|
if (projectId) {
|
||||||
|
dispatch(fetchTaskListColumns(projectId));
|
||||||
|
dispatch(fetchTasksV3(projectId));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error deleting custom column:', error);
|
logger.error('Error deleting custom column:', error);
|
||||||
message.error('Failed to delete custom column');
|
message.error(t('customColumns.modal.deleteErrorMessage'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -118,49 +173,49 @@ const CustomColumnModal = () => {
|
|||||||
{
|
{
|
||||||
key: 'people',
|
key: 'people',
|
||||||
value: 'people',
|
value: 'people',
|
||||||
label: 'People',
|
label: t('customColumns.fieldTypes.people'),
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'number',
|
key: 'number',
|
||||||
value: 'number',
|
value: 'number',
|
||||||
label: 'Number',
|
label: t('customColumns.fieldTypes.number'),
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'date',
|
key: 'date',
|
||||||
value: 'date',
|
value: 'date',
|
||||||
label: 'Date',
|
label: t('customColumns.fieldTypes.date'),
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'selection',
|
key: 'selection',
|
||||||
value: 'selection',
|
value: 'selection',
|
||||||
label: 'Selection',
|
label: t('customColumns.fieldTypes.selection'),
|
||||||
disabled: false,
|
disabled: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'checkbox',
|
key: 'checkbox',
|
||||||
value: 'checkbox',
|
value: 'checkbox',
|
||||||
label: 'Checkbox',
|
label: t('customColumns.fieldTypes.checkbox'),
|
||||||
disabled: true,
|
disabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'labels',
|
key: 'labels',
|
||||||
value: 'labels',
|
value: 'labels',
|
||||||
label: 'Labels',
|
label: t('customColumns.fieldTypes.labels'),
|
||||||
disabled: true,
|
disabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'key',
|
key: 'key',
|
||||||
value: 'key',
|
value: 'key',
|
||||||
label: 'Key',
|
label: t('customColumns.fieldTypes.key'),
|
||||||
disabled: true,
|
disabled: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'formula',
|
key: 'formula',
|
||||||
value: 'formula',
|
value: 'formula',
|
||||||
label: 'Formula',
|
label: t('customColumns.fieldTypes.formula'),
|
||||||
disabled: true,
|
disabled: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -231,12 +286,21 @@ const CustomColumnModal = () => {
|
|||||||
if (res.done) {
|
if (res.done) {
|
||||||
if (res.body.id) newColumn.id = res.body.id;
|
if (res.body.id) newColumn.id = res.body.id;
|
||||||
dispatch(addCustomColumn(newColumn));
|
dispatch(addCustomColumn(newColumn));
|
||||||
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
|
|
||||||
dispatch(toggleCustomColumnModalOpen(false));
|
dispatch(toggleCustomColumnModalOpen(false));
|
||||||
|
resetModalData();
|
||||||
|
|
||||||
|
// Show success message
|
||||||
|
message.success(t('customColumns.modal.createSuccessMessage'));
|
||||||
|
|
||||||
|
// Refresh tasks and columns to include the new custom column values
|
||||||
|
if (projectId) {
|
||||||
|
dispatch(fetchTaskListColumns(projectId));
|
||||||
|
dispatch(fetchTasksV3(projectId));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error creating custom column:', error);
|
logger.error('Error creating custom column:', error);
|
||||||
message.error('Failed to create custom column');
|
message.error(t('customColumns.modal.createErrorMessage'));
|
||||||
}
|
}
|
||||||
} else if (customColumnModalType === 'edit' && customColumnId) {
|
} else if (customColumnModalType === 'edit' && customColumnId) {
|
||||||
const updatedColumn = openedColumn
|
const updatedColumn = openedColumn
|
||||||
@@ -264,7 +328,7 @@ const CustomColumnModal = () => {
|
|||||||
}
|
}
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (updatedColumn) {
|
if (updatedColumn && openedColumn?.id) {
|
||||||
try {
|
try {
|
||||||
// Prepare the configuration object
|
// Prepare the configuration object
|
||||||
const configuration = {
|
const configuration = {
|
||||||
@@ -299,7 +363,7 @@ const CustomColumnModal = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Make API request to update custom column using the service
|
// Make API request to update custom column using the service
|
||||||
await tasksCustomColumnsService.updateCustomColumn(openedColumn?.id || customColumnId, {
|
await tasksCustomColumnsService.updateCustomColumn(openedColumn.id, {
|
||||||
name: value.fieldTitle,
|
name: value.fieldTitle,
|
||||||
field_type: value.fieldType,
|
field_type: value.fieldType,
|
||||||
width: 150,
|
width: 150,
|
||||||
@@ -307,15 +371,21 @@ const CustomColumnModal = () => {
|
|||||||
configuration,
|
configuration,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close modal
|
// Close modal and reset data
|
||||||
dispatch(toggleCustomColumnModalOpen(false));
|
dispatch(toggleCustomColumnModalOpen(false));
|
||||||
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
|
resetModalData();
|
||||||
|
|
||||||
// Reload the page instead of updating the slice
|
// Show success message
|
||||||
window.location.reload();
|
message.success(t('customColumns.modal.updateSuccessMessage'));
|
||||||
|
|
||||||
|
// Refresh tasks and columns to reflect the updated custom column
|
||||||
|
if (projectId) {
|
||||||
|
dispatch(fetchTaskListColumns(projectId));
|
||||||
|
dispatch(fetchTasksV3(projectId));
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error updating custom column:', error);
|
logger.error('Error updating custom column:', error);
|
||||||
message.error('Failed to update custom column');
|
message.error(t('customColumns.modal.updateErrorMessage'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -328,20 +398,17 @@ const CustomColumnModal = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={customColumnModalType === 'create' ? 'Add field' : 'Edit field'}
|
title={customColumnModalType === 'create' ? t('customColumns.modal.addFieldTitle') : t('customColumns.modal.editFieldTitle')}
|
||||||
centered
|
centered
|
||||||
open={isCustomColumnModalOpen}
|
open={isCustomColumnModalOpen}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
dispatch(toggleCustomColumnModalOpen(false));
|
dispatch(toggleCustomColumnModalOpen(false));
|
||||||
dispatch(setCustomColumnModalAttributes({ modalType: 'create', columnId: null }));
|
resetModalData();
|
||||||
}}
|
}}
|
||||||
styles={{
|
styles={{
|
||||||
header: { position: 'relative' },
|
header: { position: 'relative' },
|
||||||
footer: { display: 'none' },
|
footer: { display: 'none' },
|
||||||
}}
|
}}
|
||||||
onClose={() => {
|
|
||||||
mainForm.resetFields();
|
|
||||||
}}
|
|
||||||
afterOpenChange={open => {
|
afterOpenChange={open => {
|
||||||
if (open && customColumnModalType === 'edit' && openedColumn) {
|
if (open && customColumnModalType === 'edit' && openedColumn) {
|
||||||
// Set the field type first so the correct form fields are displayed
|
// Set the field type first so the correct form fields are displayed
|
||||||
@@ -394,9 +461,11 @@ const CustomColumnModal = () => {
|
|||||||
secondNumericColumn: openedColumn.custom_column_obj?.secondNumericColumn,
|
secondNumericColumn: openedColumn.custom_column_obj?.secondNumericColumn,
|
||||||
});
|
});
|
||||||
} else if (open && customColumnModalType === 'create') {
|
} else if (open && customColumnModalType === 'create') {
|
||||||
// Reset form for create mode
|
// Reset all data for create mode
|
||||||
mainForm.resetFields();
|
resetModalData();
|
||||||
dispatch(setCustomFieldType('people'));
|
} else if (!open) {
|
||||||
|
// Reset data when modal closes
|
||||||
|
resetModalData();
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -437,22 +506,22 @@ const CustomColumnModal = () => {
|
|||||||
<Flex gap={16} align="center" justify="space-between">
|
<Flex gap={16} align="center" justify="space-between">
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name={'fieldTitle'}
|
name={'fieldTitle'}
|
||||||
label={<Typography.Text>Field title</Typography.Text>}
|
label={<Typography.Text>{t('customColumns.modal.fieldTitle')}</Typography.Text>}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
rules={[
|
rules={[
|
||||||
{
|
{
|
||||||
required: true,
|
required: true,
|
||||||
message: 'Field title is required',
|
message: t('customColumns.modal.fieldTitleRequired'),
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
required={false}
|
required={false}
|
||||||
>
|
>
|
||||||
<Input placeholder="title" style={{ minWidth: '100%', width: 300 }} />
|
<Input placeholder={t('customColumns.modal.columnTitlePlaceholder')} style={{ minWidth: '100%', width: 300 }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
|
|
||||||
<Form.Item
|
<Form.Item
|
||||||
name={'fieldType'}
|
name={'fieldType'}
|
||||||
label={<Typography.Text>Type</Typography.Text>}
|
label={<Typography.Text>{t('customColumns.modal.type')}</Typography.Text>}
|
||||||
layout="vertical"
|
layout="vertical"
|
||||||
>
|
>
|
||||||
<Select
|
<Select
|
||||||
@@ -485,27 +554,30 @@ const CustomColumnModal = () => {
|
|||||||
>
|
>
|
||||||
{customColumnModalType === 'edit' && customColumnId && (
|
{customColumnModalType === 'edit' && customColumnId && (
|
||||||
<Popconfirm
|
<Popconfirm
|
||||||
title="Are you sure you want to delete this custom column?"
|
title={t('customColumns.modal.deleteConfirmTitle')}
|
||||||
description="This action cannot be undone. All data associated with this column will be permanently deleted."
|
description={t('customColumns.modal.deleteConfirmDescription')}
|
||||||
icon={<ExclamationCircleFilled style={{ color: 'red' }} />}
|
icon={<ExclamationCircleFilled style={{ color: 'red' }} />}
|
||||||
onConfirm={handleDeleteColumn}
|
onConfirm={handleDeleteColumn}
|
||||||
okText="Delete"
|
okText={t('customColumns.modal.deleteButton')}
|
||||||
cancelText="Cancel"
|
cancelText={t('customColumns.modal.cancelButton')}
|
||||||
okButtonProps={{ danger: true }}
|
okButtonProps={{ danger: true }}
|
||||||
>
|
>
|
||||||
<Button danger>Delete</Button>
|
<Button danger>{t('customColumns.modal.deleteButton')}</Button>
|
||||||
</Popconfirm>
|
</Popconfirm>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Flex gap={8}>
|
<Flex gap={8}>
|
||||||
<Button onClick={() => dispatch(toggleCustomColumnModalOpen(false))}>Cancel</Button>
|
<Button onClick={() => {
|
||||||
|
dispatch(toggleCustomColumnModalOpen(false));
|
||||||
|
resetModalData();
|
||||||
|
}}>{t('customColumns.modal.cancelButton')}</Button>
|
||||||
{customColumnModalType === 'create' ? (
|
{customColumnModalType === 'create' ? (
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
Create
|
{t('customColumns.modal.createButton')}
|
||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button type="primary" htmlType="submit">
|
<Button type="primary" htmlType="submit">
|
||||||
Update
|
{t('customColumns.modal.updateButton')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Flex>
|
</Flex>
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export {
|
|||||||
RetweetOutlined,
|
RetweetOutlined,
|
||||||
DoubleRightOutlined,
|
DoubleRightOutlined,
|
||||||
UserAddOutlined,
|
UserAddOutlined,
|
||||||
|
ArrowsAltOutlined,
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
|
||||||
// Re-export all components with React
|
// Re-export all components with React
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { InlineMember } from './teamMembers/inlineMember.types';
|
import { InlineMember } from './teamMembers/inlineMember.types';
|
||||||
import { EntityState } from '@reduxjs/toolkit';
|
import { EntityState } from '@reduxjs/toolkit';
|
||||||
|
import { ITaskListColumn } from './tasks/taskList.types';
|
||||||
|
|
||||||
export interface Task {
|
export interface Task {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -25,12 +26,13 @@ export interface Task {
|
|||||||
sub_tasks_count?: number;
|
sub_tasks_count?: number;
|
||||||
show_sub_tasks?: boolean;
|
show_sub_tasks?: boolean;
|
||||||
parent_task_id?: string;
|
parent_task_id?: string;
|
||||||
|
is_sub_task?: boolean; // Add this property
|
||||||
progress?: number;
|
progress?: number;
|
||||||
weight?: number;
|
weight?: number;
|
||||||
color?: string;
|
color?: string;
|
||||||
statusColor?: string;
|
statusColor?: string;
|
||||||
priorityColor?: string;
|
priorityColor?: string;
|
||||||
labels?: { id: string; name: string; color: string }[];
|
labels?: { id: string; name: string; color: string; end?: boolean; names?: string[] }[];
|
||||||
comments_count?: number;
|
comments_count?: number;
|
||||||
attachments_count?: number;
|
attachments_count?: number;
|
||||||
has_dependencies?: boolean;
|
has_dependencies?: boolean;
|
||||||
@@ -41,6 +43,8 @@ export interface Task {
|
|||||||
logged?: number;
|
logged?: number;
|
||||||
estimated?: number;
|
estimated?: number;
|
||||||
};
|
};
|
||||||
|
custom_column_values?: Record<string, any>; // Custom column values
|
||||||
|
isTemporary?: boolean; // Temporary task indicator
|
||||||
// Add any other task properties as needed
|
// Add any other task properties as needed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,7 +100,11 @@ export interface TaskManagementState {
|
|||||||
grouping: string | undefined;
|
grouping: string | undefined;
|
||||||
selectedPriorities: string[];
|
selectedPriorities: string[];
|
||||||
search: string;
|
search: string;
|
||||||
|
archived: boolean;
|
||||||
loadingSubtasks: Record<string, boolean>; // Track loading state for individual tasks
|
loadingSubtasks: Record<string, boolean>; // Track loading state for individual tasks
|
||||||
|
loadingColumns: boolean;
|
||||||
|
columns: ITaskListColumn[];
|
||||||
|
customColumns: ITaskListColumn[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TaskGroupsState {
|
export interface TaskGroupsState {
|
||||||
|
|||||||
Reference in New Issue
Block a user