diff --git a/worklenz-backend/src/controllers/projects-controller.ts b/worklenz-backend/src/controllers/projects-controller.ts index a350675e..da204832 100644 --- a/worklenz-backend/src/controllers/projects-controller.ts +++ b/worklenz-backend/src/controllers/projects-controller.ts @@ -71,7 +71,7 @@ export default class ProjectsController extends WorklenzControllerBase { return res.status(200).send(new ServerResponse(false, [], `Sorry, the free plan cannot have more than ${projectsLimit} projects.`)); } } - + const q = `SELECT create_project($1) AS project`; req.body.team_id = req.user?.team_id || null; @@ -317,65 +317,58 @@ export default class ProjectsController extends WorklenzControllerBase { @HandleExceptions() public static async getMembersByProjectId(req: IWorkLenzRequest, res: IWorkLenzResponse): Promise { const {sortField, sortOrder, size, offset} = this.toPaginationOptions(req.query, "name"); + const search = (req.query.search || "").toString().trim(); + + let searchFilter = ""; + const params = [req.params.id, req.user?.team_id ?? null, size, offset]; + if (search) { + searchFilter = ` + AND ( + (SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%' + OR (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) ILIKE '%' || $5 || '%' + ) + `; + params.push(search); + } const q = ` - SELECT ROW_TO_JSON(rec) AS members - FROM (SELECT COUNT(*) AS total, - (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) - FROM (SELECT project_members.id, - team_member_id, - (SELECT name - FROM team_member_info_view - WHERE team_member_info_view.team_member_id = tm.id), - (SELECT email - FROM team_member_info_view - WHERE team_member_info_view.team_member_id = tm.id) AS email, - u.avatar_url, - (SELECT COUNT(*) - FROM tasks - WHERE archived IS FALSE - AND project_id = project_members.project_id - AND id IN (SELECT task_id - FROM tasks_assignees - WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count, - (SELECT COUNT(*) - FROM tasks - WHERE archived IS FALSE - AND project_id = project_members.project_id - AND id IN (SELECT task_id - FROM tasks_assignees - WHERE tasks_assignees.project_member_id = project_members.id) - AND status_id IN (SELECT id - FROM task_statuses - WHERE category_id = (SELECT id - FROM sys_task_status_categories - WHERE is_done IS TRUE))) AS completed_tasks_count, - EXISTS(SELECT email - FROM email_invitations - WHERE team_member_id = project_members.team_member_id - AND email_invitations.team_id = $2) AS pending_invitation, - (SELECT project_access_levels.name - FROM project_access_levels - WHERE project_access_levels.id = project_members.project_access_level_id) AS access, - (SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title - FROM project_members - INNER JOIN team_members tm ON project_members.team_member_id = tm.id - LEFT JOIN users u ON tm.user_id = u.id - WHERE project_id = $1 - ORDER BY ${sortField} ${sortOrder} - LIMIT $3 OFFSET $4) t) AS data - FROM project_members - WHERE project_id = $1) rec; + WITH filtered_members AS ( + SELECT project_members.id, + team_member_id, + (SELECT name FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS name, + (SELECT email FROM team_member_info_view WHERE team_member_info_view.team_member_id = tm.id) AS email, + u.avatar_url, + (SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id)) AS all_tasks_count, + (SELECT COUNT(*) FROM tasks WHERE archived IS FALSE AND project_id = project_members.project_id AND id IN (SELECT task_id FROM tasks_assignees WHERE tasks_assignees.project_member_id = project_members.id) AND status_id IN (SELECT id FROM task_statuses WHERE category_id = (SELECT id FROM sys_task_status_categories WHERE is_done IS TRUE))) AS completed_tasks_count, + EXISTS(SELECT email FROM email_invitations WHERE team_member_id = project_members.team_member_id AND email_invitations.team_id = $2) AS pending_invitation, + (SELECT project_access_levels.name FROM project_access_levels WHERE project_access_levels.id = project_members.project_access_level_id) AS access, + (SELECT name FROM job_titles WHERE id = tm.job_title_id) AS job_title + FROM project_members + INNER JOIN team_members tm ON project_members.team_member_id = tm.id + LEFT JOIN users u ON tm.user_id = u.id + WHERE project_id = $1 + ${search ? searchFilter : ""} + ) + SELECT + (SELECT COUNT(*) FROM filtered_members) AS total, + (SELECT COALESCE(ARRAY_TO_JSON(ARRAY_AGG(ROW_TO_JSON(t))), '[]'::JSON) + FROM ( + SELECT * FROM filtered_members + ORDER BY ${sortField} ${sortOrder} + LIMIT $3 OFFSET $4 + ) t + ) AS data `; - const result = await db.query(q, [req.params.id, req.user?.team_id ?? null, size, offset]); + + const result = await db.query(q, params); const [data] = result.rows; - for (const member of data?.members.data || []) { + for (const member of data?.data || []) { member.progress = member.all_tasks_count > 0 ? ((member.completed_tasks_count / member.all_tasks_count) * 100).toFixed(0) : 0; } - return res.status(200).send(new ServerResponse(true, data?.members || this.paginatedDatasetDefaultStruct)); + return res.status(200).send(new ServerResponse(true, data || this.paginatedDatasetDefaultStruct)); } @HandleExceptions() @@ -779,7 +772,7 @@ export default class ProjectsController extends WorklenzControllerBase { let groupJoin = ""; let groupByFields = ""; let groupOrderBy = ""; - + switch (groupBy) { case "client": groupField = "COALESCE(projects.client_id::text, 'no-client')"; @@ -888,13 +881,13 @@ export default class ProjectsController extends WorklenzControllerBase { ELSE p2.updated_at END) AS updated_at FROM projects p2 ${groupJoin.replace("projects.", "p2.")} - WHERE p2.team_id = $1 + WHERE p2.team_id = $1 AND ${groupField.replace("projects.", "p2.")} = ${groupField} - ${categories.replace("projects.", "p2.")} - ${statuses.replace("projects.", "p2.")} - ${isArchived.replace("projects.", "p2.")} - ${isFavorites.replace("projects.", "p2.")} - ${filterByMember.replace("projects.", "p2.")} + ${categories.replace("projects.", "p2.")} + ${statuses.replace("projects.", "p2.")} + ${isArchived.replace("projects.", "p2.")} + ${isFavorites.replace("projects.", "p2.")} + ${filterByMember.replace("projects.", "p2.")} ${searchQuery.replace("projects.", "p2.")} ORDER BY ${innerSortField} ${sortOrder} ) project_data diff --git a/worklenz-frontend/public/locales/alb/kanban-board.json b/worklenz-frontend/public/locales/alb/kanban-board.json index def705aa..50835235 100644 --- a/worklenz-frontend/public/locales/alb/kanban-board.json +++ b/worklenz-frontend/public/locales/alb/kanban-board.json @@ -10,6 +10,17 @@ "deleteConfirmationOk": "Po", "deleteConfirmationCancel": "Anulo", + "deleteTaskTitle": "Fshi Detyrën", + "deleteTaskContent": "Jeni i sigurt që doni të fshini këtë detyrë? Kjo veprim nuk mund të zhbëhet.", + "deleteTaskConfirm": "Fshi", + "deleteTaskCancel": "Anulo", + + "deleteStatusTitle": "Fshi Statusin", + "deleteStatusContent": "Jeni i sigurt që doni të fshini këtë status? Kjo veprim nuk mund të zhbëhet.", + + "deletePhaseTitle": "Fshi Fazen", + "deletePhaseContent": "Jeni i sigurt që doni të fshini këtë fazë? Kjo veprim nuk mund të zhbëhet.", + "dueDate": "Data e përfundimit", "cancel": "Anulo", @@ -26,5 +37,17 @@ "noDueDate": "Pa datë përfundimi", "save": "Ruaj", "clear": "Pastro", - "nextWeek": "Javën e ardhshme" + "nextWeek": "Javën e ardhshme", + "noSubtasks": "Pa nëndetyra", + "showSubtasks": "Shfaq nëndetyrat", + "hideSubtasks": "Fshih nëndetyrat", + + "errorLoadingTasks": "Gabim gjatë ngarkimit të detyrave", + "noTasksFound": "Nuk u gjetën detyra", + "loadingFilters": "Duke ngarkuar filtra...", + "failedToUpdateColumnOrder": "Dështoi përditësimi i rendit të kolonave", + "failedToUpdatePhaseOrder": "Dështoi përditësimi i rendit të fazave", + "pleaseTryAgain": "Ju lutemi provoni përsëri", + "taskNotCompleted": "Detyra nuk është përfunduar", + "completeTaskDependencies": "Ju lutemi përfundoni varësitë e detyrës para se të vazhdoni" } diff --git a/worklenz-frontend/public/locales/alb/project-view-members.json b/worklenz-frontend/public/locales/alb/project-view-members.json index 239b77e9..b6406344 100644 --- a/worklenz-frontend/public/locales/alb/project-view-members.json +++ b/worklenz-frontend/public/locales/alb/project-view-members.json @@ -13,5 +13,6 @@ "deleteButtonTooltip": "Hiq nga projekti", "memberCount": "Anëtar", "membersCountPlural": "Anëtarë", - "emptyText": "Nuk ka bashkëngjitje në projekt." + "emptyText": "Nuk ka bashkëngjitje në projekt.", + "searchPlaceholder": "Kërko anëtarë" } diff --git a/worklenz-frontend/public/locales/alb/project-view/project-member-drawer.json b/worklenz-frontend/public/locales/alb/project-view/project-member-drawer.json index aa6637e1..03c891c0 100644 --- a/worklenz-frontend/public/locales/alb/project-view/project-member-drawer.json +++ b/worklenz-frontend/public/locales/alb/project-view/project-member-drawer.json @@ -3,5 +3,9 @@ "searchLabel": "Shtoni anëtarë duke shkruar emrin ose email-in e tyre", "searchPlaceholder": "Shkruani emrin ose email-in", "inviteAsAMember": "Fto si anëtar", - "inviteNewMemberByEmail": "Fto anëtar të ri me email" + "inviteNewMemberByEmail": "Fto anëtar të ri me email", + "members": "Anëtarë", + "copyProjectLink": "Kopjo lidhjen e projektit", + "inviteMember": "Fto anëtar", + "alsoInviteToProject": "Fto edhe në projekt" } diff --git a/worklenz-frontend/public/locales/alb/settings/team-members.json b/worklenz-frontend/public/locales/alb/settings/team-members.json index 955954dc..935d5a0f 100644 --- a/worklenz-frontend/public/locales/alb/settings/team-members.json +++ b/worklenz-frontend/public/locales/alb/settings/team-members.json @@ -28,7 +28,7 @@ "jobTitleLabel": "Titulli i Punës", "jobTitlePlaceholder": "Zgjidh ose kërko titull pune (Opsionale)", "memberAccessLabel": "Niveli i Qasjes", - "addToTeamButton": "Shto Anëtar në Ekip", + "addToTeamButton": "Dërgo ftesën", "updateButton": "Ruaj Ndryshimet", "resendInvitationButton": "Dërgo Përsëri Email-in e Ftesës", "invitationSentSuccessMessage": "Ftesa për ekip u dërgua me sukses!", @@ -43,5 +43,6 @@ "updatedText": "Përditësuar", "noResultFound": "Shkruani një adresë email dhe shtypni Enter...", "jobTitlesFetchError": "Dështoi marrja e titujve të punës", - "invitationResent": "Ftesa u dërgua sërish me sukses!" + "invitationResent": "Ftesa u dërgua sërish me sukses!", + "copyTeamLink": "Kopjo lidhjen e ekipit" } diff --git a/worklenz-frontend/public/locales/de/kanban-board.json b/worklenz-frontend/public/locales/de/kanban-board.json index 70e1f6ca..10b58b95 100644 --- a/worklenz-frontend/public/locales/de/kanban-board.json +++ b/worklenz-frontend/public/locales/de/kanban-board.json @@ -10,6 +10,17 @@ "deleteConfirmationOk": "Ja", "deleteConfirmationCancel": "Abbrechen", + "deleteTaskTitle": "Aufgabe löschen", + "deleteTaskContent": "Sind Sie sicher, dass Sie diese Aufgabe löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "deleteTaskConfirm": "Löschen", + "deleteTaskCancel": "Abbrechen", + + "deleteStatusTitle": "Status löschen", + "deleteStatusContent": "Sind Sie sicher, dass Sie diesen Status löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + + "deletePhaseTitle": "Phase löschen", + "deletePhaseContent": "Sind Sie sicher, dass Sie diese Phase löschen möchten? Diese Aktion kann nicht rückgängig gemacht werden.", + "dueDate": "Fälligkeitsdatum", "cancel": "Abbrechen", @@ -26,5 +37,17 @@ "noDueDate": "Kein Fälligkeitsdatum", "save": "Speichern", "clear": "Löschen", - "nextWeek": "Nächste Woche" + "nextWeek": "Nächste Woche", + "noSubtasks": "Keine Unteraufgaben", + "showSubtasks": "Unteraufgaben anzeigen", + "hideSubtasks": "Unteraufgaben ausblenden", + + "errorLoadingTasks": "Fehler beim Laden der Aufgaben", + "noTasksFound": "Keine Aufgaben gefunden", + "loadingFilters": "Filter werden geladen...", + "failedToUpdateColumnOrder": "Fehler beim Aktualisieren der Spaltenreihenfolge", + "failedToUpdatePhaseOrder": "Fehler beim Aktualisieren der Phasenreihenfolge", + "pleaseTryAgain": "Bitte versuchen Sie es erneut", + "taskNotCompleted": "Aufgabe ist nicht abgeschlossen", + "completeTaskDependencies": "Bitte schließen Sie die Aufgabenabhängigkeiten ab, bevor Sie fortfahren" } diff --git a/worklenz-frontend/public/locales/de/project-view-members.json b/worklenz-frontend/public/locales/de/project-view-members.json index eee5d0a1..4e40f757 100644 --- a/worklenz-frontend/public/locales/de/project-view-members.json +++ b/worklenz-frontend/public/locales/de/project-view-members.json @@ -13,5 +13,6 @@ "deleteButtonTooltip": "Aus Projekt entfernen", "memberCount": "Mitglied", "membersCountPlural": "Mitglieder", - "emptyText": "Es gibt keine Anhänge in diesem Projekt." + "emptyText": "Es gibt keine Anhänge in diesem Projekt.", + "searchPlaceholder": "Mitglieder suchen" } diff --git a/worklenz-frontend/public/locales/de/project-view/project-member-drawer.json b/worklenz-frontend/public/locales/de/project-view/project-member-drawer.json index cb391b2c..b92056e8 100644 --- a/worklenz-frontend/public/locales/de/project-view/project-member-drawer.json +++ b/worklenz-frontend/public/locales/de/project-view/project-member-drawer.json @@ -3,5 +3,9 @@ "searchLabel": "Mitglieder hinzufügen durch Eingabe von Name oder E-Mail", "searchPlaceholder": "Name oder E-Mail eingeben", "inviteAsAMember": "Als Mitglied einladen", - "inviteNewMemberByEmail": "Neues Mitglied per E-Mail einladen" + "inviteNewMemberByEmail": "Neues Mitglied per E-Mail einladen", + "members": "Mitglieder", + "copyProjectLink": "Projektlink kopieren", + "inviteMember": "Mitglied einladen", + "alsoInviteToProject": "Auch zum Projekt einladen" } diff --git a/worklenz-frontend/public/locales/de/settings/team-members.json b/worklenz-frontend/public/locales/de/settings/team-members.json index d223f08e..55c01713 100644 --- a/worklenz-frontend/public/locales/de/settings/team-members.json +++ b/worklenz-frontend/public/locales/de/settings/team-members.json @@ -28,7 +28,7 @@ "jobTitleLabel": "Jobtitel", "jobTitlePlaceholder": "Jobtitel auswählen oder suchen (optional)", "memberAccessLabel": "Zugriffslevel", - "addToTeamButton": "Mitglied zum Team hinzufügen", + "addToTeamButton": "Einladung senden", "updateButton": "Änderungen speichern", "resendInvitationButton": "Einladungs-E-Mail erneut senden", "invitationSentSuccessMessage": "Team-Einladung erfolgreich versendet!", @@ -43,5 +43,6 @@ "updatedText": "Aktualisiert", "noResultFound": "Geben Sie eine E-Mail-Adresse ein und drücken Sie Enter...", "jobTitlesFetchError": "Fehler beim Abrufen der Jobtitel", - "invitationResent": "Einladung erfolgreich erneut gesendet!" + "invitationResent": "Einladung erfolgreich erneut gesendet!", + "copyTeamLink": "Team-Link kopieren" } diff --git a/worklenz-frontend/public/locales/en/kanban-board.json b/worklenz-frontend/public/locales/en/kanban-board.json index e295a6c6..77659152 100644 --- a/worklenz-frontend/public/locales/en/kanban-board.json +++ b/worklenz-frontend/public/locales/en/kanban-board.json @@ -10,6 +10,17 @@ "deleteConfirmationOk": "Yes", "deleteConfirmationCancel": "Cancel", + "deleteTaskTitle": "Delete Task", + "deleteTaskContent": "Are you sure you want to delete this task? This action cannot be undone.", + "deleteTaskConfirm": "Delete", + "deleteTaskCancel": "Cancel", + + "deleteStatusTitle": "Delete Status", + "deleteStatusContent": "Are you sure you want to delete this status? This action cannot be undone.", + + "deletePhaseTitle": "Delete Phase", + "deletePhaseContent": "Are you sure you want to delete this phase? This action cannot be undone.", + "dueDate": "Due date", "cancel": "Cancel", @@ -29,5 +40,14 @@ "nextWeek": "Next week", "noSubtasks": "No subtasks", "showSubtasks": "Show subtasks", - "hideSubtasks": "Hide subtasks" + "hideSubtasks": "Hide subtasks", + + "errorLoadingTasks": "Error loading tasks", + "noTasksFound": "No tasks found", + "loadingFilters": "Loading filters...", + "failedToUpdateColumnOrder": "Failed to update column order", + "failedToUpdatePhaseOrder": "Failed to update phase order", + "pleaseTryAgain": "Please try again", + "taskNotCompleted": "Task is not completed", + "completeTaskDependencies": "Please complete the task dependencies before proceeding" } diff --git a/worklenz-frontend/public/locales/en/project-view-members.json b/worklenz-frontend/public/locales/en/project-view-members.json index 6ed8ddf0..fd15ca71 100644 --- a/worklenz-frontend/public/locales/en/project-view-members.json +++ b/worklenz-frontend/public/locales/en/project-view-members.json @@ -13,5 +13,6 @@ "deleteButtonTooltip": "Remove from project", "memberCount": "Member", "membersCountPlural": "Members", - "emptyText": "There are no attachments in the project." + "emptyText": "There are no attachments in the project.", + "searchPlaceholder": "Search members" } diff --git a/worklenz-frontend/public/locales/en/project-view/project-member-drawer.json b/worklenz-frontend/public/locales/en/project-view/project-member-drawer.json index ad2d60c8..29262250 100644 --- a/worklenz-frontend/public/locales/en/project-view/project-member-drawer.json +++ b/worklenz-frontend/public/locales/en/project-view/project-member-drawer.json @@ -1,7 +1,11 @@ { - "title": "Project Members", + "title": "Share Project", "searchLabel": "Add members by adding their name or email", "searchPlaceholder": "Type name or email", "inviteAsAMember": "Invite as a member", - "inviteNewMemberByEmail": "Invite new member by email" + "inviteNewMemberByEmail": "Invite new member by email", + "members": "Members", + "copyProjectLink": "Copy project link", + "inviteMember": "Invite Member", + "alsoInviteToProject": "Also invite to project" } diff --git a/worklenz-frontend/public/locales/en/settings/team-members.json b/worklenz-frontend/public/locales/en/settings/team-members.json index 36918b90..d59f2cf2 100644 --- a/worklenz-frontend/public/locales/en/settings/team-members.json +++ b/worklenz-frontend/public/locales/en/settings/team-members.json @@ -19,7 +19,7 @@ "cancelText": "No, cancel", "deactivatedText": "(Currently deactivated)", "pendingInvitationText": "(Invitation pending)", - "addMemberDrawerTitle": "Add New Team Member", + "addMemberDrawerTitle": "Invite Team Members", "updateMemberDrawerTitle": "Update Team Member", "addMemberEmailHint": "Members will be added to the team regardless of invitation acceptance status", "memberEmailLabel": "Email(s)", @@ -28,7 +28,7 @@ "jobTitleLabel": "Job Title", "jobTitlePlaceholder": "Select or search job title (Optional)", "memberAccessLabel": "Access Level", - "addToTeamButton": "Add Member to Team", + "addToTeamButton": "Send Invitation", "updateButton": "Save Changes", "resendInvitationButton": "Resend Invitation Email", "invitationSentSuccessMessage": "Team invitation sent successfully!", @@ -43,5 +43,6 @@ "updatedText": "Updated", "noResultFound": "Type an email address and hit enter...", "jobTitlesFetchError": "Failed to fetch job titles", - "invitationResent": "Invitation resent successfully!" + "invitationResent": "Invitation resent successfully!", + "copyTeamLink": "Copy team link" } diff --git a/worklenz-frontend/public/locales/es/kanban-board.json b/worklenz-frontend/public/locales/es/kanban-board.json index 6e8d5975..df4f2b1e 100644 --- a/worklenz-frontend/public/locales/es/kanban-board.json +++ b/worklenz-frontend/public/locales/es/kanban-board.json @@ -10,6 +10,17 @@ "deleteConfirmationOk": "Sí", "deleteConfirmationCancel": "Cancelar", + "deleteTaskTitle": "Eliminar tarea", + "deleteTaskContent": "¿Estás seguro de que deseas eliminar esta tarea? Esta acción no se puede deshacer.", + "deleteTaskConfirm": "Eliminar", + "deleteTaskCancel": "Cancelar", + + "deleteStatusTitle": "Eliminar estado", + "deleteStatusContent": "¿Estás seguro de que deseas eliminar este estado? Esta acción no se puede deshacer.", + + "deletePhaseTitle": "Eliminar fase", + "deletePhaseContent": "¿Estás seguro de que deseas eliminar esta fase? Esta acción no se puede deshacer.", + "dueDate": "Fecha de vencimiento", "cancel": "Cancelar", @@ -26,5 +37,17 @@ "noDueDate": "Sin fecha de vencimiento", "save": "Guardar", "clear": "Limpiar", - "nextWeek": "Próxima semana" + "nextWeek": "Próxima semana", + "noSubtasks": "Sin subtareas", + "showSubtasks": "Mostrar subtareas", + "hideSubtasks": "Ocultar subtareas", + + "errorLoadingTasks": "Error al cargar tareas", + "noTasksFound": "No se encontraron tareas", + "loadingFilters": "Cargando filtros...", + "failedToUpdateColumnOrder": "Error al actualizar el orden de las columnas", + "failedToUpdatePhaseOrder": "Error al actualizar el orden de las fases", + "pleaseTryAgain": "Por favor, inténtalo de nuevo", + "taskNotCompleted": "La tarea no está completada", + "completeTaskDependencies": "Por favor, completa las dependencias de la tarea antes de continuar" } diff --git a/worklenz-frontend/public/locales/es/project-view-members.json b/worklenz-frontend/public/locales/es/project-view-members.json index 95a8d943..46f26b3f 100644 --- a/worklenz-frontend/public/locales/es/project-view-members.json +++ b/worklenz-frontend/public/locales/es/project-view-members.json @@ -13,5 +13,6 @@ "deleteButtonTooltip": "Eliminar del proyecto", "memberCount": "Miembro", "membersCountPlural": "Miembros", - "emptyText": "No hay archivos adjuntos en el proyecto." + "emptyText": "No hay archivos adjuntos en el proyecto.", + "searchPlaceholder": "Buscar miembros" } diff --git a/worklenz-frontend/public/locales/es/project-view/project-member-drawer.json b/worklenz-frontend/public/locales/es/project-view/project-member-drawer.json index ab7570fd..2ade994e 100644 --- a/worklenz-frontend/public/locales/es/project-view/project-member-drawer.json +++ b/worklenz-frontend/public/locales/es/project-view/project-member-drawer.json @@ -3,5 +3,9 @@ "searchLabel": "Agregar miembros ingresando su nombre o correo electrónico", "searchPlaceholder": "Escriba nombre o correo electrónico", "inviteAsAMember": "Invitar como miembro", - "inviteNewMemberByEmail": "Invitar nuevo miembro por correo electrónico" + "inviteNewMemberByEmail": "Invitar nuevo miembro por correo electrónico", + "members": "Miembros", + "copyProjectLink": "Copiar enlace del proyecto", + "inviteMember": "Invitar miembro", + "alsoInviteToProject": "También invitar al proyecto" } diff --git a/worklenz-frontend/public/locales/es/settings/team-members.json b/worklenz-frontend/public/locales/es/settings/team-members.json index 1000bf98..7f317fab 100644 --- a/worklenz-frontend/public/locales/es/settings/team-members.json +++ b/worklenz-frontend/public/locales/es/settings/team-members.json @@ -28,7 +28,7 @@ "jobTitleLabel": "Cargo", "jobTitlePlaceholder": "Seleccione o busque cargo (Opcional)", "memberAccessLabel": "Nivel de acceso", - "addToTeamButton": "Agregar miembro al equipo", + "addToTeamButton": "Enviar invitación", "updateButton": "Guardar cambios", "resendInvitationButton": "Reenviar correo de invitación", "invitationSentSuccessMessage": "¡Invitación al equipo enviada exitosamente!", @@ -43,5 +43,6 @@ "updatedText": "Actualizado", "noResultFound": "Escriba una dirección de correo electrónico y presione enter...", "jobTitlesFetchError": "Error al obtener los cargos", - "invitationResent": "¡Invitación reenviada exitosamente!" + "invitationResent": "¡Invitación reenviada exitosamente!", + "copyTeamLink": "Copiar enlace del equipo" } diff --git a/worklenz-frontend/public/locales/pt/kanban-board.json b/worklenz-frontend/public/locales/pt/kanban-board.json index a2034daa..5bac3adb 100644 --- a/worklenz-frontend/public/locales/pt/kanban-board.json +++ b/worklenz-frontend/public/locales/pt/kanban-board.json @@ -10,6 +10,17 @@ "deleteConfirmationOk": "Sim", "deleteConfirmationCancel": "Cancelar", + "deleteTaskTitle": "Excluir Tarefa", + "deleteTaskContent": "Tem certeza de que deseja excluir esta tarefa? Esta ação não pode ser desfeita.", + "deleteTaskConfirm": "Excluir", + "deleteTaskCancel": "Cancelar", + + "deleteStatusTitle": "Excluir Status", + "deleteStatusContent": "Tem certeza de que deseja excluir este status? Esta ação não pode ser desfeita.", + + "deletePhaseTitle": "Excluir Fase", + "deletePhaseContent": "Tem certeza de que deseja excluir esta fase? Esta ação não pode ser desfeita.", + "dueDate": "Data de vencimento", "cancel": "Cancelar", @@ -26,5 +37,17 @@ "noDueDate": "Sem data de vencimento", "save": "Salvar", "clear": "Limpar", - "nextWeek": "Próxima semana" + "nextWeek": "Próxima semana", + "noSubtasks": "Sem subtarefas", + "showSubtasks": "Mostrar subtarefas", + "hideSubtasks": "Ocultar subtarefas", + + "errorLoadingTasks": "Erro ao carregar tarefas", + "noTasksFound": "Nenhuma tarefa encontrada", + "loadingFilters": "Carregando filtros...", + "failedToUpdateColumnOrder": "Falha ao atualizar a ordem das colunas", + "failedToUpdatePhaseOrder": "Falha ao atualizar a ordem das fases", + "pleaseTryAgain": "Por favor, tente novamente", + "taskNotCompleted": "Tarefa não está concluída", + "completeTaskDependencies": "Por favor, complete as dependências da tarefa antes de prosseguir" } diff --git a/worklenz-frontend/public/locales/pt/project-view-members.json b/worklenz-frontend/public/locales/pt/project-view-members.json index 72524807..df6eded0 100644 --- a/worklenz-frontend/public/locales/pt/project-view-members.json +++ b/worklenz-frontend/public/locales/pt/project-view-members.json @@ -13,5 +13,6 @@ "deleteButtonTooltip": "Remover do projeto", "memberCount": "Membro", "membersCountPlural": "Membros", - "emptyText": "Não há anexos no projeto." + "emptyText": "Não há anexos no projeto.", + "searchPlaceholder": "Pesquisar membros" } diff --git a/worklenz-frontend/public/locales/pt/project-view/project-member-drawer.json b/worklenz-frontend/public/locales/pt/project-view/project-member-drawer.json index 0afe3d87..0c5c7b1a 100644 --- a/worklenz-frontend/public/locales/pt/project-view/project-member-drawer.json +++ b/worklenz-frontend/public/locales/pt/project-view/project-member-drawer.json @@ -3,5 +3,9 @@ "searchLabel": "Adicionar membros inserindo nome ou e-mail", "searchPlaceholder": "Digite nome ou e-mail", "inviteAsAMember": "Convidar como membro", - "inviteNewMemberByEmail": "Convidar novo membro por e-mail" + "inviteNewMemberByEmail": "Convidar novo membro por e-mail", + "members": "Membros", + "copyProjectLink": "Copiar link do projeto", + "inviteMember": "Convidar membro", + "alsoInviteToProject": "Convidar também para o projeto" } diff --git a/worklenz-frontend/public/locales/pt/settings/team-members.json b/worklenz-frontend/public/locales/pt/settings/team-members.json index 9ace1764..9bb38de3 100644 --- a/worklenz-frontend/public/locales/pt/settings/team-members.json +++ b/worklenz-frontend/public/locales/pt/settings/team-members.json @@ -28,7 +28,7 @@ "jobTitleLabel": "Título do Emprego", "jobTitlePlaceholder": "Selecione ou pesquise o título do emprego (Opcional)", "memberAccessLabel": "Nível de Acesso", - "addToTeamButton": "Adicionar Membro à Equipe", + "addToTeamButton": "Enviar convite", "updateButton": "Salvar Alterações", "resendInvitationButton": "Redirecionar Email de Convite", "invitationSentSuccessMessage": "Convite para a equipe enviado com sucesso!", @@ -43,5 +43,6 @@ "updatedText": "Atualizado", "noResultFound": "Digite um endereço de email e pressione enter...", "jobTitlesFetchError": "Falha ao buscar cargos", - "invitationResent": "Convite reenviado com sucesso!" + "invitationResent": "Convite reenviado com sucesso!", + "copyTeamLink": "Copiar link da equipe" } diff --git a/worklenz-frontend/public/locales/zh/kanban-board.json b/worklenz-frontend/public/locales/zh/kanban-board.json index 7b72c5d5..20c7cb08 100644 --- a/worklenz-frontend/public/locales/zh/kanban-board.json +++ b/worklenz-frontend/public/locales/zh/kanban-board.json @@ -15,5 +15,32 @@ "assignToMe": "分配给我", "archive": "归档", "newTaskNamePlaceholder": "写一个任务名称", - "newSubtaskNamePlaceholder": "写一个子任务名称" + "newSubtaskNamePlaceholder": "写一个子任务名称", + "deleteTaskTitle": "删除任务", + "deleteTaskContent": "您确定要删除此任务吗?此操作无法撤销。", + "deleteTaskConfirm": "删除", + "deleteTaskCancel": "取消", + "deleteStatusTitle": "删除状态", + "deleteStatusContent": "您确定要删除此状态吗?此操作无法撤销。", + "deletePhaseTitle": "删除阶段", + "deletePhaseContent": "您确定要删除此阶段吗?此操作无法撤销。", + "untitledSection": "未命名部分", + "unmapped": "未映射", + "clickToChangeDate": "点击更改日期", + "noDueDate": "无截止日期", + "save": "保存", + "clear": "清除", + "nextWeek": "下周", + "noSubtasks": "无子任务", + "showSubtasks": "显示子任务", + "hideSubtasks": "隐藏子任务", + + "errorLoadingTasks": "加载任务时出错", + "noTasksFound": "未找到任务", + "loadingFilters": "正在加载过滤器...", + "failedToUpdateColumnOrder": "更新列顺序失败", + "failedToUpdatePhaseOrder": "更新阶段顺序失败", + "pleaseTryAgain": "请重试", + "taskNotCompleted": "任务未完成", + "completeTaskDependencies": "请先完成任务依赖项,然后再继续" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/project-view/project-member-drawer.json b/worklenz-frontend/public/locales/zh/project-view/project-member-drawer.json index f412f22b..512ab0d0 100644 --- a/worklenz-frontend/public/locales/zh/project-view/project-member-drawer.json +++ b/worklenz-frontend/public/locales/zh/project-view/project-member-drawer.json @@ -3,5 +3,9 @@ "searchLabel": "通过添加名称或电子邮件添加成员", "searchPlaceholder": "输入名称或电子邮件", "inviteAsAMember": "邀请为成员", - "inviteNewMemberByEmail": "通过电子邮件邀请新成员" + "inviteNewMemberByEmail": "通过电子邮件邀请新成员", + "members": "成员", + "copyProjectLink": "复制项目链接", + "inviteMember": "邀请成员", + "alsoInviteToProject": "也邀请到项目" } \ No newline at end of file diff --git a/worklenz-frontend/public/locales/zh/settings/team-members.json b/worklenz-frontend/public/locales/zh/settings/team-members.json index 8b39483c..8e9bcfb0 100644 --- a/worklenz-frontend/public/locales/zh/settings/team-members.json +++ b/worklenz-frontend/public/locales/zh/settings/team-members.json @@ -28,7 +28,7 @@ "jobTitleLabel": "职位", "jobTitlePlaceholder": "选择或搜索职位(可选)", "memberAccessLabel": "访问级别", - "addToTeamButton": "将成员添加到团队", + "addToTeamButton": "发送邀请", "updateButton": "保存更改", "resendInvitationButton": "重新发送邀请邮件", "invitationSentSuccessMessage": "团队邀请已成功发送!", @@ -43,5 +43,6 @@ "updatedText": "已更新", "noResultFound": "输入电子邮件地址并按回车键...", "jobTitlesFetchError": "获取职位失败", - "invitationResent": "邀请重新发送成功!" + "invitationResent": "邀请重新发送成功!", + "copyTeamLink": "复制团队链接" } \ No newline at end of file diff --git a/worklenz-frontend/src/app/routes/main-routes.tsx b/worklenz-frontend/src/app/routes/main-routes.tsx index 8ec8cb9a..4c96c8f9 100644 --- a/worklenz-frontend/src/app/routes/main-routes.tsx +++ b/worklenz-frontend/src/app/routes/main-routes.tsx @@ -17,6 +17,7 @@ const ProjectTemplateEditView = lazy( const LicenseExpired = lazy(() => import('@/pages/license-expired/license-expired')); const ProjectView = lazy(() => import('@/pages/projects/projectView/project-view')); const Unauthorized = lazy(() => import('@/pages/unauthorized/unauthorized')); +const GanttDemoPage = lazy(() => import('@/pages/GanttDemoPage')); // Define AdminGuard component with defensive programming const AdminGuard = ({ children }: { children: React.ReactNode }) => { @@ -106,6 +107,14 @@ const mainRoutes: RouteObject[] = [ ), }, + { + path: 'gantt-demo', + element: ( + }> + + + ), + }, ...settingsRoutes, ...adminCenterRoutes, ], diff --git a/worklenz-frontend/src/components/AssigneeSelector.tsx b/worklenz-frontend/src/components/AssigneeSelector.tsx index 7a489885..3f786959 100644 --- a/worklenz-frontend/src/components/AssigneeSelector.tsx +++ b/worklenz-frontend/src/components/AssigneeSelector.tsx @@ -11,7 +11,7 @@ import { useAuthService } from '@/hooks/useAuth'; import { Avatar, Button, Checkbox } from '@/components'; import { sortTeamMembers } from '@/utils/sort-team-members'; import { useAppDispatch } from '@/hooks/useAppDispatch'; -import { toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice'; +import { setIsFromAssigner, toggleProjectMemberDrawer } from '@/features/projects/singleProject/members/projectMembersSlice'; import { updateEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice'; interface AssigneeSelectorProps { @@ -206,6 +206,7 @@ const AssigneeSelector: React.FC = ({ const handleInviteProjectMemberDrawer = () => { setIsOpen(false); // Close the assignee dropdown first + dispatch(setIsFromAssigner(true)); dispatch(toggleProjectMemberDrawer()); // Then open the invite drawer }; diff --git a/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttChart.tsx b/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttChart.tsx new file mode 100644 index 00000000..b1858c92 --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttChart.tsx @@ -0,0 +1,612 @@ +import React, { useReducer, useMemo, useCallback, useRef, useEffect, useState } from 'react'; +import { + GanttTask, + ColumnConfig, + TimelineConfig, + VirtualScrollConfig, + ZoomLevel, + GanttState, + GanttAction, + AdvancedGanttProps, + SelectionState, + GanttViewState, + DragState +} from '../../types/advanced-gantt.types'; +import GanttGrid from './GanttGrid'; +import DraggableTaskBar from './DraggableTaskBar'; +import TimelineMarkers, { holidayPresets, workingDayPresets } from './TimelineMarkers'; +import VirtualScrollContainer, { VirtualTimeline } from './VirtualScrollContainer'; +import { + usePerformanceMonitoring, + useTaskCalculations, + useDateCalculations, + useDebounce, + useThrottle +} from '../../utils/gantt-performance'; +import { useAppSelector } from '../../hooks/useAppSelector'; +import { themeWiseColor } from '../../utils/themeWiseColor'; + +// Default configurations +const defaultColumns: ColumnConfig[] = [ + { + field: 'name', + title: 'Task Name', + width: 250, + minWidth: 150, + resizable: true, + sortable: true, + fixed: true, + editor: 'text' + }, + { + field: 'startDate', + title: 'Start Date', + width: 120, + minWidth: 100, + resizable: true, + sortable: true, + fixed: true, + editor: 'date' + }, + { + field: 'endDate', + title: 'End Date', + width: 120, + minWidth: 100, + resizable: true, + sortable: true, + fixed: true, + editor: 'date' + }, + { + field: 'duration', + title: 'Duration', + width: 80, + minWidth: 60, + resizable: true, + sortable: false, + fixed: true + }, + { + field: 'progress', + title: 'Progress', + width: 100, + minWidth: 80, + resizable: true, + sortable: true, + fixed: true, + editor: 'number' + }, +]; + +const defaultTimelineConfig: TimelineConfig = { + topTier: { unit: 'month', format: 'MMM yyyy', height: 30 }, + bottomTier: { unit: 'day', format: 'dd', height: 25 }, + showWeekends: true, + showNonWorkingDays: true, + holidays: holidayPresets.US, + workingDays: workingDayPresets.standard, + workingHours: { start: 9, end: 17 }, + dayWidth: 30, +}; + +const defaultVirtualScrollConfig: VirtualScrollConfig = { + enableRowVirtualization: true, + enableTimelineVirtualization: true, + bufferSize: 10, + itemHeight: 40, + overscan: 5, +}; + +const defaultZoomLevels: ZoomLevel[] = [ + { + name: 'Year', + dayWidth: 2, + scale: 0.1, + topTier: { unit: 'year', format: 'yyyy' }, + bottomTier: { unit: 'month', format: 'MMM' } + }, + { + name: 'Month', + dayWidth: 8, + scale: 0.5, + topTier: { unit: 'month', format: 'MMM yyyy' }, + bottomTier: { unit: 'week', format: 'w' } + }, + { + name: 'Week', + dayWidth: 25, + scale: 1, + topTier: { unit: 'week', format: 'MMM dd' }, + bottomTier: { unit: 'day', format: 'dd' } + }, + { + name: 'Day', + dayWidth: 50, + scale: 2, + topTier: { unit: 'day', format: 'MMM dd' }, + bottomTier: { unit: 'hour', format: 'HH' } + }, +]; + +// Gantt state reducer +function ganttReducer(state: GanttState, action: GanttAction): GanttState { + switch (action.type) { + case 'SET_TASKS': + return { ...state, tasks: action.payload }; + + case 'UPDATE_TASK': + return { + ...state, + tasks: state.tasks.map(task => + task.id === action.payload.id + ? { ...task, ...action.payload.updates } + : task + ), + }; + + case 'ADD_TASK': + return { ...state, tasks: [...state.tasks, action.payload] }; + + case 'DELETE_TASK': + return { + ...state, + tasks: state.tasks.filter(task => task.id !== action.payload), + }; + + case 'SET_SELECTION': + return { + ...state, + selectionState: { ...state.selectionState, selectedTasks: action.payload }, + }; + + case 'SET_DRAG_STATE': + return { ...state, dragState: action.payload }; + + case 'SET_ZOOM_LEVEL': + const newZoomLevel = Math.max(0, Math.min(state.zoomLevels.length - 1, action.payload)); + return { + ...state, + viewState: { ...state.viewState, zoomLevel: newZoomLevel }, + timelineConfig: { + ...state.timelineConfig, + dayWidth: state.zoomLevels[newZoomLevel].dayWidth, + topTier: state.zoomLevels[newZoomLevel].topTier, + bottomTier: state.zoomLevels[newZoomLevel].bottomTier, + }, + }; + + case 'SET_SCROLL_POSITION': + return { + ...state, + viewState: { ...state.viewState, scrollPosition: action.payload }, + }; + + case 'SET_SPLITTER_POSITION': + return { + ...state, + viewState: { ...state.viewState, splitterPosition: action.payload }, + }; + + case 'TOGGLE_TASK_EXPANSION': + return { + ...state, + tasks: state.tasks.map(task => + task.id === action.payload + ? { ...task, isExpanded: !task.isExpanded } + : task + ), + }; + + case 'SET_VIEW_STATE': + return { + ...state, + viewState: { ...state.viewState, ...action.payload }, + }; + + case 'UPDATE_COLUMN_WIDTH': + return { + ...state, + columns: state.columns.map(col => + col.field === action.payload.field + ? { ...col, width: action.payload.width } + : col + ), + }; + + default: + return state; + } +} + +const AdvancedGanttChart: React.FC = ({ + tasks: initialTasks, + columns = defaultColumns, + timelineConfig = {}, + virtualScrollConfig = {}, + zoomLevels = defaultZoomLevels, + initialViewState = {}, + initialSelection = [], + onTaskUpdate, + onTaskCreate, + onTaskDelete, + onTaskMove, + onTaskResize, + onProgressChange, + onSelectionChange, + onColumnResize, + onDependencyCreate, + onDependencyDelete, + className = '', + style = {}, + theme = 'auto', + enableDragDrop = true, + enableResize = true, + enableProgressEdit = true, + enableInlineEdit = true, + enableVirtualScrolling = true, + enableDebouncing = true, + debounceDelay = 300, + maxVisibleTasks = 1000, +}) => { + const containerRef = useRef(null); + const [containerSize, setContainerSize] = useState({ width: 0, height: 0 }); + const themeMode = useAppSelector(state => state.themeReducer.mode); + const { startMeasure, endMeasure, metrics } = usePerformanceMonitoring(); + const { getDaysBetween } = useDateCalculations(); + + // Initialize state + const initialState: GanttState = { + tasks: initialTasks, + columns, + timelineConfig: { ...defaultTimelineConfig, ...timelineConfig }, + virtualScrollConfig: { ...defaultVirtualScrollConfig, ...virtualScrollConfig }, + dragState: null, + selectionState: { + selectedTasks: initialSelection, + selectedRows: [], + focusedTask: undefined, + }, + viewState: { + zoomLevel: 2, // Week view by default + scrollPosition: { x: 0, y: 0 }, + viewportSize: { width: 0, height: 0 }, + splitterPosition: 40, // 40% for grid, 60% for timeline + showCriticalPath: false, + showBaseline: false, + showProgress: true, + showDependencies: true, + autoSchedule: false, + readOnly: false, + ...initialViewState, + }, + zoomLevels, + performanceMetrics: { + renderTime: 0, + taskCount: initialTasks.length, + visibleTaskCount: 0, + }, + }; + + const [state, dispatch] = useReducer(ganttReducer, initialState); + const { taskMap, parentChildMap, totalTasks } = useTaskCalculations(state.tasks); + + // Calculate project timeline bounds + const projectBounds = useMemo(() => { + if (state.tasks.length === 0) { + const today = new Date(); + return { + start: new Date(today.getFullYear(), today.getMonth(), 1), + end: new Date(today.getFullYear(), today.getMonth() + 3, 0), + }; + } + + const startDates = state.tasks.map(task => task.startDate); + const endDates = state.tasks.map(task => task.endDate); + const minStart = new Date(Math.min(...startDates.map(d => d.getTime()))); + const maxEnd = new Date(Math.max(...endDates.map(d => d.getTime()))); + + // Add some padding + minStart.setDate(minStart.getDate() - 7); + maxEnd.setDate(maxEnd.getDate() + 7); + + return { start: minStart, end: maxEnd }; + }, [state.tasks]); + + // Debounced event handlers + const debouncedTaskUpdate = useDebounce( + useCallback((taskId: string, updates: Partial) => { + dispatch({ type: 'UPDATE_TASK', payload: { id: taskId, updates } }); + onTaskUpdate?.(taskId, updates); + }, [onTaskUpdate]), + enableDebouncing ? debounceDelay : 0 + ); + + const debouncedTaskMove = useDebounce( + useCallback((taskId: string, newDates: { start: Date; end: Date }) => { + dispatch({ type: 'UPDATE_TASK', payload: { + id: taskId, + updates: { startDate: newDates.start, endDate: newDates.end } + }}); + onTaskMove?.(taskId, newDates); + }, [onTaskMove]), + enableDebouncing ? debounceDelay : 0 + ); + + const debouncedProgressChange = useDebounce( + useCallback((taskId: string, progress: number) => { + dispatch({ type: 'UPDATE_TASK', payload: { id: taskId, updates: { progress } }}); + onProgressChange?.(taskId, progress); + }, [onProgressChange]), + enableDebouncing ? debounceDelay : 0 + ); + + // Throttled scroll handler + const throttledScrollHandler = useThrottle( + useCallback((scrollLeft: number, scrollTop: number) => { + dispatch({ type: 'SET_SCROLL_POSITION', payload: { x: scrollLeft, y: scrollTop } }); + }, []), + 16 // 60fps + ); + + // Container size observer + useEffect(() => { + const observer = new ResizeObserver((entries) => { + for (const entry of entries) { + const { width, height } = entry.contentRect; + setContainerSize({ width, height }); + dispatch({ + type: 'SET_VIEW_STATE', + payload: { viewportSize: { width, height } } + }); + } + }); + + if (containerRef.current) { + observer.observe(containerRef.current); + } + + return () => observer.disconnect(); + }, []); + + // Calculate grid and timeline dimensions + const gridWidth = useMemo(() => { + return Math.floor(containerSize.width * (state.viewState.splitterPosition / 100)); + }, [containerSize.width, state.viewState.splitterPosition]); + + const timelineWidth = useMemo(() => { + return containerSize.width - gridWidth; + }, [containerSize.width, gridWidth]); + + // Handle zoom changes + const handleZoomChange = useCallback((direction: 'in' | 'out') => { + const currentZoom = state.viewState.zoomLevel; + const newZoom = direction === 'in' + ? Math.min(state.zoomLevels.length - 1, currentZoom + 1) + : Math.max(0, currentZoom - 1); + + dispatch({ type: 'SET_ZOOM_LEVEL', payload: newZoom }); + }, [state.viewState.zoomLevel, state.zoomLevels.length]); + + // Theme-aware colors + const colors = useMemo(() => ({ + background: themeWiseColor('#ffffff', '#1f2937', themeMode), + border: themeWiseColor('#e5e7eb', '#4b5563', themeMode), + timelineBackground: themeWiseColor('#f8f9fa', '#374151', themeMode), + }), [themeMode]); + + // Render timeline header + const renderTimelineHeader = () => { + const currentZoom = state.zoomLevels[state.viewState.zoomLevel]; + const totalDays = getDaysBetween(projectBounds.start, projectBounds.end); + const totalWidth = totalDays * state.timelineConfig.dayWidth; + + return ( +
+ + {(date, index, style) => ( +
+
+ {formatDateForUnit(date, currentZoom.topTier.unit)} +
+
+ {formatDateForUnit(date, currentZoom.bottomTier.unit)} +
+
+ )} +
+
+ ); + }; + + // Render timeline content + const renderTimelineContent = () => { + const headerHeight = (state.zoomLevels[state.viewState.zoomLevel].topTier.height || 30) + + (state.zoomLevels[state.viewState.zoomLevel].bottomTier.height || 25); + const contentHeight = containerSize.height - headerHeight; + + return ( +
+ {/* Timeline markers (weekends, holidays, etc.) */} + + + {/* Task bars */} + + {(task, index, style) => ( + + )} + +
+ ); + }; + + // Render toolbar + const renderToolbar = () => ( +
+
+ + + {state.zoomLevels[state.viewState.zoomLevel].name} + + +
+ +
+ Tasks: {state.tasks.length} + + Render: {Math.round(metrics.renderTime)}ms +
+
+ ); + + // Performance monitoring + useEffect(() => { + startMeasure('render'); + return () => endMeasure('render'); + }); + + return ( +
+ {/* Toolbar */} + {renderToolbar()} + + {/* Main content */} +
+ {/* Grid */} +
+ { + // Handle task selection + const newSelection = { ...state.selectionState, selectedTasks: [task.id] }; + dispatch({ type: 'SET_SELECTION', payload: [task.id] }); + onSelectionChange?.(newSelection); + }} + onTaskExpand={(taskId) => { + dispatch({ type: 'TOGGLE_TASK_EXPANSION', payload: taskId }); + }} + onColumnResize={(field, width) => { + dispatch({ type: 'UPDATE_COLUMN_WIDTH', payload: { field, width } }); + onColumnResize?.(field, width); + }} + onTaskUpdate={debouncedTaskUpdate} + /> +
+ + {/* Timeline */} +
+ {renderTimelineHeader()} + {renderTimelineContent()} +
+
+
+ ); +}; + +// Helper function to format dates based on unit +function formatDateForUnit(date: Date, unit: string): string { + switch (unit) { + case 'year': + return date.getFullYear().toString(); + case 'month': + return date.toLocaleDateString('en-US', { month: 'short', year: 'numeric' }); + case 'week': + return `W${getWeekNumber(date)}`; + case 'day': + return date.getDate().toString(); + case 'hour': + return date.getHours().toString().padStart(2, '0'); + default: + return ''; + } +} + +function getWeekNumber(date: Date): number { + const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())); + const dayNum = d.getUTCDay() || 7; + d.setUTCDate(d.getUTCDate() + 4 - dayNum); + const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1)); + return Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7); +} + +export default AdvancedGanttChart; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttDemo.tsx b/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttDemo.tsx new file mode 100644 index 00000000..64b10de8 --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/AdvancedGanttDemo.tsx @@ -0,0 +1,668 @@ +import React, { useState, useMemo } from 'react'; +import { Button, Space, message, Card } from 'antd'; +import AdvancedGanttChart from './AdvancedGanttChart'; +import { GanttTask, ColumnConfig } from '../../types/advanced-gantt.types'; +import { useAppSelector } from '../../hooks/useAppSelector'; +import { holidayPresets, workingDayPresets } from './TimelineMarkers'; + +// Enhanced sample data with more realistic project structure +const generateSampleTasks = (): GanttTask[] => { + const baseDate = new Date(2024, 11, 1); // December 1, 2024 + + return [ + // Project Phase 1: Planning & Design + { + id: 'project-1', + name: '🚀 Web Platform Development', + startDate: new Date(2024, 11, 1), + endDate: new Date(2025, 2, 31), + progress: 45, + type: 'project', + status: 'in-progress', + priority: 'high', + color: '#1890ff', + hasChildren: true, + isExpanded: true, + level: 0, + }, + { + id: 'planning-phase', + name: '📋 Planning & Analysis Phase', + startDate: new Date(2024, 11, 1), + endDate: new Date(2024, 11, 20), + progress: 85, + type: 'project', + status: 'in-progress', + priority: 'high', + parent: 'project-1', + color: '#52c41a', + hasChildren: true, + isExpanded: true, + level: 1, + }, + { + id: 'requirements-analysis', + name: 'Requirements Gathering & Analysis', + startDate: new Date(2024, 11, 1), + endDate: new Date(2024, 11, 8), + progress: 100, + type: 'task', + status: 'completed', + priority: 'high', + parent: 'planning-phase', + assignee: { + id: 'user-1', + name: 'Alice Johnson', + avatar: 'https://ui-avatars.com/api/?name=Alice+Johnson&background=1890ff&color=fff', + }, + tags: ['research', 'documentation'], + level: 2, + }, + { + id: 'technical-architecture', + name: 'Technical Architecture Design', + startDate: new Date(2024, 11, 8), + endDate: new Date(2024, 11, 18), + progress: 75, + type: 'task', + status: 'in-progress', + priority: 'high', + parent: 'planning-phase', + assignee: { + id: 'user-2', + name: 'Bob Smith', + avatar: 'https://ui-avatars.com/api/?name=Bob+Smith&background=52c41a&color=fff', + }, + dependencies: ['requirements-analysis'], + tags: ['architecture', 'design'], + level: 2, + }, + { + id: 'ui-ux-design', + name: 'UI/UX Design & Prototyping', + startDate: new Date(2024, 11, 10), + endDate: new Date(2024, 11, 20), + progress: 60, + type: 'task', + status: 'in-progress', + priority: 'medium', + parent: 'planning-phase', + assignee: { + id: 'user-3', + name: 'Carol Davis', + avatar: 'https://ui-avatars.com/api/?name=Carol+Davis&background=faad14&color=fff', + }, + dependencies: ['requirements-analysis'], + tags: ['design', 'prototype'], + level: 2, + }, + { + id: 'milestone-planning-complete', + name: '🎯 Planning Phase Complete', + startDate: new Date(2024, 11, 20), + endDate: new Date(2024, 11, 20), + progress: 0, + type: 'milestone', + status: 'not-started', + priority: 'critical', + parent: 'planning-phase', + dependencies: ['technical-architecture', 'ui-ux-design'], + level: 2, + }, + + // Development Phase + { + id: 'development-phase', + name: '⚡ Development Phase', + startDate: new Date(2024, 11, 21), + endDate: new Date(2025, 1, 28), + progress: 35, + type: 'project', + status: 'in-progress', + priority: 'high', + parent: 'project-1', + color: '#722ed1', + hasChildren: true, + isExpanded: true, + level: 1, + }, + { + id: 'backend-development', + name: 'Backend API Development', + startDate: new Date(2024, 11, 21), + endDate: new Date(2025, 1, 15), + progress: 45, + type: 'task', + status: 'in-progress', + priority: 'high', + parent: 'development-phase', + assignee: { + id: 'user-4', + name: 'David Wilson', + avatar: 'https://ui-avatars.com/api/?name=David+Wilson&background=722ed1&color=fff', + }, + dependencies: ['milestone-planning-complete'], + tags: ['backend', 'api'], + level: 2, + }, + { + id: 'frontend-development', + name: 'Frontend React Application', + startDate: new Date(2025, 0, 5), + endDate: new Date(2025, 1, 25), + progress: 25, + type: 'task', + status: 'in-progress', + priority: 'high', + parent: 'development-phase', + assignee: { + id: 'user-5', + name: 'Eva Brown', + avatar: 'https://ui-avatars.com/api/?name=Eva+Brown&background=ff7a45&color=fff', + }, + dependencies: ['backend-development'], + tags: ['frontend', 'react'], + level: 2, + }, + { + id: 'database-setup', + name: 'Database Schema & Migration', + startDate: new Date(2024, 11, 21), + endDate: new Date(2025, 0, 10), + progress: 80, + type: 'task', + status: 'in-progress', + priority: 'medium', + parent: 'development-phase', + assignee: { + id: 'user-6', + name: 'Frank Miller', + avatar: 'https://ui-avatars.com/api/?name=Frank+Miller&background=13c2c2&color=fff', + }, + dependencies: ['milestone-planning-complete'], + tags: ['database', 'migration'], + level: 2, + }, + + // Testing Phase + { + id: 'testing-phase', + name: '🧪 Testing & QA Phase', + startDate: new Date(2025, 2, 1), + endDate: new Date(2025, 2, 20), + progress: 0, + type: 'project', + status: 'not-started', + priority: 'high', + parent: 'project-1', + color: '#fa8c16', + hasChildren: true, + isExpanded: false, + level: 1, + }, + { + id: 'unit-testing', + name: 'Unit Testing Implementation', + startDate: new Date(2025, 2, 1), + endDate: new Date(2025, 2, 10), + progress: 0, + type: 'task', + status: 'not-started', + priority: 'high', + parent: 'testing-phase', + assignee: { + id: 'user-7', + name: 'Grace Lee', + avatar: 'https://ui-avatars.com/api/?name=Grace+Lee&background=fa8c16&color=fff', + }, + dependencies: ['frontend-development'], + tags: ['testing', 'unit'], + level: 2, + }, + { + id: 'integration-testing', + name: 'Integration & E2E Testing', + startDate: new Date(2025, 2, 8), + endDate: new Date(2025, 2, 18), + progress: 0, + type: 'task', + status: 'not-started', + priority: 'high', + parent: 'testing-phase', + assignee: { + id: 'user-8', + name: 'Henry Clark', + avatar: 'https://ui-avatars.com/api/?name=Henry+Clark&background=eb2f96&color=fff', + }, + dependencies: ['unit-testing'], + tags: ['testing', 'integration'], + level: 2, + }, + { + id: 'milestone-beta-ready', + name: '🎯 Beta Release Ready', + startDate: new Date(2025, 2, 20), + endDate: new Date(2025, 2, 20), + progress: 0, + type: 'milestone', + status: 'not-started', + priority: 'critical', + parent: 'testing-phase', + dependencies: ['integration-testing'], + level: 2, + }, + + // Deployment Phase + { + id: 'deployment-phase', + name: '🚀 Deployment & Launch', + startDate: new Date(2025, 2, 21), + endDate: new Date(2025, 2, 31), + progress: 0, + type: 'project', + status: 'not-started', + priority: 'critical', + parent: 'project-1', + color: '#f5222d', + hasChildren: true, + isExpanded: false, + level: 1, + }, + { + id: 'production-deployment', + name: 'Production Environment Setup', + startDate: new Date(2025, 2, 21), + endDate: new Date(2025, 2, 25), + progress: 0, + type: 'task', + status: 'not-started', + priority: 'critical', + parent: 'deployment-phase', + assignee: { + id: 'user-9', + name: 'Ivy Taylor', + avatar: 'https://ui-avatars.com/api/?name=Ivy+Taylor&background=f5222d&color=fff', + }, + dependencies: ['milestone-beta-ready'], + tags: ['deployment', 'production'], + level: 2, + }, + { + id: 'go-live', + name: 'Go Live & Monitoring', + startDate: new Date(2025, 2, 26), + endDate: new Date(2025, 2, 31), + progress: 0, + type: 'task', + status: 'not-started', + priority: 'critical', + parent: 'deployment-phase', + assignee: { + id: 'user-10', + name: 'Jack Anderson', + avatar: 'https://ui-avatars.com/api/?name=Jack+Anderson&background=2f54eb&color=fff', + }, + dependencies: ['production-deployment'], + tags: ['launch', 'monitoring'], + level: 2, + }, + { + id: 'milestone-project-complete', + name: '🎉 Project Launch Complete', + startDate: new Date(2025, 2, 31), + endDate: new Date(2025, 2, 31), + progress: 0, + type: 'milestone', + status: 'not-started', + priority: 'critical', + parent: 'deployment-phase', + dependencies: ['go-live'], + level: 2, + }, + ]; +}; + +// Enhanced column configuration +const sampleColumns: ColumnConfig[] = [ + { + field: 'name', + title: 'Task / Phase Name', + width: 300, + minWidth: 200, + resizable: true, + sortable: true, + fixed: true, + editor: 'text' + }, + { + field: 'assignee', + title: 'Assignee', + width: 150, + minWidth: 120, + resizable: true, + sortable: true, + fixed: true + }, + { + field: 'startDate', + title: 'Start Date', + width: 120, + minWidth: 100, + resizable: true, + sortable: true, + fixed: true, + editor: 'date' + }, + { + field: 'endDate', + title: 'End Date', + width: 120, + minWidth: 100, + resizable: true, + sortable: true, + fixed: true, + editor: 'date' + }, + { + field: 'duration', + title: 'Duration', + width: 80, + minWidth: 60, + resizable: true, + sortable: false, + fixed: true, + align: 'center' + }, + { + field: 'progress', + title: 'Progress', + width: 120, + minWidth: 100, + resizable: true, + sortable: true, + fixed: true, + editor: 'number' + }, + { + field: 'status', + title: 'Status', + width: 100, + minWidth: 80, + resizable: true, + sortable: true, + fixed: true, + editor: 'select', + editorOptions: [ + { value: 'not-started', label: 'Not Started' }, + { value: 'in-progress', label: 'In Progress' }, + { value: 'completed', label: 'Completed' }, + { value: 'on-hold', label: 'On Hold' }, + { value: 'overdue', label: 'Overdue' }, + ] + }, + { + field: 'priority', + title: 'Priority', + width: 100, + minWidth: 80, + resizable: true, + sortable: true, + fixed: true, + editor: 'select', + editorOptions: [ + { value: 'low', label: 'Low' }, + { value: 'medium', label: 'Medium' }, + { value: 'high', label: 'High' }, + { value: 'critical', label: 'Critical' }, + ] + }, +]; + +const AdvancedGanttDemo: React.FC = () => { + const [tasks, setTasks] = useState(generateSampleTasks()); + const [selectedTasks, setSelectedTasks] = useState([]); + const themeMode = useAppSelector(state => state.themeReducer.mode); + + const handleTaskUpdate = (taskId: string, updates: Partial) => { + setTasks(prevTasks => + prevTasks.map(task => + task.id === taskId ? { ...task, ...updates } : task + ) + ); + message.success(`Task "${tasks.find(t => t.id === taskId)?.name}" updated`); + }; + + const handleTaskMove = (taskId: string, newDates: { start: Date; end: Date }) => { + setTasks(prevTasks => + prevTasks.map(task => + task.id === taskId + ? { ...task, startDate: newDates.start, endDate: newDates.end } + : task + ) + ); + message.info(`Task moved: ${newDates.start.toLocaleDateString()} - ${newDates.end.toLocaleDateString()}`); + }; + + const handleProgressChange = (taskId: string, progress: number) => { + setTasks(prevTasks => + prevTasks.map(task => + task.id === taskId ? { ...task, progress } : task + ) + ); + message.info(`Progress updated: ${Math.round(progress)}%`); + }; + + const handleSelectionChange = (selection: any) => { + setSelectedTasks(selection.selectedTasks); + }; + + const resetToSampleData = () => { + setTasks(generateSampleTasks()); + setSelectedTasks([]); + message.info('Gantt chart reset to sample data'); + }; + + const addSampleTask = () => { + const newTask: GanttTask = { + id: `task-${Date.now()}`, + name: `New Task ${tasks.length + 1}`, + startDate: new Date(), + endDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // +7 days + progress: 0, + type: 'task', + status: 'not-started', + priority: 'medium', + level: 0, + }; + setTasks(prev => [...prev, newTask]); + message.success('New task added'); + }; + + const deleteSelectedTasks = () => { + if (selectedTasks.length === 0) { + message.warning('No tasks selected'); + return; + } + + setTasks(prev => prev.filter(task => !selectedTasks.includes(task.id))); + setSelectedTasks([]); + message.success(`${selectedTasks.length} task(s) deleted`); + }; + + const taskStats = useMemo(() => { + const total = tasks.length; + const completed = tasks.filter(t => t.status === 'completed').length; + const inProgress = tasks.filter(t => t.status === 'in-progress').length; + const overdue = tasks.filter(t => t.status === 'overdue').length; + const avgProgress = tasks.reduce((sum, t) => sum + t.progress, 0) / total; + + return { total, completed, inProgress, overdue, avgProgress }; + }, [tasks]); + + return ( +
+ {/* Header */} +
+
+
+
+

+ 🚀 Advanced Gantt Chart Demo +

+

+ Professional Gantt chart with draggable tasks, virtual scrolling, holiday markers, + and performance optimizations for modern project management. +

+
+ +
+ + + + + +
+
+ + {/* Project Statistics */} +
+
+
Total Tasks
+
{taskStats.total}
+
+
+
Completed
+
{taskStats.completed}
+
+
+
In Progress
+
{taskStats.inProgress}
+
+
+
Avg Progress
+
+ {Math.round(taskStats.avgProgress)}% +
+
+
+
+
+ + {/* Gantt Chart */} +
+ +
+ + {/* Feature List */} +
+
+

+ ✨ Advanced Features Demonstrated +

+
+
+

Performance & UX

+
    +
  • • Virtual scrolling for 1000+ tasks
  • +
  • • Smooth 60fps drag & drop
  • +
  • • Debounced updates
  • +
  • • Memory-optimized rendering
  • +
  • • Responsive design
  • +
+
+
+

Gantt Features

+
    +
  • • Draggable task bars
  • +
  • • Resizable task duration
  • +
  • • Progress editing
  • +
  • • Multi-level hierarchy
  • +
  • • Task dependencies
  • +
+
+
+

Timeline & Markers

+
    +
  • • Weekend & holiday markers
  • +
  • • Working day indicators
  • +
  • • Today line
  • +
  • • Multi-tier timeline
  • +
  • • Zoom levels (Year/Month/Week/Day)
  • +
+
+
+

Grid Features

+
    +
  • • Fixed columns layout
  • +
  • • Inline editing
  • +
  • • Column resizing
  • +
  • • Multi-select
  • +
  • • Hierarchical tree view
  • +
+
+
+

UI/UX

+
    +
  • • Dark/Light theme support
  • +
  • • Tailwind CSS styling
  • +
  • • Consistent with Worklenz
  • +
  • • Accessibility features
  • +
  • • Mobile responsive
  • +
+
+
+

Architecture

+
    +
  • • Modern React patterns
  • +
  • • TypeScript safety
  • +
  • • Optimized performance
  • +
  • • Enterprise features
  • +
  • • Best practices 2025
  • +
+
+
+
+
+
+ ); +}; + +export default AdvancedGanttDemo; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/DraggableTaskBar.tsx b/worklenz-frontend/src/components/advanced-gantt/DraggableTaskBar.tsx new file mode 100644 index 00000000..f8d930ca --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/DraggableTaskBar.tsx @@ -0,0 +1,304 @@ +import React, { useState, useRef, useCallback, useMemo } from 'react'; +import { GanttTask, DragState } from '../../types/advanced-gantt.types'; +import { useAppSelector } from '../../hooks/useAppSelector'; +import { themeWiseColor } from '../../utils/themeWiseColor'; +import { useDateCalculations } from '../../utils/gantt-performance'; + +interface DraggableTaskBarProps { + task: GanttTask; + timelineStart: Date; + dayWidth: number; + rowHeight: number; + index: number; + onTaskMove?: (taskId: string, newDates: { start: Date; end: Date }) => void; + onTaskResize?: (taskId: string, newDates: { start: Date; end: Date }) => void; + onProgressChange?: (taskId: string, progress: number) => void; + onTaskClick?: (task: GanttTask) => void; + onTaskDoubleClick?: (task: GanttTask) => void; + enableDragDrop?: boolean; + enableResize?: boolean; + enableProgressEdit?: boolean; + readOnly?: boolean; +} + +const DraggableTaskBar: React.FC = ({ + task, + timelineStart, + dayWidth, + rowHeight, + index, + onTaskMove, + onTaskResize, + onProgressChange, + onTaskClick, + onTaskDoubleClick, + enableDragDrop = true, + enableResize = true, + enableProgressEdit = true, + readOnly = false, +}) => { + const [dragState, setDragState] = useState(null); + const [hoverState, setHoverState] = useState(null); + const taskBarRef = useRef(null); + const themeMode = useAppSelector(state => state.themeReducer.mode); + const { getDaysBetween, addDays } = useDateCalculations(); + + // Calculate task position and dimensions + const taskPosition = useMemo(() => { + const startDays = getDaysBetween(timelineStart, task.startDate); + const duration = getDaysBetween(task.startDate, task.endDate); + + return { + x: startDays * dayWidth, + width: Math.max(dayWidth * 0.5, duration * dayWidth), + y: index * rowHeight + 8, // 8px padding + height: rowHeight - 16, // 16px total padding + }; + }, [task.startDate, task.endDate, timelineStart, dayWidth, rowHeight, index, getDaysBetween]); + + // Theme-aware colors + const colors = useMemo(() => { + const baseColor = task.color || getDefaultTaskColor(task.status); + return { + background: themeWiseColor(baseColor, adjustColorForDarkMode(baseColor), themeMode), + border: themeWiseColor(darkenColor(baseColor, 0.2), lightenColor(baseColor, 0.2), themeMode), + progress: themeWiseColor('#52c41a', '#34d399', themeMode), + text: themeWiseColor('#ffffff', '#f9fafb', themeMode), + hover: themeWiseColor(lightenColor(baseColor, 0.1), darkenColor(baseColor, 0.1), themeMode), + }; + }, [task.color, task.status, themeMode]); + + // Mouse event handlers + const handleMouseDown = useCallback((e: React.MouseEvent, dragType: DragState['dragType']) => { + if (readOnly || !enableDragDrop) return; + + e.preventDefault(); + e.stopPropagation(); + + const rect = taskBarRef.current?.getBoundingClientRect(); + if (!rect) return; + + setDragState({ + isDragging: true, + dragType, + taskId: task.id, + initialPosition: { x: e.clientX, y: e.clientY }, + currentPosition: { x: e.clientX, y: e.clientY }, + initialDates: { start: task.startDate, end: task.endDate }, + initialProgress: task.progress, + snapToGrid: true, + }); + + // Add global mouse event listeners + const handleMouseMove = (moveEvent: MouseEvent) => { + handleMouseMove_Internal(moveEvent, dragType); + }; + + const handleMouseUp = () => { + handleMouseUp_Internal(); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, [readOnly, enableDragDrop, task]); + + const handleMouseMove_Internal = useCallback((e: MouseEvent, dragType: DragState['dragType']) => { + if (!dragState) return; + + const deltaX = e.clientX - dragState.initialPosition.x; + const deltaDays = Math.round(deltaX / dayWidth); + + let newStartDate = task.startDate; + let newEndDate = task.endDate; + + switch (dragType) { + case 'move': + newStartDate = addDays(dragState.initialDates.start, deltaDays); + newEndDate = addDays(dragState.initialDates.end, deltaDays); + break; + + case 'resize-start': + newStartDate = addDays(dragState.initialDates.start, deltaDays); + // Ensure minimum duration + if (newStartDate >= newEndDate) { + newStartDate = addDays(newEndDate, -1); + } + break; + + case 'resize-end': + newEndDate = addDays(dragState.initialDates.end, deltaDays); + // Ensure minimum duration + if (newEndDate <= newStartDate) { + newEndDate = addDays(newStartDate, 1); + } + break; + + case 'progress': + if (enableProgressEdit) { + const progressDelta = deltaX / taskPosition.width; + const newProgress = Math.max(0, Math.min(100, (dragState.initialProgress || 0) + progressDelta * 100)); + onProgressChange?.(task.id, newProgress); + } + return; + } + + // Update drag state + setDragState(prev => prev ? { + ...prev, + currentPosition: { x: e.clientX, y: e.clientY }, + } : null); + + // Call appropriate handler + if (dragType === 'move') { + onTaskMove?.(task.id, { start: newStartDate, end: newEndDate }); + } else if (dragType.startsWith('resize')) { + onTaskResize?.(task.id, { start: newStartDate, end: newEndDate }); + } + }, [dragState, dayWidth, task, taskPosition.width, enableProgressEdit, onTaskMove, onTaskResize, onProgressChange, addDays]); + + const handleMouseUp_Internal = useCallback(() => { + setDragState(null); + }, []); + + const handleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onTaskClick?.(task); + }, [task, onTaskClick]); + + const handleDoubleClick = useCallback((e: React.MouseEvent) => { + e.stopPropagation(); + onTaskDoubleClick?.(task); + }, [task, onTaskDoubleClick]); + + // Render task bar with handles + const renderTaskBar = () => { + const isSelected = false; // TODO: Get from selection state + const isDragging = dragState?.isDragging || false; + + return ( +
setHoverState('task')} + onMouseLeave={() => setHoverState(null)} + onMouseDown={(e) => handleMouseDown(e, 'move')} + > + {/* Progress bar */} +
+ + {/* Task content */} +
+ + {task.name} + + + {/* Duration display for smaller tasks */} + {taskPosition.width < 100 && ( + + {getDaysBetween(task.startDate, task.endDate)}d + + )} +
+ + {/* Resize handles */} + {enableResize && !readOnly && hoverState === 'task' && ( + <> + {/* Left resize handle */} +
handleMouseDown(e, 'resize-start')} + onMouseEnter={() => setHoverState('resize-start')} + /> + + {/* Right resize handle */} +
handleMouseDown(e, 'resize-end')} + onMouseEnter={() => setHoverState('resize-end')} + /> + + )} + + {/* Progress handle */} + {enableProgressEdit && !readOnly && hoverState === 'task' && ( +
handleMouseDown(e, 'progress')} + onMouseEnter={() => setHoverState('progress')} + /> + )} + + {/* Task type indicator */} + {task.type === 'milestone' && ( +
+ )} +
+ ); + }; + + return renderTaskBar(); +}; + +// Helper functions +function getDefaultTaskColor(status: GanttTask['status']): string { + switch (status) { + case 'completed': return '#52c41a'; + case 'in-progress': return '#1890ff'; + case 'overdue': return '#ff4d4f'; + case 'on-hold': return '#faad14'; + default: return '#d9d9d9'; + } +} + +function darkenColor(color: string, amount: number): string { + // Simple color darkening - in a real app, use a proper color manipulation library + return color; +} + +function lightenColor(color: string, amount: number): string { + // Simple color lightening - in a real app, use a proper color manipulation library + return color; +} + +function adjustColorForDarkMode(color: string): string { + // Adjust color for dark mode - in a real app, use a proper color manipulation library + return color; +} + +export default DraggableTaskBar; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/GanttGrid.tsx b/worklenz-frontend/src/components/advanced-gantt/GanttGrid.tsx new file mode 100644 index 00000000..0d5aaed5 --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/GanttGrid.tsx @@ -0,0 +1,492 @@ +import React, { useMemo, useRef, useState, useCallback } from 'react'; +import { GanttTask, ColumnConfig, SelectionState } from '../../types/advanced-gantt.types'; +import { useAppSelector } from '../../hooks/useAppSelector'; +import { themeWiseColor } from '../../utils/themeWiseColor'; +import { ChevronRightIcon, ChevronDownIcon } from '@heroicons/react/24/outline'; +import { CalendarIcon, UserIcon, FlagIcon } from '@heroicons/react/24/solid'; + +interface GanttGridProps { + tasks: GanttTask[]; + columns: ColumnConfig[]; + rowHeight: number; + containerHeight: number; + selection: SelectionState; + enableInlineEdit?: boolean; + enableMultiSelect?: boolean; + onTaskClick?: (task: GanttTask, event: React.MouseEvent) => void; + onTaskDoubleClick?: (task: GanttTask) => void; + onTaskExpand?: (taskId: string) => void; + onSelectionChange?: (selection: SelectionState) => void; + onColumnResize?: (columnField: string, newWidth: number) => void; + onTaskUpdate?: (taskId: string, field: string, value: any) => void; + className?: string; +} + +const GanttGrid: React.FC = ({ + tasks, + columns, + rowHeight, + containerHeight, + selection, + enableInlineEdit = true, + enableMultiSelect = true, + onTaskClick, + onTaskDoubleClick, + onTaskExpand, + onSelectionChange, + onColumnResize, + onTaskUpdate, + className = '', +}) => { + const [editingCell, setEditingCell] = useState<{ taskId: string; field: string } | null>(null); + const [columnWidths, setColumnWidths] = useState>( + columns.reduce((acc, col) => ({ ...acc, [col.field]: col.width }), {}) + ); + const gridRef = useRef(null); + const themeMode = useAppSelector(state => state.themeReducer.mode); + + // Theme-aware colors + const colors = useMemo(() => ({ + background: themeWiseColor('#ffffff', '#1f2937', themeMode), + alternateRow: themeWiseColor('#f9fafb', '#374151', themeMode), + border: themeWiseColor('#e5e7eb', '#4b5563', themeMode), + text: themeWiseColor('#111827', '#f9fafb', themeMode), + textSecondary: themeWiseColor('#6b7280', '#d1d5db', themeMode), + selected: themeWiseColor('#eff6ff', '#1e3a8a', themeMode), + hover: themeWiseColor('#f3f4f6', '#4b5563', themeMode), + headerBg: themeWiseColor('#f8f9fa', '#374151', themeMode), + }), [themeMode]); + + // Calculate total grid width + const totalWidth = useMemo(() => { + return columns.reduce((sum, col) => sum + columnWidths[col.field], 0); + }, [columns, columnWidths]); + + // Handle column resize + const handleColumnResize = useCallback((columnField: string, deltaX: number) => { + const column = columns.find(col => col.field === columnField); + if (!column) return; + + const currentWidth = columnWidths[columnField]; + const newWidth = Math.max(column.minWidth || 60, Math.min(column.maxWidth || 400, currentWidth + deltaX)); + + setColumnWidths(prev => ({ ...prev, [columnField]: newWidth })); + onColumnResize?.(columnField, newWidth); + }, [columns, columnWidths, onColumnResize]); + + // Handle task selection + const handleTaskSelection = useCallback((task: GanttTask, event: React.MouseEvent) => { + const { ctrlKey, shiftKey } = event; + let newSelectedTasks = [...selection.selectedTasks]; + + if (shiftKey && enableMultiSelect && selection.selectedTasks.length > 0) { + // Range selection + const lastSelectedIndex = tasks.findIndex(t => t.id === selection.selectedTasks[selection.selectedTasks.length - 1]); + const currentIndex = tasks.findIndex(t => t.id === task.id); + const [start, end] = [Math.min(lastSelectedIndex, currentIndex), Math.max(lastSelectedIndex, currentIndex)]; + + newSelectedTasks = tasks.slice(start, end + 1).map(t => t.id); + } else if (ctrlKey && enableMultiSelect) { + // Multi selection + if (newSelectedTasks.includes(task.id)) { + newSelectedTasks = newSelectedTasks.filter(id => id !== task.id); + } else { + newSelectedTasks.push(task.id); + } + } else { + // Single selection + newSelectedTasks = [task.id]; + } + + onSelectionChange?.({ + ...selection, + selectedTasks: newSelectedTasks, + focusedTask: task.id, + }); + + onTaskClick?.(task, event); + }, [tasks, selection, enableMultiSelect, onSelectionChange, onTaskClick]); + + // Handle cell editing + const handleCellDoubleClick = useCallback((task: GanttTask, column: ColumnConfig) => { + if (!enableInlineEdit || !column.editor) return; + + setEditingCell({ taskId: task.id, field: column.field }); + }, [enableInlineEdit]); + + const handleCellEditComplete = useCallback((value: any) => { + if (!editingCell) return; + + onTaskUpdate?.(editingCell.taskId, editingCell.field, value); + setEditingCell(null); + }, [editingCell, onTaskUpdate]); + + // Render cell content + const renderCellContent = useCallback((task: GanttTask, column: ColumnConfig) => { + const value = task[column.field as keyof GanttTask]; + const isEditing = editingCell?.taskId === task.id && editingCell?.field === column.field; + + if (isEditing) { + return renderCellEditor(value, column, handleCellEditComplete); + } + + if (column.renderer) { + return column.renderer(value, task); + } + + // Default renderers + switch (column.field) { + case 'name': + return ( +
+ {task.hasChildren && ( + + )} +
+ {getTaskTypeIcon(task.type)} + {task.name} +
+
+ ); + + case 'startDate': + case 'endDate': + return ( +
+ + {(value as Date)?.toLocaleDateString() || '-'} +
+ ); + + case 'assignee': + return task.assignee ? ( +
+ {task.assignee.avatar ? ( + {task.assignee.name} + ) : ( + + )} + {task.assignee.name} +
+ ) : ( + Unassigned + ); + + case 'progress': + return ( +
+
+
+
+ {task.progress}% +
+ ); + + case 'status': + return ( + + {task.status.replace('-', ' ')} + + ); + + case 'priority': + return ( +
+ + {task.priority} +
+ ); + + case 'duration': + const duration = task.duration || Math.ceil((task.endDate.getTime() - task.startDate.getTime()) / (1000 * 60 * 60 * 24)); + return {duration}d; + + default: + return {String(value || '')}; + } + }, [editingCell, onTaskExpand, handleCellEditComplete]); + + // Render header + const renderHeader = () => ( +
+ {columns.map((column, index) => ( +
+ + {column.title} + + + {/* Resize handle */} + {column.resizable && ( + handleColumnResize(column.field, deltaX)} + className="absolute right-0 top-0 h-full w-1 cursor-col-resize hover:bg-blue-500 opacity-0 group-hover:opacity-100" + /> + )} +
+ ))} +
+ ); + + // Render task rows + const renderRows = () => ( +
+ {tasks.map((task, rowIndex) => { + const isSelected = selection.selectedTasks.includes(task.id); + const isFocused = selection.focusedTask === task.id; + + return ( +
handleTaskSelection(task, e)} + onDoubleClick={() => onTaskDoubleClick?.(task)} + > + {columns.map((column) => ( +
handleCellDoubleClick(task, column)} + > + {renderCellContent(task, column)} +
+ ))} +
+ ); + })} +
+ ); + + return ( +
+ {renderHeader()} +
+ {renderRows()} +
+
+ ); +}; + +// Resize handle component +interface ResizeHandleProps { + onResize: (deltaX: number) => void; + className?: string; +} + +const ResizeHandle: React.FC = ({ onResize, className }) => { + const [isDragging, setIsDragging] = useState(false); + const startXRef = useRef(0); + + const handleMouseDown = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + setIsDragging(true); + startXRef.current = e.clientX; + + const handleMouseMove = (moveEvent: MouseEvent) => { + const deltaX = moveEvent.clientX - startXRef.current; + onResize(deltaX); + startXRef.current = moveEvent.clientX; + }; + + const handleMouseUp = () => { + setIsDragging(false); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }, [onResize]); + + return ( +
+ ); +}; + +// Cell editor component +const renderCellEditor = (value: any, column: ColumnConfig, onComplete: (value: any) => void) => { + const [editValue, setEditValue] = useState(value); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter') { + onComplete(editValue); + } else if (e.key === 'Escape') { + onComplete(value); // Cancel editing + } + }; + + const handleBlur = () => { + onComplete(editValue); + }; + + switch (column.editor) { + case 'text': + return ( + setEditValue(e.target.value)} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + className="w-full px-1 py-0.5 border rounded text-sm" + autoFocus + /> + ); + + case 'date': + return ( + setEditValue(new Date(e.target.value))} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + className="w-full px-1 py-0.5 border rounded text-sm" + autoFocus + /> + ); + + case 'number': + return ( + setEditValue(parseFloat(e.target.value))} + onKeyDown={handleKeyDown} + onBlur={handleBlur} + className="w-full px-1 py-0.5 border rounded text-sm" + autoFocus + /> + ); + + case 'select': + return ( + + ); + + default: + return {String(value)}; + } +}; + +// Helper functions +const getTaskTypeIcon = (type: GanttTask['type']) => { + switch (type) { + case 'project': + return
; + case 'milestone': + return
; + default: + return
; + } +}; + +const getStatusColor = (status: GanttTask['status']) => { + switch (status) { + case 'completed': + return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'; + case 'in-progress': + return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200'; + case 'overdue': + return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'; + case 'on-hold': + return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'; + default: + return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'; + } +}; + +const getPriorityColor = (priority: GanttTask['priority']) => { + switch (priority) { + case 'critical': + return 'text-red-600'; + case 'high': + return 'text-orange-500'; + case 'medium': + return 'text-yellow-500'; + case 'low': + return 'text-green-500'; + default: + return 'text-gray-400'; + } +}; + +export default GanttGrid; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/TimelineMarkers.tsx b/worklenz-frontend/src/components/advanced-gantt/TimelineMarkers.tsx new file mode 100644 index 00000000..4dd3a9ad --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/TimelineMarkers.tsx @@ -0,0 +1,295 @@ +import React, { useMemo } from 'react'; +import { Holiday, TimelineConfig } from '../../types/advanced-gantt.types'; +import { useAppSelector } from '../../hooks/useAppSelector'; +import { themeWiseColor } from '../../utils/themeWiseColor'; +import { useDateCalculations } from '../../utils/gantt-performance'; + +interface TimelineMarkersProps { + startDate: Date; + endDate: Date; + dayWidth: number; + containerHeight: number; + timelineConfig: TimelineConfig; + holidays?: Holiday[]; + showWeekends?: boolean; + showHolidays?: boolean; + showToday?: boolean; + className?: string; +} + +const TimelineMarkers: React.FC = ({ + startDate, + endDate, + dayWidth, + containerHeight, + timelineConfig, + holidays = [], + showWeekends = true, + showHolidays = true, + showToday = true, + className = '', +}) => { + const themeMode = useAppSelector(state => state.themeReducer.mode); + const { getDaysBetween, isWeekend, isWorkingDay } = useDateCalculations(); + + // Generate all dates in the timeline + const timelineDates = useMemo(() => { + const dates: Date[] = []; + const totalDays = getDaysBetween(startDate, endDate); + + for (let i = 0; i <= totalDays; i++) { + const date = new Date(startDate); + date.setDate(date.getDate() + i); + dates.push(date); + } + + return dates; + }, [startDate, endDate, getDaysBetween]); + + // Theme-aware colors + const colors = useMemo(() => ({ + weekend: themeWiseColor('rgba(0, 0, 0, 0.05)', 'rgba(255, 255, 255, 0.05)', themeMode), + nonWorkingDay: themeWiseColor('rgba(0, 0, 0, 0.03)', 'rgba(255, 255, 255, 0.03)', themeMode), + holiday: themeWiseColor('rgba(255, 107, 107, 0.1)', 'rgba(255, 107, 107, 0.15)', themeMode), + today: themeWiseColor('rgba(24, 144, 255, 0.15)', 'rgba(64, 169, 255, 0.2)', themeMode), + todayLine: themeWiseColor('#1890ff', '#40a9ff', themeMode), + holidayBorder: themeWiseColor('#ff6b6b', '#ff8787', themeMode), + }), [themeMode]); + + // Check if a date is a holiday + const isHoliday = (date: Date): Holiday | undefined => { + return holidays.find(holiday => { + if (holiday.recurring) { + return holiday.date.getMonth() === date.getMonth() && + holiday.date.getDate() === date.getDate(); + } + return holiday.date.toDateString() === date.toDateString(); + }); + }; + + // Check if date is today + const isToday = (date: Date): boolean => { + const today = new Date(); + return date.toDateString() === today.toDateString(); + }; + + // Render weekend markers + const renderWeekendMarkers = () => { + if (!showWeekends) return null; + + return timelineDates.map((date, index) => { + if (!isWeekend(date)) return null; + + return ( +
+ ); + }); + }; + + // Render non-working day markers + const renderNonWorkingDayMarkers = () => { + return timelineDates.map((date, index) => { + if (isWorkingDay(date, timelineConfig.workingDays)) return null; + + return ( +
+ ); + }); + }; + + // Render holiday markers + const renderHolidayMarkers = () => { + if (!showHolidays) return null; + + return timelineDates.map((date, index) => { + const holiday = isHoliday(date); + if (!holiday) return null; + + const holidayColor = holiday.color || colors.holiday; + + return ( +
+ {/* Holiday tooltip */} +
+
{holiday.name}
+
{date.toLocaleDateString()}
+
+
+ + {/* Holiday icon */} +
+
+
+
+ ); + }); + }; + + // Render today marker + const renderTodayMarker = () => { + if (!showToday) return null; + + const todayIndex = timelineDates.findIndex(date => isToday(date)); + if (todayIndex === -1) return null; + + return ( +
+ {/* Today line */} +
+ + {/* Today label */} +
+ Today +
+
+ ); + }; + + // Render time period markers (quarters, months, etc.) + const renderTimePeriodMarkers = () => { + const markers: React.ReactNode[] = []; + const currentDate = new Date(startDate); + currentDate.setDate(1); // Start of month + + while (currentDate <= endDate) { + const daysSinceStart = getDaysBetween(startDate, currentDate); + const isQuarterStart = currentDate.getMonth() % 3 === 0 && currentDate.getDate() === 1; + const isYearStart = currentDate.getMonth() === 0 && currentDate.getDate() === 1; + + if (isYearStart) { + markers.push( +
+
+ {currentDate.getFullYear()} +
+
+ ); + } else if (isQuarterStart) { + markers.push( +
+
+ Q{Math.floor(currentDate.getMonth() / 3) + 1} +
+
+ ); + } + + // Move to next month + currentDate.setMonth(currentDate.getMonth() + 1); + } + + return markers; + }; + + return ( +
+ {renderNonWorkingDayMarkers()} + {renderWeekendMarkers()} + {renderHolidayMarkers()} + {renderTodayMarker()} + {renderTimePeriodMarkers()} +
+ ); +}; + +// Holiday presets for common countries +export const holidayPresets = { + US: [ + { date: new Date(2024, 0, 1), name: "New Year's Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 0, 15), name: "Martin Luther King Jr. Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 1, 19), name: "Presidents' Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 4, 27), name: "Memorial Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 5, 19), name: "Juneteenth", type: 'national' as const, recurring: true }, + { date: new Date(2024, 6, 4), name: "Independence Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 8, 2), name: "Labor Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 9, 14), name: "Columbus Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 10, 11), name: "Veterans Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 10, 28), name: "Thanksgiving", type: 'national' as const, recurring: true }, + { date: new Date(2024, 11, 25), name: "Christmas Day", type: 'national' as const, recurring: true }, + ], + + UK: [ + { date: new Date(2024, 0, 1), name: "New Year's Day", type: 'national' as const, recurring: true }, + { date: new Date(2024, 2, 29), name: "Good Friday", type: 'religious' as const, recurring: false }, + { date: new Date(2024, 3, 1), name: "Easter Monday", type: 'religious' as const, recurring: false }, + { date: new Date(2024, 4, 6), name: "Early May Bank Holiday", type: 'national' as const, recurring: true }, + { date: new Date(2024, 4, 27), name: "Spring Bank Holiday", type: 'national' as const, recurring: true }, + { date: new Date(2024, 7, 26), name: "Summer Bank Holiday", type: 'national' as const, recurring: true }, + { date: new Date(2024, 11, 25), name: "Christmas Day", type: 'religious' as const, recurring: true }, + { date: new Date(2024, 11, 26), name: "Boxing Day", type: 'national' as const, recurring: true }, + ], +}; + +// Working day presets +export const workingDayPresets = { + standard: [1, 2, 3, 4, 5], // Monday to Friday + middle_east: [0, 1, 2, 3, 4], // Sunday to Thursday + six_day: [1, 2, 3, 4, 5, 6], // Monday to Saturday + four_day: [1, 2, 3, 4], // Monday to Thursday +}; + +export default TimelineMarkers; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/VirtualScrollContainer.tsx b/worklenz-frontend/src/components/advanced-gantt/VirtualScrollContainer.tsx new file mode 100644 index 00000000..0e855bc3 --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/VirtualScrollContainer.tsx @@ -0,0 +1,372 @@ +import React, { useRef, useEffect, useState, useCallback, ReactNode } from 'react'; +import { useThrottle, usePerformanceMonitoring } from '../../utils/gantt-performance'; +import { useAppSelector } from '../../hooks/useAppSelector'; + +interface VirtualScrollContainerProps { + items: any[]; + itemHeight: number; + containerHeight: number; + containerWidth?: number; + overscan?: number; + horizontal?: boolean; + children: (item: any, index: number, style: React.CSSProperties) => ReactNode; + onScroll?: (scrollLeft: number, scrollTop: number) => void; + className?: string; + style?: React.CSSProperties; +} + +const VirtualScrollContainer: React.FC = ({ + items, + itemHeight, + containerHeight, + containerWidth = 0, + overscan = 5, + horizontal = false, + children, + onScroll, + className = '', + style = {}, +}) => { + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [scrollLeft, setScrollLeft] = useState(0); + const { startMeasure, endMeasure, recordMetric } = usePerformanceMonitoring(); + const themeMode = useAppSelector(state => state.themeReducer.mode); + + // Calculate visible range + const totalHeight = items.length * itemHeight; + const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan); + const endIndex = Math.min( + items.length - 1, + Math.ceil((scrollTop + containerHeight) / itemHeight) + overscan + ); + const visibleItems = items.slice(startIndex, endIndex + 1); + const offsetY = startIndex * itemHeight; + + // Throttled scroll handler + const throttledScrollHandler = useThrottle( + useCallback((event: Event) => { + const target = event.target as HTMLDivElement; + const newScrollTop = target.scrollTop; + const newScrollLeft = target.scrollLeft; + + setScrollTop(newScrollTop); + setScrollLeft(newScrollLeft); + onScroll?.(newScrollLeft, newScrollTop); + }, [onScroll]), + 16 // ~60fps + ); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + container.addEventListener('scroll', throttledScrollHandler, { passive: true }); + + return () => { + container.removeEventListener('scroll', throttledScrollHandler); + }; + }, [throttledScrollHandler]); + + // Performance monitoring + useEffect(() => { + startMeasure('virtualScroll'); + recordMetric('visibleTaskCount', visibleItems.length); + recordMetric('taskCount', items.length); + endMeasure('virtualScroll'); + }, [visibleItems.length, items.length, startMeasure, endMeasure, recordMetric]); + + const renderVisibleItems = () => { + return visibleItems.map((item, virtualIndex) => { + const actualIndex = startIndex + virtualIndex; + const itemStyle: React.CSSProperties = { + position: 'absolute', + top: horizontal ? 0 : actualIndex * itemHeight, + left: horizontal ? actualIndex * itemHeight : 0, + height: horizontal ? '100%' : itemHeight, + width: horizontal ? itemHeight : '100%', + transform: horizontal ? 'none' : `translateY(${offsetY}px)`, + }; + + return ( +
+ {children(item, actualIndex, itemStyle)} +
+ ); + }); + }; + + return ( +
+ {/* Spacer to maintain scroll height */} +
+ {/* Visible items container */} +
+ {renderVisibleItems()} +
+
+
+ ); +}; + +// Grid virtual scrolling component for both rows and columns +interface VirtualGridProps { + data: any[][]; + rowHeight: number; + columnWidth: number | number[]; + containerHeight: number; + containerWidth: number; + overscan?: number; + children: (item: any, rowIndex: number, colIndex: number, style: React.CSSProperties) => ReactNode; + onScroll?: (scrollLeft: number, scrollTop: number) => void; + className?: string; +} + +export const VirtualGrid: React.FC = ({ + data, + rowHeight, + columnWidth, + containerHeight, + containerWidth, + overscan = 3, + children, + onScroll, + className = '', +}) => { + const containerRef = useRef(null); + const [scrollTop, setScrollTop] = useState(0); + const [scrollLeft, setScrollLeft] = useState(0); + + const rowCount = data.length; + const colCount = data[0]?.length || 0; + + // Calculate column positions for variable width columns + const columnWidths = Array.isArray(columnWidth) ? columnWidth : new Array(colCount).fill(columnWidth); + const columnPositions = columnWidths.reduce((acc, width, index) => { + acc[index] = index === 0 ? 0 : acc[index - 1] + columnWidths[index - 1]; + return acc; + }, {} as Record); + + const totalWidth = columnWidths.reduce((sum, width) => sum + width, 0); + const totalHeight = rowCount * rowHeight; + + // Calculate visible ranges + const startRowIndex = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan); + const endRowIndex = Math.min(rowCount - 1, Math.ceil((scrollTop + containerHeight) / rowHeight) + overscan); + + const startColIndex = Math.max(0, findColumnIndex(scrollLeft) - overscan); + const endColIndex = Math.min(colCount - 1, findColumnIndex(scrollLeft + containerWidth) + overscan); + + function findColumnIndex(position: number): number { + for (let i = 0; i < colCount; i++) { + if (columnPositions[i] <= position && position < columnPositions[i] + columnWidths[i]) { + return i; + } + } + return colCount - 1; + } + + const throttledScrollHandler = useThrottle( + useCallback((event: Event) => { + const target = event.target as HTMLDivElement; + const newScrollTop = target.scrollTop; + const newScrollLeft = target.scrollLeft; + + setScrollTop(newScrollTop); + setScrollLeft(newScrollLeft); + onScroll?.(newScrollLeft, newScrollTop); + }, [onScroll]), + 16 + ); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + container.addEventListener('scroll', throttledScrollHandler, { passive: true }); + + return () => { + container.removeEventListener('scroll', throttledScrollHandler); + }; + }, [throttledScrollHandler]); + + const renderVisibleCells = () => { + const cells: ReactNode[] = []; + + for (let rowIndex = startRowIndex; rowIndex <= endRowIndex; rowIndex++) { + for (let colIndex = startColIndex; colIndex <= endColIndex; colIndex++) { + const item = data[rowIndex]?.[colIndex]; + if (!item) continue; + + const cellStyle: React.CSSProperties = { + position: 'absolute', + top: rowIndex * rowHeight, + left: columnPositions[colIndex], + height: rowHeight, + width: columnWidths[colIndex], + }; + + cells.push( +
+ {children(item, rowIndex, colIndex, cellStyle)} +
+ ); + } + } + + return cells; + }; + + return ( +
+
+ {renderVisibleCells()} +
+
+ ); +}; + +// Timeline virtual scrolling component +interface VirtualTimelineProps { + startDate: Date; + endDate: Date; + dayWidth: number; + containerWidth: number; + containerHeight: number; + overscan?: number; + children: (date: Date, index: number, style: React.CSSProperties) => ReactNode; + onScroll?: (scrollLeft: number) => void; + className?: string; +} + +export const VirtualTimeline: React.FC = ({ + startDate, + endDate, + dayWidth, + containerWidth, + containerHeight, + overscan = 10, + children, + onScroll, + className = '', +}) => { + const containerRef = useRef(null); + const [scrollLeft, setScrollLeft] = useState(0); + + const totalDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)); + const totalWidth = totalDays * dayWidth; + + const startDayIndex = Math.max(0, Math.floor(scrollLeft / dayWidth) - overscan); + const endDayIndex = Math.min(totalDays - 1, Math.ceil((scrollLeft + containerWidth) / dayWidth) + overscan); + + const throttledScrollHandler = useThrottle( + useCallback((event: Event) => { + const target = event.target as HTMLDivElement; + const newScrollLeft = target.scrollLeft; + setScrollLeft(newScrollLeft); + onScroll?.(newScrollLeft); + }, [onScroll]), + 16 + ); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + + container.addEventListener('scroll', throttledScrollHandler, { passive: true }); + + return () => { + container.removeEventListener('scroll', throttledScrollHandler); + }; + }, [throttledScrollHandler]); + + const renderVisibleDays = () => { + const days: ReactNode[] = []; + + for (let dayIndex = startDayIndex; dayIndex <= endDayIndex; dayIndex++) { + const date = new Date(startDate); + date.setDate(date.getDate() + dayIndex); + + const dayStyle: React.CSSProperties = { + position: 'absolute', + left: dayIndex * dayWidth, + top: 0, + width: dayWidth, + height: '100%', + }; + + days.push( +
+ {children(date, dayIndex, dayStyle)} +
+ ); + } + + return days; + }; + + return ( +
+
+ {renderVisibleDays()} +
+
+ ); +}; + +export default VirtualScrollContainer; \ No newline at end of file diff --git a/worklenz-frontend/src/components/advanced-gantt/index.ts b/worklenz-frontend/src/components/advanced-gantt/index.ts new file mode 100644 index 00000000..7d27b5bc --- /dev/null +++ b/worklenz-frontend/src/components/advanced-gantt/index.ts @@ -0,0 +1,17 @@ +// Main Components +export { default as AdvancedGanttChart } from './AdvancedGanttChart'; +export { default as AdvancedGanttDemo } from './AdvancedGanttDemo'; + +// Core Components +export { default as GanttGrid } from './GanttGrid'; +export { default as DraggableTaskBar } from './DraggableTaskBar'; +export { default as TimelineMarkers, holidayPresets, workingDayPresets } from './TimelineMarkers'; + +// Utility Components +export { default as VirtualScrollContainer, VirtualGrid, VirtualTimeline } from './VirtualScrollContainer'; + +// Types +export * from '../../types/advanced-gantt.types'; + +// Performance Utilities +export * from '../../utils/gantt-performance'; \ No newline at end of file diff --git a/worklenz-frontend/src/components/common/invite-team-members/invite-team-members.tsx b/worklenz-frontend/src/components/common/invite-team-members/invite-team-members.tsx index da36593d..ab4ff36a 100644 --- a/worklenz-frontend/src/components/common/invite-team-members/invite-team-members.tsx +++ b/worklenz-frontend/src/components/common/invite-team-members/invite-team-members.tsx @@ -1,4 +1,4 @@ -import { AutoComplete, Button, Drawer, Flex, Form, message, Select, Spin, Typography } from '@/shared/antd-imports'; +import { AutoComplete, Button, Drawer, Flex, Form, message, Modal, Select, Spin, Typography } from '@/shared/antd-imports'; import { useAppSelector } from '@/hooks/useAppSelector'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { @@ -11,6 +11,7 @@ import { jobTitlesApiService } from '@/api/settings/job-titles/job-titles.api.se import { IJobTitle } from '@/types/job.types'; import { teamMembersApiService } from '@/api/team-members/teamMembers.api.service'; import { ITeamMemberCreateRequest } from '@/types/teamMembers/team-member-create-request'; +import { LinkOutlined } from '@ant-design/icons'; interface FormValues { email: string[]; @@ -87,23 +88,33 @@ const InviteTeamMembers = () => { }; return ( - {t('addMemberDrawerTitle')} } open={isDrawerOpen} - onClose={handleClose} + onCancel={handleClose} destroyOnClose afterOpenChange={visible => visible && handleSearch('')} width={400} loading={loading} footer={ - - + + {/* */} + + + } > @@ -176,7 +187,7 @@ const InviteTeamMembers = () => { /> - + ); }; diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx index ae1e3c35..ef587923 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/EnhancedKanbanBoardNativeDnD.tsx @@ -8,7 +8,7 @@ import ImprovedTaskFilters from '../../task-management/improved-task-filters'; import Card from 'antd/es/card'; import Spin from 'antd/es/spin'; import Empty from 'antd/es/empty'; -import { reorderGroups, reorderEnhancedKanbanGroups, reorderTasks, reorderEnhancedKanbanTasks, fetchEnhancedKanbanLabels, fetchEnhancedKanbanGroups, fetchEnhancedKanbanTaskAssignees } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { reorderGroups, reorderEnhancedKanbanGroups, reorderTasks, reorderEnhancedKanbanTasks, fetchEnhancedKanbanLabels, fetchEnhancedKanbanGroups, fetchEnhancedKanbanTaskAssignees, updateEnhancedKanbanTaskPriority } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import { fetchStatusesCategories } from '@/features/taskAttributes/taskStatusSlice'; import { useAppSelector } from '@/hooks/useAppSelector'; import KanbanGroup from './KanbanGroup'; @@ -21,8 +21,14 @@ import alertService from '@/services/alerts/alertService'; import logger from '@/utils/errorLogger'; import { checkTaskDependencyStatus } from '@/utils/check-task-dependency-status'; import { useTaskSocketHandlers } from '@/hooks/useTaskSocketHandlers'; +import { phasesApiService } from '@/api/taskAttributes/phases/phases.api.service'; +import { ITaskListGroup } from '@/types/tasks/taskList.types'; +import { fetchPhasesByProjectId, updatePhaseListOrder } from '@/features/projects/singleProject/phase/phases.slice'; +import { useTranslation } from 'react-i18next'; +import { ITaskListPriorityChangeResponse } from '@/types/tasks/task-list-priority.types'; const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ projectId }) => { + const { t } = useTranslation('kanban-board'); const dispatch = useDispatch(); const authService = useAuthService(); const { socket } = useSocket(); @@ -34,6 +40,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project loadingGroups, error, } = useSelector((state: RootState) => state.enhancedKanbanReducer); + const { phaseList, loadingPhases } = useAppSelector(state => state.phaseReducer); const [draggedGroupId, setDraggedGroupId] = useState(null); const [draggedTaskId, setDraggedTaskId] = useState(null); const [draggedTaskGroupId, setDraggedTaskGroupId] = useState(null); @@ -56,6 +63,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project if (!statusCategories.length) { dispatch(fetchStatusesCategories() as any); } + if (groupBy === 'phase' && !phaseList.length) { + dispatch(fetchPhasesByProjectId(projectId) as any); + } }, [dispatch, projectId]); // Reset drag state if taskGroups changes (e.g., real-time update) useEffect(() => { @@ -72,6 +82,9 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project setDraggedGroupId(groupId); setDragType('group'); e.dataTransfer.effectAllowed = 'move'; + try { + e.dataTransfer.setData('text/plain', groupId); + } catch {} }; const handleGroupDragOver = (e: React.DragEvent) => { if (dragType !== 'group') return; @@ -90,19 +103,35 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project reorderedGroups.splice(toIdx, 0, moved); dispatch(reorderGroups({ fromIndex: fromIdx, toIndex: toIdx, reorderedGroups })); dispatch(reorderEnhancedKanbanGroups({ fromIndex: fromIdx, toIndex: toIdx, reorderedGroups }) as any); - // API call for group order try { - const columnOrder = reorderedGroups.map(group => group.id); - const requestBody = { status_order: columnOrder }; - const response = await statusApiService.updateStatusOrder(requestBody, projectId); - if (!response.done) { - // Revert the change if API call fails - const revertedGroups = [...reorderedGroups]; - const [movedBackGroup] = revertedGroups.splice(toIdx, 1); - revertedGroups.splice(fromIdx, 0, movedBackGroup); - dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups })); - alertService.error('Failed to update column order', 'Please try again'); + if (groupBy === 'status') { + const columnOrder = reorderedGroups.map(group => group.id); + const requestBody = { status_order: columnOrder }; + const response = await statusApiService.updateStatusOrder(requestBody, projectId); + if (!response.done) { + // Revert the change if API call fails + const revertedGroups = [...reorderedGroups]; + const [movedBackGroup] = revertedGroups.splice(toIdx, 1); + revertedGroups.splice(fromIdx, 0, movedBackGroup); + dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups })); + alertService.error(t('failedToUpdateColumnOrder'), t('pleaseTryAgain')); + } + } else if (groupBy === 'phase') { + const newPhaseList = [...phaseList]; + const [movedItem] = newPhaseList.splice(fromIdx, 1); + newPhaseList.splice(toIdx, 0, movedItem); + dispatch(updatePhaseListOrder(newPhaseList)); + const requestBody = { + from_index: fromIdx, + to_index: toIdx, + phases: newPhaseList, + project_id: projectId, + }; + const response = await phasesApiService.updatePhaseOrder(projectId, requestBody); + if (!response.done) { + alertService.error(t('failedToUpdatePhaseOrder'), t('pleaseTryAgain')); + } } } catch (error) { // Revert the change if API call fails @@ -110,7 +139,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project const [movedBackGroup] = revertedGroups.splice(toIdx, 1); revertedGroups.splice(fromIdx, 0, movedBackGroup); dispatch(reorderGroups({ fromIndex: toIdx, toIndex: fromIdx, reorderedGroups: revertedGroups })); - alertService.error('Failed to update column order', 'Please try again'); + alertService.error(t('failedToUpdateColumnOrder'), t('pleaseTryAgain')); logger.error('Failed to update column order', error); } @@ -118,12 +147,47 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project setDragType(null); }; + // Utility to recalculate all task orders for all groups + function getAllTaskUpdates(allGroups: ITaskListGroup[], groupBy: string) { + const taskUpdates: Array<{ + task_id: string | undefined; + sort_order: number; + status_id?: string; + priority_id?: string; + phase_id?: string; + }> = []; + let currentSortOrder = 0; + for (const group of allGroups) { + for (const task of group.tasks) { + const update: { + task_id: string | undefined; + sort_order: number; + status_id?: string; + priority_id?: string; + phase_id?: string; + } = { + task_id: task.id, + sort_order: currentSortOrder, + }; + if (groupBy === 'status') update.status_id = group.id; + else if (groupBy === 'priority') update.priority_id = group.id; + else if (groupBy === 'phase' && group.name !== 'Unmapped') update.phase_id = group.id; + taskUpdates.push(update); + currentSortOrder++; + } + } + return taskUpdates; + } + // Task drag handlers const handleTaskDragStart = (e: React.DragEvent, taskId: string, groupId: string) => { setDraggedTaskId(taskId); setDraggedTaskGroupId(groupId); setDragType('task'); e.dataTransfer.effectAllowed = 'move'; + try { + e.dataTransfer.setData('text/plain', taskId); + } catch {} }; const handleTaskDragOver = (e: React.DragEvent, groupId: string, taskIdx: number | null) => { if (dragType !== 'task') return; @@ -157,8 +221,8 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project const canContinue = await checkTaskDependencyStatus(movedTask.id, targetGroupId); if (!canContinue) { alertService.error( - 'Task is not completed', - 'Please complete the task dependencies before proceeding' + t('taskNotCompleted'), + t('completeTaskDependencies') ); return; } @@ -168,6 +232,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project let insertIdx = hoveredTaskIdx; // Handle same group reordering + let newTaskGroups = [...taskGroups]; if (sourceGroup.id === targetGroup.id) { // Create a single updated array for the same group const updatedTasks = [...sourceGroup.tasks]; @@ -182,7 +247,6 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project if (insertIdx > updatedTasks.length) insertIdx = updatedTasks.length; updatedTasks.splice(insertIdx, 0, movedTask); // Insert at new position - dispatch(reorderTasks({ activeGroupId: sourceGroup.id, overGroupId: targetGroup.id, @@ -201,6 +265,8 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project updatedSourceTasks: updatedTasks, updatedTargetTasks: updatedTasks, }) as any); + // Update newTaskGroups for socket emit + newTaskGroups = newTaskGroups.map(g => g.id === sourceGroup.id ? { ...g, tasks: updatedTasks } : g); } else { // Handle cross-group reordering const updatedSourceTasks = [...sourceGroup.tasks]; @@ -229,34 +295,33 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project updatedSourceTasks, updatedTargetTasks, }) as any); + // Update newTaskGroups for socket emit + newTaskGroups = newTaskGroups.map(g => { + if (g.id === sourceGroup.id) return { ...g, tasks: updatedSourceTasks }; + if (g.id === targetGroup.id) return { ...g, tasks: updatedTargetTasks }; + return g; + }); } - // Socket emit for task order + // Socket emit for full task order if (socket && projectId && teamId && movedTask) { - let toSortOrder = -1; - let toLastIndex = false; - if (insertIdx === targetGroup.tasks.length) { - toSortOrder = -1; - toLastIndex = true; - } else if (targetGroup.tasks[insertIdx]) { - const sortOrder = targetGroup.tasks[insertIdx].sort_order; - toSortOrder = typeof sortOrder === 'number' ? sortOrder : 0; - toLastIndex = false; - } else if (targetGroup.tasks.length > 0) { - const lastSortOrder = targetGroup.tasks[targetGroup.tasks.length - 1].sort_order; - toSortOrder = typeof lastSortOrder === 'number' ? lastSortOrder : 0; - toLastIndex = false; - } + const taskUpdates = getAllTaskUpdates(newTaskGroups, groupBy); socket.emit(SocketEvents.TASK_SORT_ORDER_CHANGE.toString(), { project_id: projectId, - from_index: movedTask.sort_order ?? 0, - to_index: toSortOrder, - to_last_index: toLastIndex, + group_by: groupBy || 'status', + task_updates: taskUpdates, from_group: sourceGroup.id, to_group: targetGroup.id, - group_by: groupBy || 'status', - task: movedTask, team_id: teamId, + from_index: taskIdx, + to_index: insertIdx, + to_last_index: insertIdx === (targetGroup.id === sourceGroup.id ? (newTaskGroups.find(g => g.id === targetGroup.id)?.tasks.length || 0) - 1 : targetGroup.tasks.length), + task: { + id: movedTask.id, + project_id: movedTask.project_id || projectId, + status: movedTask.status || '', + priority: movedTask.priority || '', + } }); // Emit progress update if status changed @@ -271,6 +336,22 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project }) ); } + if (groupBy === 'priority' && movedTask.id) { + socket?.emit( + SocketEvents.TASK_PRIORITY_CHANGE.toString(), + JSON.stringify({ + task_id: movedTask.id, + priority_id: targetGroupId, + team_id: teamId, + }) + ); + socket?.once( + SocketEvents.TASK_PRIORITY_CHANGE.toString(), + (data: ITaskListPriorityChangeResponse) => { + dispatch(updateEnhancedKanbanTaskPriority(data)); + } + ); + } } setDraggedTaskId(null); @@ -291,7 +372,7 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project if (error) { return ( - + ); } @@ -299,21 +380,21 @@ const EnhancedKanbanBoardNativeDnD: React.FC<{ projectId: string }> = ({ project return ( <>
- Loading filters...
}> + {t('loadingFilters')}
}>
{loadingGroups ? ( -
-
-
-
-
-
+
+
+
+
+
+
) : taskGroups.length === 0 ? ( - + ) : (
diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx index f5e443d2..9338b48b 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/KanbanGroup.tsx @@ -25,6 +25,7 @@ import { IGroupBy, } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import { createPortal } from 'react-dom'; +import { Modal } from 'antd'; // Simple Portal component const Portal: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -218,7 +219,42 @@ const KanbanGroup: React.FC = memo(({ }; const handleDelete = () => { - setShowDeleteConfirm(true); + if (groupBy === IGroupBy.STATUS) { + Modal.confirm({ + title: t('deleteStatusTitle'), + content: t('deleteStatusContent'), + okText: t('deleteTaskConfirm'), + okType: 'danger', + cancelText: t('deleteTaskCancel'), + centered: true, + onOk: async () => { + await handleDeleteSection(); + }, + }); + } else if (groupBy === IGroupBy.PHASE) { + Modal.confirm({ + title: t('deletePhaseTitle'), + content: t('deletePhaseContent'), + okText: t('deleteTaskConfirm'), + okType: 'danger', + cancelText: t('deleteTaskCancel'), + centered: true, + onOk: async () => { + await handleDeleteSection(); + }, + }); + } else { + Modal.confirm({ + title: t('deleteConfirmationTitle'), + okText: t('deleteTaskConfirm'), + okType: 'danger', + cancelText: t('deleteTaskCancel'), + centered: true, + onOk: async () => { + await handleDeleteSection(); + }, + }); + } setShowDropdown(false); }; @@ -419,56 +455,7 @@ const KanbanGroup: React.FC = memo(({
{/* Simple Delete Confirmation */} - {showDeleteConfirm && ( - -
setShowDeleteConfirm(false)} - > -
e.stopPropagation()} - > -
-
-
- - - -
-
-

- {t('deleteConfirmationTitle')} -

-
-
-
- - -
-
-
-
-
- )} + {/* Portal-based confirmation removed, now handled by Modal.confirm */}
{/* Create card at top */} {showNewCardTop && ( diff --git a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx index bf499a12..daa8793c 100644 --- a/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx +++ b/worklenz-frontend/src/components/enhanced-kanban/EnhancedKanbanBoardNativeDnD/TaskCard.tsx @@ -14,8 +14,11 @@ import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; import { getUserSession } from '@/utils/session-helper'; import { themeWiseColor } from '@/utils/themeWiseColor'; -import { toggleTaskExpansion, fetchBoardSubTasks } from '@/features/enhanced-kanban/enhanced-kanban.slice'; +import { toggleTaskExpansion, fetchBoardSubTasks, deleteTask as deleteKanbanTask, updateEnhancedKanbanSubtask } from '@/features/enhanced-kanban/enhanced-kanban.slice'; import TaskProgressCircle from './TaskProgressCircle'; +import { Button, Modal } from 'antd'; +import { DeleteOutlined } from '@ant-design/icons'; +import { tasksApiService } from '@/api/tasks/tasks.api.service'; // Simple Portal component const Portal: React.FC<{ children: React.ReactNode }> = ({ children }) => { @@ -70,6 +73,9 @@ const TaskCard: React.FC = memo(({ const d = selectedDate || new Date(); return new Date(d.getFullYear(), d.getMonth(), 1); }); + const [contextMenu, setContextMenu] = useState<{ visible: boolean; x: number; y: number }>({ visible: false, x: 0, y: 0 }); + const contextMenuRef = useRef(null); + const [selectedTask, setSelectedTask] = useState(null); useEffect(() => { setSelectedDate(task.end_date ? new Date(task.end_date) : null); @@ -102,6 +108,21 @@ const TaskCard: React.FC = memo(({ } }, [showDatePicker]); + // Hide context menu on click elsewhere + useEffect(() => { + const handleClick = (e: MouseEvent) => { + if (contextMenuRef.current && !contextMenuRef.current.contains(e.target as Node)) { + setContextMenu({ ...contextMenu, visible: false }); + } + }; + if (contextMenu.visible) { + document.addEventListener('mousedown', handleClick); + } + return () => { + document.removeEventListener('mousedown', handleClick); + }; + }, [contextMenu]); + const handleCardClick = useCallback((e: React.MouseEvent, id: string) => { e.stopPropagation(); dispatch(setSelectedTaskId(id)); @@ -178,6 +199,48 @@ const TaskCard: React.FC = memo(({ handleSubTaskExpand(); }, [handleSubTaskExpand]); + // Delete logic (similar to task-drawer-header) + const handleDeleteTask = async (task: IProjectTask | null) => { + if (!task || !task.id) return; + Modal.confirm({ + title: t('deleteTaskTitle'), + content: t('deleteTaskContent'), + okText: t('deleteTaskConfirm'), + okType: 'danger', + cancelText: t('deleteTaskCancel'), + centered: true, + onOk: async () => { + if (!task.id) return; + const res = await tasksApiService.deleteTask(task.id); + if (res.done) { + dispatch(setSelectedTaskId(null)); + if (task.is_sub_task) { + dispatch(updateEnhancedKanbanSubtask({ + sectionId: '', + subtask: { id: task.id , parent_task_id: task.parent_task_id || '', manual_progress: false }, + mode: 'delete', + })); + } else { + dispatch(deleteKanbanTask(task.id)); + } + dispatch(setShowTaskDrawer(false)); + if (task.parent_task_id) { + socket?.emit( + SocketEvents.GET_TASK_PROGRESS.toString(), + task.parent_task_id + ); + } + } + setContextMenu({ visible: false, x: 0, y: 0 }); + setSelectedTask(null); + }, + onCancel: () => { + setContextMenu({ visible: false, x: 0, y: 0 }); + setSelectedTask(null); + }, + }); + }; + // Calendar rendering helpers const year = calendarMonth.getFullYear(); const month = calendarMonth.getMonth(); @@ -202,7 +265,37 @@ const TaskCard: React.FC = memo(({ return ( <> -
+ {/* Context menu for delete */} + {contextMenu.visible && ( +
+ +
+ )} +
{/* Progress circle at top right */}
@@ -221,6 +314,11 @@ const TaskCard: React.FC = memo(({ onDrop={e => onTaskDrop(e, groupId, idx)} onDragEnd={onDragEnd} // <-- add this onClick={e => handleCardClick(e, task.id!)} + onContextMenu={e => { + e.preventDefault(); + setContextMenu({ visible: true, x: e.clientX, y: e.clientY }); + setSelectedTask(task); + }} >
@@ -447,7 +545,14 @@ const TaskCard: React.FC = memo(({ {!task.sub_tasks_loading && Array.isArray(task.sub_tasks) && task.sub_tasks.length > 0 && (
    {task.sub_tasks.map(sub => ( -
  • handleCardClick(e, sub.id!)} className="flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-50 dark:hover:bg-gray-800"> +
  • handleCardClick(e, sub.id!)} + className="flex items-center gap-2 px-2 py-1 rounded hover:bg-gray-50 dark:hover:bg-gray-800" + onContextMenu={e => { + e.preventDefault(); + setContextMenu({ visible: true, x: e.clientX, y: e.clientY }); + setSelectedTask(sub); + }}> {sub.priority_color || sub.priority_color_dark ? ( void; + onUpdate?: (updates: Partial) => void; +} + +const PhaseModal: React.FC = ({ + visible, + phase, + onClose, + onUpdate, +}) => { + const [isEditing, setIsEditing] = useState(false); + const [form] = Form.useForm(); + + // Theme support + const themeMode = useAppSelector(state => state.themeReducer.mode); + const isDarkMode = themeMode === 'dark'; + + if (!phase) return null; + + const handleEdit = () => { + setIsEditing(true); + form.setFieldsValue({ + name: phase.name, + description: phase.description, + startDate: dayjs(phase.startDate), + endDate: dayjs(phase.endDate), + status: phase.status, + }); + }; + + const handleSave = async () => { + try { + const values = await form.validateFields(); + const updates: Partial = { + name: values.name, + description: values.description, + startDate: values.startDate.toDate(), + endDate: values.endDate.toDate(), + status: values.status, + }; + + onUpdate?.(updates); + setIsEditing(false); + } catch (error) { + console.error('Validation failed:', error); + } + }; + + const handleCancel = () => { + setIsEditing(false); + form.resetFields(); + }; + + const getStatusColor = (status: string) => { + switch (status) { + case 'completed': return 'success'; + case 'in-progress': return 'processing'; + case 'on-hold': return 'warning'; + default: return 'default'; + } + }; + + const getPriorityColor = (priority: string) => { + switch (priority) { + case 'high': return 'red'; + case 'medium': return 'orange'; + case 'low': return 'green'; + default: return 'default'; + } + }; + + const getTaskStatusIcon = (status: string) => { + switch (status) { + case 'done': return ; + case 'in-progress': return ; + default: return ; + } + }; + + return ( + + + + {isEditing ? ( + + + + ) : ( +

    + {phase.name} +

    + )} +
    + + {isEditing ? ( + <> + + + + ) : ( + + )} + +
+ } + open={visible} + onCancel={onClose} + width={800} + footer={null} + className="dark:bg-gray-800" + > +
+
+ {isEditing ? ( + Description}> +