From a865dea7192f7525e50c6ce6ee073dd53e9f0605 Mon Sep 17 00:00:00 2001 From: Gabriel Menezes Date: Mon, 9 Nov 2026 11:43:37 -0400 Subject: [PATCH] feat(tasks): migrate task statuses to db table with org scope and dynamic board columns --- .../beetask/entities/task/Task.java | 7 +- .../beetask/entities/task/TaskStatus.java | 108 ++- .../config/OrganizationUseCaseConfig.java | 6 +- .../config/TaskStatusUseCaseConfig.java | 47 ++ .../frameworks/config/TaskUseCaseConfig.java | 22 +- .../persistence/entities/TaskEntity.java | 21 +- .../entities/TaskStatusEntity.java | 122 ++++ .../persistence/mapper/TaskEntityMapper.java | 12 +- .../mapper/TaskStatusEntityMapper.java | 39 ++ .../repository/TaskRepositoryImpl.java | 35 +- .../repository/TaskStatusRepositoryImpl.java | 100 +++ .../controllers/task/CreateTaskRequest.java | 5 +- .../controllers/task/CreateTaskResponse.java | 15 +- .../controllers/task/ListMyTasksRequest.java | 4 +- .../controllers/task/ListTasksRequest.java | 4 +- .../controllers/task/TaskController.java | 37 +- .../task/TaskControllerMapper.java | 40 +- .../taskstatus/TaskStatusController.java | 114 ++++ .../TaskStatusControllerMapper.java | 50 ++ .../taskstatus/TaskStatusRequest.java | 10 + .../taskstatus/TaskStatusResponse.java | 13 + .../create/CreateOrganizationUseCase.java | 18 +- .../publicstats/PublicStatsUseCase.java | 3 +- .../generate/GenerateReportUseCase.java | 18 +- .../usecase/repository/TaskRepository.java | 6 +- .../repository/TaskStatusRepository.java | 17 + .../usecase/task/create/CreateTaskInput.java | 4 +- .../task/create/CreateTaskUseCase.java | 37 +- .../task/listall/ListMyTasksInput.java | 4 +- .../usecase/task/listall/ListTasksInput.java | 4 +- .../usecase/task/start/StartTaskUseCase.java | 14 +- .../usecase/task/update/UpdateTaskInput.java | 4 +- .../task/update/UpdateTaskStatusUseCase.java | 55 +- .../task/update/UpdateTaskUseCase.java | 28 +- .../taskstatus/CreateTaskStatusInput.java | 12 + .../taskstatus/CreateTaskStatusUseCase.java | 71 ++ .../taskstatus/DeleteTaskStatusUseCase.java | 56 ++ .../taskstatus/ListTaskStatusesUseCase.java | 31 + .../ReorderTaskStatusesUseCase.java | 48 ++ .../usecase/taskstatus/TaskStatusMapper.java | 20 + .../usecase/taskstatus/TaskStatusOutput.java | 13 + .../UpdateTaskStatusInfoUseCase.java | 74 ++ .../taskstatus/UpdateTaskStatusInput.java | 13 + .../V18__create_task_statuses_table.sql | 63 ++ frontend/src/App.tsx | 2 + frontend/src/components/layout/Sidebar.tsx | 2 + .../src/components/tasks/CreateTaskModal.tsx | 46 +- .../src/components/tasks/TaskKanbanView.tsx | 215 ++++-- .../src/components/tasks/TaskListView.tsx | 36 +- frontend/src/lib/userPreferences.ts | 3 +- frontend/src/pages/DashboardPage.tsx | 4 +- frontend/src/pages/StatusesPage.tsx | 625 +++++++++++++++++ frontend/src/pages/TasksPage.tsx | 640 +++++++++++++----- frontend/src/services/taskService.ts | 11 +- frontend/src/services/taskStatusService.ts | 56 ++ frontend/src/types/task.ts | 24 +- 56 files changed, 2739 insertions(+), 349 deletions(-) create mode 100644 backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskStatusUseCaseConfig.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskStatusEntity.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskStatusEntityMapper.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskStatusRepositoryImpl.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/taskstatus/TaskStatusController.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/taskstatus/TaskStatusControllerMapper.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/taskstatus/TaskStatusRequest.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/taskstatus/TaskStatusResponse.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskStatusRepository.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/CreateTaskStatusInput.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/CreateTaskStatusUseCase.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/DeleteTaskStatusUseCase.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/ListTaskStatusesUseCase.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/ReorderTaskStatusesUseCase.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/TaskStatusMapper.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/TaskStatusOutput.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/UpdateTaskStatusInfoUseCase.java create mode 100644 backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/UpdateTaskStatusInput.java create mode 100644 backend/src/main/resources/db/migration/V18__create_task_statuses_table.sql create mode 100644 frontend/src/pages/StatusesPage.tsx create mode 100644 frontend/src/services/taskStatusService.ts diff --git a/backend/src/main/java/net/beetechgroup/beetask/entities/task/Task.java b/backend/src/main/java/net/beetechgroup/beetask/entities/task/Task.java index 1bddee2..160cc29 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/entities/task/Task.java +++ b/backend/src/main/java/net/beetechgroup/beetask/entities/task/Task.java @@ -77,13 +77,13 @@ public class Task { } public void setStatus(TaskStatus status) { - if (this.status == status) return; + if (Objects.equals(this.status, status)) return; - if (status == TaskStatus.COMPLETED || status == TaskStatus.CANCELED) { + if (status != null && (status.isCompleted() || status.isCanceled())) { if (isRunning()) { this.stop(); } - if (status == TaskStatus.COMPLETED) { + if (status.isCompleted()) { this.finishedAt = LocalDateTime.now(); } else { this.finishedAt = null; @@ -157,7 +157,6 @@ public class Task { throw new IllegalStateException("Tarefa já está em execução"); } - this.status = TaskStatus.IN_PROGRESS; TaskHistoryItem item = new TaskHistoryItem(); item.setStartAt(LocalDateTime.now()); item.setUser(worker != null ? worker : this.user); diff --git a/backend/src/main/java/net/beetechgroup/beetask/entities/task/TaskStatus.java b/backend/src/main/java/net/beetechgroup/beetask/entities/task/TaskStatus.java index fe8546c..18e59b7 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/entities/task/TaskStatus.java +++ b/backend/src/main/java/net/beetechgroup/beetask/entities/task/TaskStatus.java @@ -1,18 +1,106 @@ package net.beetechgroup.beetask.entities.task; -public enum TaskStatus { - NOT_STARTED("NOT_STARTED"), - IN_PROGRESS("IN_PROGRESS"), - COMPLETED("COMPLETED"), - CANCELED("CANCELED"); +import java.util.Objects; +import net.beetechgroup.beetask.entities.organization.Organization; - private String status; +public class TaskStatus { + private Long id; + private String name; + private String color; + private Integer position; + private boolean isDefault; + private boolean isCompleted; + private boolean isCanceled; + private Organization organization; - TaskStatus(String status) { - this.status = status; + public TaskStatus() { } - public String getStatus() { - return status; + public TaskStatus(Long id, String name, String color, Integer position, boolean isDefault, boolean isCompleted, boolean isCanceled, Organization organization) { + this.id = id; + this.name = name; + this.color = color; + this.position = position; + this.isDefault = isDefault; + this.isCompleted = isCompleted; + this.isCanceled = isCanceled; + this.organization = organization; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getColor() { + return color; + } + + public void setColor(String color) { + this.color = color; + } + + public Integer getPosition() { + return position != null ? position : 0; + } + + public void setPosition(Integer position) { + this.position = position; + } + + public boolean isDefault() { + return isDefault; + } + + public void setDefault(boolean isDefault) { + this.isDefault = isDefault; + } + + public boolean isCompleted() { + return isCompleted; + } + + public void setCompleted(boolean isCompleted) { + this.isCompleted = isCompleted; + } + + public boolean isCanceled() { + return isCanceled; + } + + public void setCanceled(boolean isCanceled) { + this.isCanceled = isCanceled; + } + + public Organization getOrganization() { + return organization; + } + + public void setOrganization(Organization organization) { + this.organization = organization; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + TaskStatus that = (TaskStatus) o; + return Objects.equals(id, that.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/OrganizationUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/OrganizationUseCaseConfig.java index cf88a7e..90e81fc 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/OrganizationUseCaseConfig.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/OrganizationUseCaseConfig.java @@ -12,6 +12,7 @@ import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationA import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.organization.members.ListOrganizationMembersUseCase; import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; +import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository; import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository; @@ -33,8 +34,9 @@ public class OrganizationUseCaseConfig { @Produces public CreateOrganizationUseCase createOrganizationUseCase(OrganizationRepository organizationRepository, UserRepository userRepository, - UserOrganizationRepository userOrganizationRepository) { - return new CreateOrganizationUseCase(organizationRepository, userRepository, userOrganizationRepository); + UserOrganizationRepository userOrganizationRepository, + TaskStatusRepository taskStatusRepository) { + return new CreateOrganizationUseCase(organizationRepository, userRepository, userOrganizationRepository, taskStatusRepository); } @Produces diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskStatusUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskStatusUseCaseConfig.java new file mode 100644 index 0000000..638095c --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskStatusUseCaseConfig.java @@ -0,0 +1,47 @@ +package net.beetechgroup.beetask.frameworks.config; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.inject.Produces; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; +import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; +import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository; +import net.beetechgroup.beetask.usecase.taskstatus.CreateTaskStatusUseCase; +import net.beetechgroup.beetask.usecase.taskstatus.DeleteTaskStatusUseCase; +import net.beetechgroup.beetask.usecase.taskstatus.ListTaskStatusesUseCase; +import net.beetechgroup.beetask.usecase.taskstatus.ReorderTaskStatusesUseCase; +import net.beetechgroup.beetask.usecase.taskstatus.UpdateTaskStatusInfoUseCase; + +@ApplicationScoped +public class TaskStatusUseCaseConfig { + + @Produces + public ListTaskStatusesUseCase listTaskStatusesUseCase(TaskStatusRepository taskStatusRepository, + AuthorizeOrganizationMemberUseCase authorizer) { + return new ListTaskStatusesUseCase(taskStatusRepository, authorizer); + } + + @Produces + public CreateTaskStatusUseCase createTaskStatusUseCase(TaskStatusRepository taskStatusRepository, + OrganizationRepository organizationRepository, + AuthorizeOrganizationMemberUseCase authorizer) { + return new CreateTaskStatusUseCase(taskStatusRepository, organizationRepository, authorizer); + } + + @Produces + public UpdateTaskStatusInfoUseCase updateTaskStatusInfoUseCase(TaskStatusRepository taskStatusRepository, + AuthorizeOrganizationMemberUseCase authorizer) { + return new UpdateTaskStatusInfoUseCase(taskStatusRepository, authorizer); + } + + @Produces + public DeleteTaskStatusUseCase deleteTaskStatusUseCase(TaskStatusRepository taskStatusRepository, + AuthorizeOrganizationMemberUseCase authorizer) { + return new DeleteTaskStatusUseCase(taskStatusRepository, authorizer); + } + + @Produces + public ReorderTaskStatusesUseCase reorderTaskStatusesUseCase(TaskStatusRepository taskStatusRepository, + AuthorizeOrganizationMemberUseCase authorizer) { + return new ReorderTaskStatusesUseCase(taskStatusRepository, authorizer); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java index 83d9461..8bca0de 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/config/TaskUseCaseConfig.java @@ -6,6 +6,7 @@ import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationM import net.beetechgroup.beetask.usecase.repository.CategoryRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository; +import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository; import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase; @@ -28,13 +29,15 @@ public class TaskUseCaseConfig { public CreateTaskUseCase createTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, CategoryRepository categoryRepository, UserRepository userRepository, UserOrganizationRepository userOrganizationRepository, - net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) { - return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository); + net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository, + TaskStatusRepository taskStatusRepository) { + return new CreateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository, taskStatusRepository); } @Produces - public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository, UserRepository userRepository, AuthorizeOrganizationMemberUseCase authorizer) { - return new StartTaskUseCase(taskRepository, userRepository, authorizer); + public StartTaskUseCase startTaskUseCase(TaskRepository taskRepository, UserRepository userRepository, + AuthorizeOrganizationMemberUseCase authorizer, TaskStatusRepository taskStatusRepository) { + return new StartTaskUseCase(taskRepository, userRepository, authorizer, taskStatusRepository); } @Produces @@ -53,16 +56,19 @@ public class TaskUseCaseConfig { } @Produces - public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) { - return new UpdateTaskStatusUseCase(taskRepository, authorizer); + public UpdateTaskStatusUseCase updateTaskStatusUseCase(TaskRepository taskRepository, + TaskStatusRepository taskStatusRepository, + AuthorizeOrganizationMemberUseCase authorizer) { + return new UpdateTaskStatusUseCase(taskRepository, taskStatusRepository, authorizer); } @Produces public UpdateTaskUseCase updateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, CategoryRepository categoryRepository, UserRepository userRepository, UserOrganizationRepository userOrganizationRepository, - net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) { - return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository); + net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository, + TaskStatusRepository taskStatusRepository) { + return new UpdateTaskUseCase(taskRepository, projectRepository, categoryRepository, userRepository, userOrganizationRepository, organizationRepository, taskStatusRepository); } @Produces diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskEntity.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskEntity.java index ad80d58..570d8a3 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskEntity.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskEntity.java @@ -16,7 +16,7 @@ import jakarta.persistence.OneToMany; import jakarta.persistence.Table; import org.hibernate.annotations.SQLDelete; import org.hibernate.annotations.SQLRestriction; -import net.beetechgroup.beetask.entities.task.TaskStatus; +import jakarta.persistence.FetchType; @Entity @Table(name = "tasks") @@ -28,8 +28,11 @@ public class TaskEntity { private Long id; private String title; private String description; - @Enumerated(EnumType.STRING) - private TaskStatus status; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "status_id") + private TaskStatusEntity taskStatus; + @Column(name = "status", length = 120) + private String status; @Column(name = "finished_at") private java.time.LocalDateTime finishedAt; @Column(name = "external_url", length = 1000) @@ -103,11 +106,19 @@ public class TaskEntity { this.description = description; } - public TaskStatus getStatus() { + public TaskStatusEntity getTaskStatus() { + return taskStatus; + } + + public void setTaskStatus(TaskStatusEntity taskStatus) { + this.taskStatus = taskStatus; + } + + public String getStatus() { return status; } - public void setStatus(TaskStatus status) { + public void setStatus(String status) { this.status = status; } diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskStatusEntity.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskStatusEntity.java new file mode 100644 index 0000000..07dca5f --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/entities/TaskStatusEntity.java @@ -0,0 +1,122 @@ +package net.beetechgroup.beetask.frameworks.persistence.entities; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table(name = "task_statuses") +@SQLDelete(sql = "UPDATE task_statuses SET deleted_at = CURRENT_TIMESTAMP WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class TaskStatusEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 120) + private String name; + + @Column(nullable = false, length = 32) + private String color; + + @Column(nullable = false) + private Integer position; + + @Column(name = "is_default", nullable = false) + private boolean isDefault; + + @Column(name = "is_completed", nullable = false) + private boolean isCompleted; + + @Column(name = "is_canceled", nullable = false) + private boolean isCanceled; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "organization_id", nullable = false) + private OrganizationEntity organization; + + @Column(name = "deleted_at") + private LocalDateTime deletedAt; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getColor() { + return color; + } + + public void setColor(String color) { + this.color = color; + } + + public Integer getPosition() { + return position != null ? position : 0; + } + + public void setPosition(Integer position) { + this.position = position; + } + + public boolean isDefault() { + return isDefault; + } + + public void setDefault(boolean aDefault) { + isDefault = aDefault; + } + + public boolean isCompleted() { + return isCompleted; + } + + public void setCompleted(boolean completed) { + isCompleted = completed; + } + + public boolean isCanceled() { + return isCanceled; + } + + public void setCanceled(boolean canceled) { + isCanceled = canceled; + } + + public OrganizationEntity getOrganization() { + return organization; + } + + public void setOrganization(OrganizationEntity organization) { + this.organization = organization; + } + + public LocalDateTime getDeletedAt() { + return deletedAt; + } + + public void setDeletedAt(LocalDateTime deletedAt) { + this.deletedAt = deletedAt; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskEntityMapper.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskEntityMapper.java index f81fb1e..8fb77ce 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskEntityMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskEntityMapper.java @@ -4,6 +4,7 @@ import java.util.ArrayList; import java.util.Objects; import net.beetechgroup.beetask.entities.task.Task; +import net.beetechgroup.beetask.entities.task.TaskStatus; import net.beetechgroup.beetask.frameworks.persistence.entities.TaskEntity; import net.beetechgroup.beetask.frameworks.persistence.entities.TaskHistoryItemEntity; @@ -14,7 +15,8 @@ public class TaskEntityMapper { entity.setId(task.getId()); entity.setTitle(task.getTitle()); entity.setDescription(task.getDescription()); - entity.setStatus(task.getStatus()); + entity.setTaskStatus(TaskStatusEntityMapper.toEntity(task.getStatus())); + entity.setStatus(task.getStatus() != null ? task.getStatus().getName() : null); entity.setFinishedAt(task.getFinishedAt()); entity.setExternalUrl(task.getExternalUrl()); entity.setOrganization(OrganizationEntityMapper.toEntity(task.getOrganization())); @@ -37,7 +39,13 @@ public class TaskEntityMapper { task.setId(entity.getId()); task.setTitle(entity.getTitle()); task.setDescription(entity.getDescription()); - task.setStatus(entity.getStatus()); + if (entity.getTaskStatus() != null) { + task.setStatus(TaskStatusEntityMapper.toDomain(entity.getTaskStatus())); + } else if (entity.getStatus() != null) { + TaskStatus fallback = new TaskStatus(); + fallback.setName(entity.getStatus()); + task.setStatus(fallback); + } task.setFinishedAt(entity.getFinishedAt()); task.setExternalUrl(entity.getExternalUrl()); task.setOrganization(OrganizationEntityMapper.toDomain(entity.getOrganization())); diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskStatusEntityMapper.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskStatusEntityMapper.java new file mode 100644 index 0000000..fb42cd9 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/mapper/TaskStatusEntityMapper.java @@ -0,0 +1,39 @@ +package net.beetechgroup.beetask.frameworks.persistence.mapper; + +import net.beetechgroup.beetask.entities.task.TaskStatus; +import net.beetechgroup.beetask.frameworks.persistence.entities.TaskStatusEntity; + +public class TaskStatusEntityMapper { + + public static TaskStatusEntity toEntity(TaskStatus domain) { + if (domain == null) return null; + TaskStatusEntity entity = new TaskStatusEntity(); + entity.setId(domain.getId()); + entity.setName(domain.getName()); + entity.setColor(domain.getColor()); + entity.setPosition(domain.getPosition()); + entity.setDefault(domain.isDefault()); + entity.setCompleted(domain.isCompleted()); + entity.setCanceled(domain.isCanceled()); + if (domain.getOrganization() != null) { + entity.setOrganization(OrganizationEntityMapper.toEntity(domain.getOrganization())); + } + return entity; + } + + public static TaskStatus toDomain(TaskStatusEntity entity) { + if (entity == null) return null; + TaskStatus domain = new TaskStatus(); + domain.setId(entity.getId()); + domain.setName(entity.getName()); + domain.setColor(entity.getColor()); + domain.setPosition(entity.getPosition()); + domain.setDefault(entity.isDefault()); + domain.setCompleted(entity.isCompleted()); + domain.setCanceled(entity.isCanceled()); + if (entity.getOrganization() != null) { + domain.setOrganization(OrganizationEntityMapper.toDomain(entity.getOrganization())); + } + return domain; + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java index 49f1aeb..c2bbd75 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskRepositoryImpl.java @@ -55,7 +55,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository findAccessibleTasksFiltered(Long organizationId, String text, List projectIds, - List statuses, List categoryIds, List userIds) { + List statuses, List categoryIds, List userIds) { StringBuilder query = new StringBuilder( "select distinct t from TaskEntity t left join t.project p left join t.category c " + "where (t.organization.id = ?1 or p.organization.id = ?1 or c.organization.id = ?1)" @@ -77,7 +77,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository findTasksByUserFiltered(String email, String text, List projectIds, - List statuses, List categoryIds) { + List statuses, List categoryIds) { StringBuilder query = new StringBuilder("(user.email = ?1 or (user is null and createdBy.email = ?1))"); List params = new ArrayList<>(); params.add(email); @@ -118,7 +118,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end) { - return find("user.email = ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4", - email, TaskStatus.COMPLETED, start, end) + return find("user.email = ?1 and finishedAt is not null and finishedAt >= ?2 and finishedAt <= ?3", + email, start, end) .list().stream().map(TaskEntityMapper::toDomain).toList(); } @@ -193,8 +193,7 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository= ?2 and t.finishedAt <= ?3", + "and t.finishedAt is not null and t.finishedAt >= ?2 and t.finishedAt <= ?3", orgId, start, end).list().stream().map(TaskEntityMapper::toDomain).toList(); } @@ -225,8 +224,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository findTasksFinishedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end) { - return find("user.id = ?1 and status = ?2 and finishedAt >= ?3 and finishedAt <= ?4", - userId, TaskStatus.COMPLETED, start, end) + return find("user.id = ?1 and finishedAt is not null and finishedAt >= ?2 and finishedAt <= ?3", + userId, start, end) .list().stream().map(TaskEntityMapper::toDomain).toList(); } @@ -235,8 +234,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository= ?4 and t.finishedAt <= ?5", - orgId, userId, TaskStatus.COMPLETED, start, end) + "and t.user.id = ?2 and t.finishedAt is not null and t.finishedAt >= ?3 and t.finishedAt <= ?4", + orgId, userId, start, end) .list().stream().map(TaskEntityMapper::toDomain).toList(); } @@ -279,8 +278,8 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository= ?3 and finishedAt <= ?4", - emails, TaskStatus.COMPLETED, start, end) + return find("user.email in ?1 and finishedAt is not null and finishedAt >= ?2 and finishedAt <= ?3", + emails, start, end) .list().stream().map(TaskEntityMapper::toDomain).toList(); } @@ -293,14 +292,14 @@ public class TaskRepositoryImpl implements TaskRepository, PanacheRepository= ?4 and t.finishedAt <= ?5", - orgId, emails, TaskStatus.COMPLETED, start, end) + "and t.user.email in ?2 and t.finishedAt is not null and t.finishedAt >= ?3 and t.finishedAt <= ?4", + orgId, emails, start, end) .list().stream().map(TaskEntityMapper::toDomain).toList(); } @Override - public long countByStatus(TaskStatus status) { - return count("status", status); + public long countCompletedTasks() { + return count("finishedAt is not null or (taskStatus is not null and taskStatus.isCompleted = true)"); } @Override diff --git a/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskStatusRepositoryImpl.java b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskStatusRepositoryImpl.java new file mode 100644 index 0000000..8605974 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/frameworks/persistence/repository/TaskStatusRepositoryImpl.java @@ -0,0 +1,100 @@ +package net.beetechgroup.beetask.frameworks.persistence.repository; + +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import jakarta.enterprise.context.ApplicationScoped; +import java.util.List; +import java.util.Optional; +import net.beetechgroup.beetask.entities.task.TaskStatus; +import net.beetechgroup.beetask.frameworks.persistence.entities.TaskStatusEntity; +import net.beetechgroup.beetask.frameworks.persistence.mapper.TaskStatusEntityMapper; +import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository; +import org.jboss.logging.Logger; + +@ApplicationScoped +public class TaskStatusRepositoryImpl implements TaskStatusRepository, PanacheRepositoryBase { + private static final Logger LOGGER = Logger.getLogger(TaskStatusRepositoryImpl.class); + + @Override + public TaskStatus saveTaskStatus(TaskStatus taskStatus) { + TaskStatusEntity entity = TaskStatusEntityMapper.toEntity(taskStatus); + if (entity.getId() == null) { + persist(entity); + } else { + entity = getEntityManager().merge(entity); + } + LOGGER.infof("Persisted task status %d ('%s') for organization %d", + entity.getId(), entity.getName(), entity.getOrganization().getId()); + return TaskStatusEntityMapper.toDomain(entity); + } + + @Override + public Optional findTaskStatusById(Long id) { + return findByIdOptional(id).map(TaskStatusEntityMapper::toDomain); + } + + @Override + public List findByOrganizationId(Long organizationId) { + return find("organization.id = ?1 order by position asc, id asc", organizationId) + .list() + .stream() + .map(TaskStatusEntityMapper::toDomain) + .toList(); + } + + @Override + public Optional findDefaultByOrganizationId(Long organizationId) { + return find("organization.id = ?1 and isDefault = true", organizationId) + .firstResultOptional() + .map(TaskStatusEntityMapper::toDomain); + } + + @Override + public Optional findByNameAndOrganizationId(String name, Long organizationId) { + if (name == null || name.isBlank()) { + return Optional.empty(); + } + return find("organization.id = ?1 and lower(name) = ?2", organizationId, name.trim().toLowerCase()) + .firstResultOptional() + .map(TaskStatusEntityMapper::toDomain); + } + + @Override + public boolean existsByNameInOrganization(Long organizationId, String name, Long excludeId) { + if (name == null || name.isBlank()) { + return false; + } + String trimmed = name.trim().toLowerCase(); + if (excludeId != null) { + return count("organization.id = ?1 and lower(name) = ?2 and id != ?3", organizationId, trimmed, excludeId) > 0; + } + return count("organization.id = ?1 and lower(name) = ?2", organizationId, trimmed) > 0; + } + + @Override + public void deleteTaskStatus(TaskStatus taskStatus) { + deleteById(taskStatus.getId()); + LOGGER.infof("Deleted task status %d", taskStatus.getId()); + } + + @Override + public long countTasksByStatusId(Long statusId) { + return getEntityManager() + .createQuery("select count(t) from TaskEntity t where t.taskStatus.id = :statusId", Long.class) + .setParameter("statusId", statusId) + .getSingleResult(); + } + + @Override + public void reassignTasks(Long fromStatusId, Long toStatusId) { + TaskStatusEntity target = findById(toStatusId); + if (target != null) { + getEntityManager() + .createQuery("update TaskEntity t set t.taskStatus = :target, t.status = :targetName where t.taskStatus.id = :fromId") + .setParameter("target", target) + .setParameter("targetName", target.getName()) + .setParameter("fromId", fromStatusId) + .executeUpdate(); + LOGGER.infof("Reassigned tasks from status %d to status %d ('%s')", fromStatusId, toStatusId, target.getName()); + } + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskRequest.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskRequest.java index 197f0ca..fc8381b 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskRequest.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskRequest.java @@ -2,12 +2,11 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task; import java.util.List; -import net.beetechgroup.beetask.entities.task.TaskStatus; - public record CreateTaskRequest( String title, String description, - TaskStatus status, + String status, + Long statusId, Long organizationId, Long projectId, Long categoryId, diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java index 78e743e..6ede6e1 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/CreateTaskResponse.java @@ -3,13 +3,12 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task; import java.time.LocalDateTime; import java.util.List; -import net.beetechgroup.beetask.entities.task.TaskStatus; - public record CreateTaskResponse( Long id, String title, String description, - TaskStatus status, + String status, + TaskStatusResponse taskStatus, ProjectResponse project, CategoryResponse category, UserResponse user, @@ -18,8 +17,16 @@ public record CreateTaskResponse( List history, String externalUrl) { + public record TaskStatusResponse( + Long id, + String name, + String color, + Integer position, + boolean isDefault, + boolean isCompleted, + boolean isCanceled) {} + public record ProjectResponse(Long id, String name) {} public record CategoryResponse(Long id, String name, String color, String icon) {} public record UserResponse(Long id, String name, String email, String photo) {} } - diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListMyTasksRequest.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListMyTasksRequest.java index 024dcf4..bc615bd 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListMyTasksRequest.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListMyTasksRequest.java @@ -1,9 +1,7 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task; import java.util.List; - import jakarta.ws.rs.QueryParam; -import net.beetechgroup.beetask.entities.task.TaskStatus; public class ListMyTasksRequest { @@ -14,7 +12,7 @@ public class ListMyTasksRequest { public List projectIds; @QueryParam("status") - public List statuses; + public List statuses; @QueryParam("categoryId") public List categoryIds; diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListTasksRequest.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListTasksRequest.java index c078e7a..8fed105 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListTasksRequest.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/ListTasksRequest.java @@ -1,9 +1,7 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task; import java.util.List; - import jakarta.ws.rs.QueryParam; -import net.beetechgroup.beetask.entities.task.TaskStatus; public class ListTasksRequest { @@ -17,7 +15,7 @@ public class ListTasksRequest { public List projectIds; @QueryParam("status") - public List statuses; + public List statuses; @QueryParam("categoryId") public List categoryIds; diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java index 3e1045c..6ca2e8e 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskController.java @@ -24,7 +24,6 @@ import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; import org.eclipse.microprofile.openapi.annotations.tags.Tag; import org.jboss.logging.Logger; -import net.beetechgroup.beetask.entities.task.TaskStatus; import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskUseCase; @@ -145,15 +144,41 @@ public class TaskController { return TaskControllerMapper.toCreateTaskResponse(output); } + @PUT + @Path("/{id}/status") + @Consumes({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN}) + @Operation(summary = "Update task status (PUT)", description = "Updates the status of a task") + @APIResponse(responseCode = "200", description = "Task status updated successfully") + public CreateTaskResponse updateTaskStatusPut(@PathParam("id") Long id, String rawStatus) { + String email = securityIdentity.getPrincipal().getName(); + LOGGER.infof("Status update (PUT) requested for task %d to %s by %s", id, rawStatus, email); + CreateTaskOutput output = updateTaskStatusUseCase.execute(id, rawStatus, email); + LOGGER.infof("Task %d status updated successfully", id); + return TaskControllerMapper.toCreateTaskResponse(output); + } + @PATCH @Path("/{id}/status") - @Operation(summary = "Update task status", description = "Updates the status of a task") + @Consumes({MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN}) + @Operation(summary = "Update task status (PATCH)", description = "Updates the status of a task") @APIResponse(responseCode = "200", description = "Task status updated successfully") - public CreateTaskResponse updateTaskStatus(@PathParam("id") Long id, TaskStatus status) { + public CreateTaskResponse updateTaskStatusPatch(@PathParam("id") Long id, String rawStatus) { String email = securityIdentity.getPrincipal().getName(); - LOGGER.infof("Status update requested for task %d to %s by %s", id, status, email); - CreateTaskOutput output = updateTaskStatusUseCase.execute(id, status, email); - LOGGER.infof("Task %d status updated successfully to %s", id, status); + LOGGER.infof("Status update (PATCH) requested for task %d to %s by %s", id, rawStatus, email); + CreateTaskOutput output = updateTaskStatusUseCase.execute(id, rawStatus, email); + LOGGER.infof("Task %d status updated successfully", id); + return TaskControllerMapper.toCreateTaskResponse(output); + } + + @PUT + @Path("/{id}/status/{statusId}") + @Operation(summary = "Update task status by ID", description = "Updates the status of a task by status ID") + @APIResponse(responseCode = "200", description = "Task status updated successfully") + public CreateTaskResponse updateTaskStatusById(@PathParam("id") Long id, @PathParam("statusId") Long statusId) { + String email = securityIdentity.getPrincipal().getName(); + LOGGER.infof("Status update by ID requested for task %d to status %d by %s", id, statusId, email); + CreateTaskOutput output = updateTaskStatusUseCase.execute(id, statusId, null, email); + LOGGER.infof("Task %d status updated successfully to status %d", id, statusId); return TaskControllerMapper.toCreateTaskResponse(output); } diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java index e483930..e9d5026 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/task/TaskControllerMapper.java @@ -1,5 +1,7 @@ package net.beetechgroup.beetask.interfaceadapters.controllers.task; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; import net.beetechgroup.beetask.usecase.task.create.CreateTaskInput; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; @@ -12,20 +14,42 @@ import net.beetechgroup.beetask.usecase.task.update.UpdateTaskInput; public class TaskControllerMapper { + private static List normalizeStatuses(List rawStatuses) { + if (rawStatuses == null || rawStatuses.isEmpty()) { + return rawStatuses; + } + List normalized = new ArrayList<>(); + for (String s : rawStatuses) { + if (s == null) continue; + normalized.add(s); + String trimmed = s.trim(); + if ("NOT_STARTED".equalsIgnoreCase(trimmed) && !normalized.contains("A Fazer")) { + normalized.add("A Fazer"); + } else if ("IN_PROGRESS".equalsIgnoreCase(trimmed) && !normalized.contains("Em Andamento")) { + normalized.add("Em Andamento"); + } else if ("COMPLETED".equalsIgnoreCase(trimmed) && !normalized.contains("Concluído")) { + normalized.add("Concluído"); + } else if ("CANCELED".equalsIgnoreCase(trimmed) && !normalized.contains("Cancelado")) { + normalized.add("Cancelado"); + } + } + return normalized; + } + public static ListTasksInput toListTasksInput(ListTasksRequest request, String email) { return new ListTasksInput( email, request.organizationId, request.text, request.projectIds, - request.statuses, + normalizeStatuses(request.statuses), request.categoryIds, request.userIds ); } public static ListMyTasksInput toListMyTasksInput(ListMyTasksRequest request, String email) { - return new ListMyTasksInput(email, request.text, request.projectIds, request.statuses, request.categoryIds); + return new ListMyTasksInput(email, request.text, request.projectIds, normalizeStatuses(request.statuses), request.categoryIds); } public static CreateTaskInput toCreateTaskInput(CreateTaskRequest request, String userEmail) { @@ -33,6 +57,7 @@ public class TaskControllerMapper { request.title(), request.description(), request.status(), + request.statusId(), request.organizationId(), request.projectId(), request.categoryId(), @@ -50,7 +75,15 @@ public class TaskControllerMapper { output.id(), output.title(), output.description(), - output.status(), + output.status() != null ? output.status().getName() : null, + output.status() != null ? new CreateTaskResponse.TaskStatusResponse( + output.status().getId(), + output.status().getName(), + output.status().getColor(), + output.status().getPosition(), + output.status().isDefault(), + output.status().isCompleted(), + output.status().isCanceled()) : null, Objects.nonNull(output.project()) ? new CreateTaskResponse.ProjectResponse(output.project().id(), output.project().name()) : null, @@ -96,6 +129,7 @@ public class TaskControllerMapper { request.title(), request.description(), request.status(), + request.statusId(), request.organizationId(), request.projectId(), request.categoryId(), diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/taskstatus/TaskStatusController.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/taskstatus/TaskStatusController.java new file mode 100644 index 0000000..738ba92 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/taskstatus/TaskStatusController.java @@ -0,0 +1,114 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.taskstatus; + +import io.quarkus.security.Authenticated; +import io.quarkus.security.identity.SecurityIdentity; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.PUT; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import java.util.List; +import net.beetechgroup.beetask.usecase.taskstatus.CreateTaskStatusUseCase; +import net.beetechgroup.beetask.usecase.taskstatus.DeleteTaskStatusUseCase; +import net.beetechgroup.beetask.usecase.taskstatus.ListTaskStatusesUseCase; +import net.beetechgroup.beetask.usecase.taskstatus.ReorderTaskStatusesUseCase; +import net.beetechgroup.beetask.usecase.taskstatus.UpdateTaskStatusInfoUseCase; +import org.eclipse.microprofile.openapi.annotations.Operation; +import org.eclipse.microprofile.openapi.annotations.responses.APIResponse; +import org.eclipse.microprofile.openapi.annotations.tags.Tag; +import org.jboss.logging.Logger; + +@Path("/organizations/{orgId}/task-statuses") +@Produces(MediaType.APPLICATION_JSON) +@Consumes(MediaType.APPLICATION_JSON) +@Authenticated +@Tag(name = "Task Statuses", description = "Task status operations") +public class TaskStatusController { + private static final Logger LOGGER = Logger.getLogger(TaskStatusController.class); + + @Inject + ListTaskStatusesUseCase listTaskStatusesUseCase; + + @Inject + CreateTaskStatusUseCase createTaskStatusUseCase; + + @Inject + UpdateTaskStatusInfoUseCase updateTaskStatusInfoUseCase; + + @Inject + DeleteTaskStatusUseCase deleteTaskStatusUseCase; + + @Inject + ReorderTaskStatusesUseCase reorderTaskStatusesUseCase; + + @Inject + SecurityIdentity securityIdentity; + + @GET + @Operation(summary = "List task statuses", description = "Lists all task statuses for an organization") + @APIResponse(responseCode = "200", description = "Task statuses listed successfully") + public List listStatuses(@PathParam("orgId") Long orgId) { + String email = securityIdentity.getPrincipal().getName(); + return listTaskStatusesUseCase.execute(email, orgId).stream() + .map(TaskStatusControllerMapper::toResponse) + .toList(); + } + + @POST + @Transactional + @Operation(summary = "Create task status", description = "Creates a new task status in the organization") + @APIResponse(responseCode = "201", description = "Task status created successfully") + public Response createStatus(@PathParam("orgId") Long orgId, TaskStatusRequest request) { + String email = securityIdentity.getPrincipal().getName(); + LOGGER.infof("Creating task status '%s' in organization %d by %s", request.name(), orgId, email); + var output = createTaskStatusUseCase.execute(TaskStatusControllerMapper.toCreateInput(email, orgId, request)); + return Response.status(Response.Status.CREATED).entity(TaskStatusControllerMapper.toResponse(output)).build(); + } + + @PUT + @Path("/{id}") + @Transactional + @Operation(summary = "Update task status", description = "Updates an existing task status") + @APIResponse(responseCode = "200", description = "Task status updated successfully") + public TaskStatusResponse updateStatus(@PathParam("orgId") Long orgId, @PathParam("id") Long id, TaskStatusRequest request) { + String email = securityIdentity.getPrincipal().getName(); + LOGGER.infof("Updating task status %d in organization %d by %s", id, orgId, email); + var output = updateTaskStatusInfoUseCase.execute(TaskStatusControllerMapper.toUpdateInput(email, orgId, id, request)); + return TaskStatusControllerMapper.toResponse(output); + } + + @DELETE + @Path("/{id}") + @Transactional + @Operation(summary = "Delete task status", description = "Deletes a task status, optionally reassigning tasks to another status") + @APIResponse(responseCode = "204", description = "Task status deleted successfully") + public Response deleteStatus(@PathParam("orgId") Long orgId, + @PathParam("id") Long id, + @QueryParam("moveToStatusId") Long moveToStatusId) { + String email = securityIdentity.getPrincipal().getName(); + LOGGER.infof("Deleting task status %d in organization %d by %s (moveToStatusId=%s)", id, orgId, email, moveToStatusId); + deleteTaskStatusUseCase.execute(email, orgId, id, moveToStatusId); + return Response.noContent().build(); + } + + @PUT + @Path("/reorder") + @Transactional + @Operation(summary = "Reorder task statuses", description = "Updates order of task statuses in organization") + @APIResponse(responseCode = "200", description = "Task statuses reordered successfully") + public List reorderStatuses(@PathParam("orgId") Long orgId, List statusIds) { + String email = securityIdentity.getPrincipal().getName(); + LOGGER.infof("Reordering task statuses in organization %d by %s", orgId, email); + return reorderTaskStatusesUseCase.execute(email, orgId, statusIds).stream() + .map(TaskStatusControllerMapper::toResponse) + .toList(); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/taskstatus/TaskStatusControllerMapper.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/taskstatus/TaskStatusControllerMapper.java new file mode 100644 index 0000000..1623ab1 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/taskstatus/TaskStatusControllerMapper.java @@ -0,0 +1,50 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.taskstatus; + +import net.beetechgroup.beetask.usecase.taskstatus.CreateTaskStatusInput; +import net.beetechgroup.beetask.usecase.taskstatus.TaskStatusOutput; +import net.beetechgroup.beetask.usecase.taskstatus.UpdateTaskStatusInput; + +public class TaskStatusControllerMapper { + + public static CreateTaskStatusInput toCreateInput(String email, Long orgId, TaskStatusRequest request) { + return new CreateTaskStatusInput( + email, + orgId, + request.name(), + request.color(), + request.position(), + request.isDefault(), + request.isCompleted(), + request.isCanceled() + ); + } + + public static UpdateTaskStatusInput toUpdateInput(String email, Long orgId, Long id, TaskStatusRequest request) { + return new UpdateTaskStatusInput( + email, + orgId, + id, + request.name(), + request.color(), + request.position(), + request.isDefault(), + request.isCompleted(), + request.isCanceled() + ); + } + + public static TaskStatusResponse toResponse(TaskStatusOutput output) { + if (output == null) return null; + return new TaskStatusResponse( + output.id(), + output.name(), + output.color(), + output.position(), + output.isDefault(), + output.isCompleted(), + output.isCanceled(), + output.organizationId(), + output.taskCount() + ); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/taskstatus/TaskStatusRequest.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/taskstatus/TaskStatusRequest.java new file mode 100644 index 0000000..d1db620 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/taskstatus/TaskStatusRequest.java @@ -0,0 +1,10 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.taskstatus; + +public record TaskStatusRequest( + String name, + String color, + Integer position, + Boolean isDefault, + Boolean isCompleted, + Boolean isCanceled) { +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/taskstatus/TaskStatusResponse.java b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/taskstatus/TaskStatusResponse.java new file mode 100644 index 0000000..d237b7a --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/interfaceadapters/controllers/taskstatus/TaskStatusResponse.java @@ -0,0 +1,13 @@ +package net.beetechgroup.beetask.interfaceadapters.controllers.taskstatus; + +public record TaskStatusResponse( + Long id, + String name, + String color, + Integer position, + boolean isDefault, + boolean isCompleted, + boolean isCanceled, + Long organizationId, + long taskCount) { +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/organization/create/CreateOrganizationUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/organization/create/CreateOrganizationUseCase.java index 85cd20d..1867d43 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/organization/create/CreateOrganizationUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/organization/create/CreateOrganizationUseCase.java @@ -5,7 +5,9 @@ import net.beetechgroup.beetask.entities.organization.Organization; import net.beetechgroup.beetask.entities.organization.UserOrganization; import net.beetechgroup.beetask.entities.organization.UserOrganizationRole; import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus; +import net.beetechgroup.beetask.entities.task.TaskStatus; import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; +import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository; import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository; @@ -17,13 +19,16 @@ public class CreateOrganizationUseCase { private final OrganizationRepository organizationRepository; private final UserRepository userRepository; private final UserOrganizationRepository userOrganizationRepository; + private final TaskStatusRepository taskStatusRepository; public CreateOrganizationUseCase(OrganizationRepository organizationRepository, UserRepository userRepository, - UserOrganizationRepository userOrganizationRepository) { + UserOrganizationRepository userOrganizationRepository, + TaskStatusRepository taskStatusRepository) { this.organizationRepository = organizationRepository; this.userRepository = userRepository; this.userOrganizationRepository = userOrganizationRepository; + this.taskStatusRepository = taskStatusRepository; } public CreateOrganizationOutput execute(CreateOrganizationInput input) { @@ -59,6 +64,17 @@ public class CreateOrganizationUseCase { userOrganizationRepository.save(userOrganization); LOGGER.infof("Organization %d created successfully with owner user %d", organization.getId(), user.getId()); + // Create 4 default task statuses + createDefaultTaskStatuses(organization); + return CreateOrganizationMapper.toCreateOrganizationOutput(organization); } + + private void createDefaultTaskStatuses(Organization org) { + taskStatusRepository.saveTaskStatus(new TaskStatus(null, "A Fazer", "#71717a", 0, true, false, false, org)); + taskStatusRepository.saveTaskStatus(new TaskStatus(null, "Em Andamento", "#14b8a6", 1, false, false, false, org)); + taskStatusRepository.saveTaskStatus(new TaskStatus(null, "Concluído", "#10b981", 2, false, true, false, org)); + taskStatusRepository.saveTaskStatus(new TaskStatus(null, "Cancelado", "#f43f5e", 3, false, false, true, org)); + LOGGER.infof("Created 4 default task statuses for organization %d", org.getId()); + } } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/publicstats/PublicStatsUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/publicstats/PublicStatsUseCase.java index a7d300d..f87e269 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/publicstats/PublicStatsUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/publicstats/PublicStatsUseCase.java @@ -1,6 +1,5 @@ package net.beetechgroup.beetask.usecase.publicstats; -import net.beetechgroup.beetask.entities.task.TaskStatus; import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository; @@ -19,7 +18,7 @@ public class PublicStatsUseCase { public PublicStatsOutput execute() { long totalUsers = userRepository.countAll(); long totalOrganizations = organizationRepository.countAll(); - long totalCompletedTasks = taskRepository.countByStatus(TaskStatus.COMPLETED); + long totalCompletedTasks = taskRepository.countCompletedTasks(); double totalTrackedHours = taskRepository.sumAllCompletedTrackedMinutes() / 60.0; return new PublicStatsOutput(totalUsers, totalOrganizations, totalCompletedTasks, totalTrackedHours); diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportUseCase.java index 2fc7ed9..733d1c2 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/report/generate/GenerateReportUseCase.java @@ -423,7 +423,14 @@ public class GenerateReportUseCase { } } if (section.getFilterStatus() != null && !section.getFilterStatus().equalsIgnoreCase("ALL")) { - if (t.getStatus() == null || !t.getStatus().name().equalsIgnoreCase(section.getFilterStatus())) { + String statusName = t.getStatus() != null ? t.getStatus().getName() : ""; + String filter = section.getFilterStatus(); + boolean match = statusName.equalsIgnoreCase(filter) + || ("NOT_STARTED".equalsIgnoreCase(filter) && "A Fazer".equalsIgnoreCase(statusName)) + || ("IN_PROGRESS".equalsIgnoreCase(filter) && "Em Andamento".equalsIgnoreCase(statusName)) + || ("COMPLETED".equalsIgnoreCase(filter) && "Concluído".equalsIgnoreCase(statusName)) + || ("CANCELED".equalsIgnoreCase(filter) && "Cancelado".equalsIgnoreCase(statusName)); + if (!match) { return false; } } @@ -457,13 +464,8 @@ public class GenerateReportUseCase { } private String formatStatus(TaskStatus status) { - if (status == null) return ""; - return switch (status) { - case COMPLETED -> "Concluído"; - case IN_PROGRESS -> "Em Andamento"; - case NOT_STARTED -> "A Fazer"; - case CANCELED -> "Cancelado"; - }; + if (status == null || status.getName() == null) return ""; + return status.getName(); } private String capitalize(String text) { diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java index 8848d31..138eab7 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskRepository.java @@ -11,14 +11,14 @@ public interface TaskRepository { Task findTaskById(Long id); List findAllTasks(); List findAccessibleTasksFiltered(Long organizationId, String text, List projectIds, - List statuses, List categoryIds, List userIds); + List statuses, List categoryIds, List userIds); List findRunningTasksByUserEmail(String email); List findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List findTasksWorkedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end, Long organizationId); List findTasksFinishedByUserInPeriod(String email, LocalDateTime start, LocalDateTime end); List findTasksByUser(String email); List findTasksByUserFiltered(String email, String text, List projectIds, - List statuses, List categoryIds); + List statuses, List categoryIds); List findTasksWorkedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List findTasksFinishedByOrgInPeriod(Long orgId, LocalDateTime start, LocalDateTime end); List findTasksWorkedByUserIdInPeriod(Long userId, LocalDateTime start, LocalDateTime end); @@ -29,7 +29,7 @@ public interface TaskRepository { List findTasksWorkedByUserEmailsInPeriod(Long orgId, List emails, LocalDateTime start, LocalDateTime end); List findTasksFinishedByUserEmailsInPeriod(List emails, LocalDateTime start, LocalDateTime end); List findTasksFinishedByUserEmailsInPeriod(Long orgId, List emails, LocalDateTime start, LocalDateTime end); - long countByStatus(TaskStatus status); + long countCompletedTasks(); long sumAllCompletedTrackedMinutes(); void deleteTask(Long id); } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskStatusRepository.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskStatusRepository.java new file mode 100644 index 0000000..cf95f07 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/repository/TaskStatusRepository.java @@ -0,0 +1,17 @@ +package net.beetechgroup.beetask.usecase.repository; + +import java.util.List; +import java.util.Optional; +import net.beetechgroup.beetask.entities.task.TaskStatus; + +public interface TaskStatusRepository { + TaskStatus saveTaskStatus(TaskStatus taskStatus); + Optional findTaskStatusById(Long id); + List findByOrganizationId(Long organizationId); + Optional findDefaultByOrganizationId(Long organizationId); + Optional findByNameAndOrganizationId(String name, Long organizationId); + boolean existsByNameInOrganization(Long organizationId, String name, Long excludeId); + void deleteTaskStatus(TaskStatus taskStatus); + long countTasksByStatusId(Long statusId); + void reassignTasks(Long fromStatusId, Long toStatusId); +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskInput.java index 45656ce..33fd456 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskInput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskInput.java @@ -1,13 +1,13 @@ package net.beetechgroup.beetask.usecase.task.create; import java.util.List; -import net.beetechgroup.beetask.entities.task.TaskStatus; import net.beetechgroup.beetask.usecase.task.update.TaskHistoryItemInput; public record CreateTaskInput( String title, String description, - TaskStatus status, + String status, + Long statusId, Long organizationId, Long projectId, Long categoryId, diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskUseCase.java index 6fb0d3c..d46ad99 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/create/CreateTaskUseCase.java @@ -8,11 +8,13 @@ import net.beetechgroup.beetask.entities.User; import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus; import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.TaskHistoryItem; -import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; -import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; +import net.beetechgroup.beetask.entities.task.TaskStatus; import net.beetechgroup.beetask.usecase.repository.CategoryRepository; +import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository; +import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository; +import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository; import org.jboss.logging.Logger; @@ -24,17 +26,20 @@ public class CreateTaskUseCase { private final UserRepository userRepository; private final UserOrganizationRepository userOrganizationRepository; private final OrganizationRepository organizationRepository; + private final TaskStatusRepository taskStatusRepository; public CreateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, CategoryRepository categoryRepository, UserRepository userRepository, UserOrganizationRepository userOrganizationRepository, - OrganizationRepository organizationRepository) { + OrganizationRepository organizationRepository, + TaskStatusRepository taskStatusRepository) { this.taskRepository = taskRepository; this.projectRepository = projectRepository; this.categoryRepository = categoryRepository; this.userRepository = userRepository; this.userOrganizationRepository = userOrganizationRepository; this.organizationRepository = organizationRepository; + this.taskStatusRepository = taskStatusRepository; } public CreateTaskOutput execute(CreateTaskInput input) { @@ -42,7 +47,6 @@ public class CreateTaskUseCase { Task task = new Task(); task.setTitle(input.title()); task.setDescription(input.description()); - task.setStatus(input.status()); task.setExternalUrl(input.externalUrl()); if (Objects.nonNull(input.projectId())) { @@ -72,6 +76,28 @@ public class CreateTaskUseCase { task.setOrganization(task.getCategory().getOrganization()); } + // Resolve task status + TaskStatus taskStatus = null; + if (input.statusId() != null) { + taskStatus = taskStatusRepository.findTaskStatusById(input.statusId()).orElse(null); + } + if (taskStatus == null && input.status() != null && !input.status().isBlank() && task.getOrganization() != null) { + String name = input.status().trim(); + if ("NOT_STARTED".equalsIgnoreCase(name)) name = "A Fazer"; + else if ("IN_PROGRESS".equalsIgnoreCase(name)) name = "Em Andamento"; + else if ("COMPLETED".equalsIgnoreCase(name)) name = "Concluído"; + else if ("CANCELED".equalsIgnoreCase(name)) name = "Cancelado"; + taskStatus = taskStatusRepository.findByNameAndOrganizationId(name, task.getOrganization().getId()).orElse(null); + } + if (taskStatus == null && task.getOrganization() != null) { + taskStatus = taskStatusRepository.findDefaultByOrganizationId(task.getOrganization().getId()) + .orElseGet(() -> { + List all = taskStatusRepository.findByOrganizationId(task.getOrganization().getId()); + return all.isEmpty() ? null : all.get(0); + }); + } + task.setStatus(taskStatus); + task.setUser(resolveAssignee(input.organizationId(), input.assigneeUserId())); User creator = userRepository.findByEmail(input.userEmail()).orElse(null); task.setCreatedBy(creator); @@ -87,7 +113,8 @@ public class CreateTaskUseCase { } CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task)); - LOGGER.infof("Task %d created for user %s", output.id(), input.userEmail()); + LOGGER.infof("Task %d created for user %s with status %s", output.id(), input.userEmail(), + task.getStatus() != null ? task.getStatus().getName() : "none"); return output; } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksInput.java index 1e1b9f4..3228f2f 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksInput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListMyTasksInput.java @@ -2,7 +2,5 @@ package net.beetechgroup.beetask.usecase.task.listall; import java.util.List; -import net.beetechgroup.beetask.entities.task.TaskStatus; - public record ListMyTasksInput(String email, String text, List projectIds, - List statuses, List categoryIds) {} + List statuses, List categoryIds) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListTasksInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListTasksInput.java index 64e8998..fde6648 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListTasksInput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/listall/ListTasksInput.java @@ -2,13 +2,11 @@ package net.beetechgroup.beetask.usecase.task.listall; import java.util.List; -import net.beetechgroup.beetask.entities.task.TaskStatus; - public record ListTasksInput( String userEmail, Long organizationId, String text, List projectIds, - List statuses, + List statuses, List categoryIds, List userIds) {} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/start/StartTaskUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/start/StartTaskUseCase.java index 01218c6..f8020cf 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/start/StartTaskUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/start/StartTaskUseCase.java @@ -2,8 +2,10 @@ package net.beetechgroup.beetask.usecase.task.start; import java.util.Objects; import net.beetechgroup.beetask.entities.task.Task; +import net.beetechgroup.beetask.entities.task.TaskStatus; import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.repository.TaskRepository; +import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; @@ -15,11 +17,14 @@ public class StartTaskUseCase { private final TaskRepository taskRepository; private final UserRepository userRepository; private final AuthorizeOrganizationMemberUseCase authorizer; + private final TaskStatusRepository taskStatusRepository; - public StartTaskUseCase(TaskRepository taskRepository, UserRepository userRepository, AuthorizeOrganizationMemberUseCase authorizer) { + public StartTaskUseCase(TaskRepository taskRepository, UserRepository userRepository, + AuthorizeOrganizationMemberUseCase authorizer, TaskStatusRepository taskStatusRepository) { this.taskRepository = taskRepository; this.userRepository = userRepository; this.authorizer = authorizer; + this.taskStatusRepository = taskStatusRepository; } public CreateTaskOutput execute(StartTaskInput input) { @@ -57,10 +62,15 @@ public class StartTaskUseCase { } } + // If task is not started or in default status, move to "Em Andamento" + if (orgId != null && (task.getStatus() == null || task.getStatus().isDefault() || "A Fazer".equalsIgnoreCase(task.getStatus().getName()))) { + taskStatusRepository.findByNameAndOrganizationId("Em Andamento", orgId) + .ifPresent(task::setStatus); + } + task.start(currentUser); CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task)); LOGGER.infof("Task %d started successfully", input.id()); return output; } } - diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskInput.java index 83951b6..8f2313e 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskInput.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskInput.java @@ -1,13 +1,13 @@ package net.beetechgroup.beetask.usecase.task.update; import java.util.List; -import net.beetechgroup.beetask.entities.task.TaskStatus; public record UpdateTaskInput( Long id, String title, String description, - TaskStatus status, + String status, + Long statusId, Long organizationId, Long projectId, Long categoryId, diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskStatusUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskStatusUseCase.java index 0b22009..be8d42b 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskStatusUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskStatusUseCase.java @@ -4,6 +4,7 @@ import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.TaskStatus; import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; import net.beetechgroup.beetask.usecase.repository.TaskRepository; +import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository; import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper; import net.beetechgroup.beetask.usecase.task.create.CreateTaskOutput; import org.jboss.logging.Logger; @@ -12,19 +13,25 @@ public class UpdateTaskStatusUseCase { private static final Logger LOGGER = Logger.getLogger(UpdateTaskStatusUseCase.class); private final TaskRepository taskRepository; + private final TaskStatusRepository taskStatusRepository; private final AuthorizeOrganizationMemberUseCase authorizer; - public UpdateTaskStatusUseCase(TaskRepository taskRepository, AuthorizeOrganizationMemberUseCase authorizer) { + public UpdateTaskStatusUseCase(TaskRepository taskRepository, + TaskStatusRepository taskStatusRepository, + AuthorizeOrganizationMemberUseCase authorizer) { this.taskRepository = taskRepository; + this.taskStatusRepository = taskStatusRepository; this.authorizer = authorizer; } - public CreateTaskOutput execute(Long id, TaskStatus newStatus, String userEmail) { - LOGGER.infof("Updating status for task %d to %s by user %s", id, newStatus, userEmail); + public CreateTaskOutput execute(Long id, Long newStatusId, String newStatusName, String userEmail) { + LOGGER.infof("Updating status for task %d (statusId=%s, statusName=%s) by user %s", id, newStatusId, newStatusName, userEmail); Task task = taskRepository.findTaskById(id); Long orgId = null; - if (task.getProject() != null && task.getProject().getOrganization() != null) { + if (task.getOrganization() != null) { + orgId = task.getOrganization().getId(); + } else if (task.getProject() != null && task.getProject().getOrganization() != null) { orgId = task.getProject().getOrganization().getId(); } else if (task.getCategory() != null && task.getCategory().getOrganization() != null) { orgId = task.getCategory().getOrganization().getId(); @@ -37,11 +44,45 @@ public class UpdateTaskStatusUseCase { throw new SecurityException("Você não tem permissão para atualizar esta tarefa."); } - task.setStatus(newStatus); + TaskStatus resolved = null; + if (newStatusId != null) { + resolved = taskStatusRepository.findTaskStatusById(newStatusId).orElse(null); + } + if (resolved == null && newStatusName != null && !newStatusName.isBlank() && orgId != null) { + String name = newStatusName.trim(); + if ("NOT_STARTED".equalsIgnoreCase(name)) name = "A Fazer"; + else if ("IN_PROGRESS".equalsIgnoreCase(name)) name = "Em Andamento"; + else if ("COMPLETED".equalsIgnoreCase(name)) name = "Concluído"; + else if ("CANCELED".equalsIgnoreCase(name)) name = "Cancelado"; + resolved = taskStatusRepository.findByNameAndOrganizationId(name, orgId).orElse(null); + } + + if (resolved == null) { + throw new IllegalArgumentException("Status não encontrado para a tarefa."); + } + + task.setStatus(resolved); CreateTaskOutput output = CreateTaskMapper.toCreateTaskOutput(taskRepository.saveTask(task)); - LOGGER.infof("Status updated for task %d to %s", id, newStatus); + LOGGER.infof("Status updated for task %d to %s", id, resolved.getName()); return output; } -} + public CreateTaskOutput execute(Long id, String rawStatus, String userEmail) { + if (rawStatus == null || rawStatus.isBlank()) { + throw new IllegalArgumentException("Status é obrigatório."); + } + String clean = rawStatus.trim(); + if (clean.startsWith("\"") && clean.endsWith("\"") && clean.length() >= 2) { + clean = clean.substring(1, clean.length() - 1).trim(); + } + + Long statusId = null; + try { + statusId = Long.parseLong(clean); + } catch (NumberFormatException ignored) { + } + + return execute(id, statusId, clean, userEmail); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskUseCase.java index 59c0e9b..7c904ba 100644 --- a/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskUseCase.java +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/task/update/UpdateTaskUseCase.java @@ -8,9 +8,11 @@ import net.beetechgroup.beetask.entities.User; import net.beetechgroup.beetask.entities.organization.UserOrganizationStatus; import net.beetechgroup.beetask.entities.task.Task; import net.beetechgroup.beetask.entities.task.TaskHistoryItem; +import net.beetechgroup.beetask.entities.task.TaskStatus; import net.beetechgroup.beetask.usecase.repository.CategoryRepository; import net.beetechgroup.beetask.usecase.repository.ProjectRepository; import net.beetechgroup.beetask.usecase.repository.TaskRepository; +import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository; import net.beetechgroup.beetask.usecase.repository.UserOrganizationRepository; import net.beetechgroup.beetask.usecase.repository.UserRepository; import net.beetechgroup.beetask.usecase.task.create.CreateTaskMapper; @@ -25,17 +27,20 @@ public class UpdateTaskUseCase { private final UserRepository userRepository; private final UserOrganizationRepository userOrganizationRepository; private final net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository; + private final TaskStatusRepository taskStatusRepository; public UpdateTaskUseCase(TaskRepository taskRepository, ProjectRepository projectRepository, CategoryRepository categoryRepository, UserRepository userRepository, UserOrganizationRepository userOrganizationRepository, - net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository) { + net.beetechgroup.beetask.usecase.repository.OrganizationRepository organizationRepository, + TaskStatusRepository taskStatusRepository) { this.taskRepository = taskRepository; this.projectRepository = projectRepository; this.categoryRepository = categoryRepository; this.userRepository = userRepository; this.userOrganizationRepository = userOrganizationRepository; this.organizationRepository = organizationRepository; + this.taskStatusRepository = taskStatusRepository; } public CreateTaskOutput execute(UpdateTaskInput input) { @@ -58,9 +63,28 @@ public class UpdateTaskUseCase { task.setTitle(input.title()); task.setDescription(input.description()); - task.setStatus(input.status()); task.setExternalUrl(input.externalUrl()); + if (input.statusId() != null || (input.status() != null && !input.status().isBlank())) { + TaskStatus taskStatus = null; + if (input.statusId() != null) { + taskStatus = taskStatusRepository.findTaskStatusById(input.statusId()).orElse(null); + } + Long orgId = input.organizationId() != null ? input.organizationId() : + (task.getOrganization() != null ? task.getOrganization().getId() : null); + if (taskStatus == null && input.status() != null && !input.status().isBlank() && orgId != null) { + String name = input.status().trim(); + if ("NOT_STARTED".equalsIgnoreCase(name)) name = "A Fazer"; + else if ("IN_PROGRESS".equalsIgnoreCase(name)) name = "Em Andamento"; + else if ("COMPLETED".equalsIgnoreCase(name)) name = "Concluído"; + else if ("CANCELED".equalsIgnoreCase(name)) name = "Cancelado"; + taskStatus = taskStatusRepository.findByNameAndOrganizationId(name, orgId).orElse(null); + } + if (taskStatus != null) { + task.setStatus(taskStatus); + } + } + if (Objects.nonNull(input.organizationId())) { organizationRepository.findOrganizationById(input.organizationId()).ifPresent(task::setOrganization); } diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/CreateTaskStatusInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/CreateTaskStatusInput.java new file mode 100644 index 0000000..3759aaa --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/CreateTaskStatusInput.java @@ -0,0 +1,12 @@ +package net.beetechgroup.beetask.usecase.taskstatus; + +public record CreateTaskStatusInput( + String userEmail, + Long organizationId, + String name, + String color, + Integer position, + Boolean isDefault, + Boolean isCompleted, + Boolean isCanceled) { +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/CreateTaskStatusUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/CreateTaskStatusUseCase.java new file mode 100644 index 0000000..a22cd3b --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/CreateTaskStatusUseCase.java @@ -0,0 +1,71 @@ +package net.beetechgroup.beetask.usecase.taskstatus; + +import java.util.List; +import net.beetechgroup.beetask.entities.organization.Organization; +import net.beetechgroup.beetask.entities.task.TaskStatus; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; +import net.beetechgroup.beetask.usecase.repository.OrganizationRepository; +import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository; +import org.jboss.logging.Logger; + +public class CreateTaskStatusUseCase { + private static final Logger LOGGER = Logger.getLogger(CreateTaskStatusUseCase.class); + + private final TaskStatusRepository taskStatusRepository; + private final OrganizationRepository organizationRepository; + private final AuthorizeOrganizationMemberUseCase authorizer; + + public CreateTaskStatusUseCase(TaskStatusRepository taskStatusRepository, + OrganizationRepository organizationRepository, + AuthorizeOrganizationMemberUseCase authorizer) { + this.taskStatusRepository = taskStatusRepository; + this.organizationRepository = organizationRepository; + this.authorizer = authorizer; + } + + public TaskStatusOutput execute(CreateTaskStatusInput input) { + authorizer.execute(input.userEmail(), input.organizationId()); + + String trimmedName = input.name() != null ? input.name().trim() : ""; + if (trimmedName.isBlank()) { + throw new IllegalArgumentException("O nome do status é obrigatório."); + } + + if (taskStatusRepository.existsByNameInOrganization(input.organizationId(), trimmedName, null)) { + throw new IllegalStateException("Já existe um status com o nome '" + trimmedName + "' nesta organização."); + } + + Organization org = organizationRepository.findOrganizationById(input.organizationId()) + .orElseThrow(() -> new IllegalArgumentException("Organização não encontrada: " + input.organizationId())); + + List existing = taskStatusRepository.findByOrganizationId(input.organizationId()); + + int position = input.position() != null ? input.position() : + existing.stream().mapToInt(TaskStatus::getPosition).max().orElse(-1) + 1; + + boolean isDefault = Boolean.TRUE.equals(input.isDefault()); + if (isDefault) { + // Unset previous default + for (TaskStatus s : existing) { + if (s.isDefault()) { + s.setDefault(false); + taskStatusRepository.saveTaskStatus(s); + } + } + } + + TaskStatus status = new TaskStatus(); + status.setName(trimmedName); + status.setColor(input.color() != null && !input.color().isBlank() ? input.color().trim() : "#71717a"); + status.setPosition(position); + status.setDefault(isDefault); + status.setCompleted(Boolean.TRUE.equals(input.isCompleted())); + status.setCanceled(Boolean.TRUE.equals(input.isCanceled())); + status.setOrganization(org); + + status = taskStatusRepository.saveTaskStatus(status); + LOGGER.infof("Created task status %d ('%s') in organization %d", status.getId(), status.getName(), org.getId()); + + return TaskStatusMapper.toOutput(status, 0); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/DeleteTaskStatusUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/DeleteTaskStatusUseCase.java new file mode 100644 index 0000000..a2d6bf3 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/DeleteTaskStatusUseCase.java @@ -0,0 +1,56 @@ +package net.beetechgroup.beetask.usecase.taskstatus; + +import java.util.List; +import net.beetechgroup.beetask.entities.task.TaskStatus; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; +import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository; +import org.jboss.logging.Logger; + +public class DeleteTaskStatusUseCase { + private static final Logger LOGGER = Logger.getLogger(DeleteTaskStatusUseCase.class); + + private final TaskStatusRepository taskStatusRepository; + private final AuthorizeOrganizationMemberUseCase authorizer; + + public DeleteTaskStatusUseCase(TaskStatusRepository taskStatusRepository, + AuthorizeOrganizationMemberUseCase authorizer) { + this.taskStatusRepository = taskStatusRepository; + this.authorizer = authorizer; + } + + public void execute(String userEmail, Long organizationId, Long statusId, Long moveToStatusId) { + authorizer.execute(userEmail, organizationId); + + TaskStatus status = taskStatusRepository.findTaskStatusById(statusId) + .orElseThrow(() -> new IllegalArgumentException("Status não encontrado: " + statusId)); + + if (!status.getOrganization().getId().equals(organizationId)) { + throw new SecurityException("Este status não pertence à organização informada."); + } + + List all = taskStatusRepository.findByOrganizationId(organizationId); + if (all.size() <= 1) { + throw new IllegalStateException("A organização deve manter pelo menos um status."); + } + + long count = taskStatusRepository.countTasksByStatusId(statusId); + if (count > 0) { + if (moveToStatusId == null) { + throw new IllegalStateException("Existem " + count + " tarefas associadas a este status. Escolha para qual status deseja movê-las antes de excluir."); + } + if (moveToStatusId.equals(statusId)) { + throw new IllegalArgumentException("O status de destino não pode ser o mesmo a ser excluído."); + } + TaskStatus targetStatus = taskStatusRepository.findTaskStatusById(moveToStatusId) + .orElseThrow(() -> new IllegalArgumentException("Status de destino não encontrado: " + moveToStatusId)); + if (!targetStatus.getOrganization().getId().equals(organizationId)) { + throw new SecurityException("O status de destino não pertence à mesma organização."); + } + + taskStatusRepository.reassignTasks(statusId, moveToStatusId); + } + + taskStatusRepository.deleteTaskStatus(status); + LOGGER.infof("Status %d ('%s') deleted from organization %d by user %s", statusId, status.getName(), organizationId, userEmail); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/ListTaskStatusesUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/ListTaskStatusesUseCase.java new file mode 100644 index 0000000..c5c646f --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/ListTaskStatusesUseCase.java @@ -0,0 +1,31 @@ +package net.beetechgroup.beetask.usecase.taskstatus; + +import java.util.List; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; +import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository; +import org.jboss.logging.Logger; + +public class ListTaskStatusesUseCase { + private static final Logger LOGGER = Logger.getLogger(ListTaskStatusesUseCase.class); + + private final TaskStatusRepository taskStatusRepository; + private final AuthorizeOrganizationMemberUseCase authorizer; + + public ListTaskStatusesUseCase(TaskStatusRepository taskStatusRepository, + AuthorizeOrganizationMemberUseCase authorizer) { + this.taskStatusRepository = taskStatusRepository; + this.authorizer = authorizer; + } + + public List execute(String userEmail, Long organizationId) { + authorizer.execute(userEmail, organizationId); + LOGGER.infof("Listing task statuses for organization %d by user %s", organizationId, userEmail); + + return taskStatusRepository.findByOrganizationId(organizationId).stream() + .map(s -> { + long taskCount = taskStatusRepository.countTasksByStatusId(s.getId()); + return TaskStatusMapper.toOutput(s, taskCount); + }) + .toList(); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/ReorderTaskStatusesUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/ReorderTaskStatusesUseCase.java new file mode 100644 index 0000000..289be6d --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/ReorderTaskStatusesUseCase.java @@ -0,0 +1,48 @@ +package net.beetechgroup.beetask.usecase.taskstatus; + +import java.util.List; +import net.beetechgroup.beetask.entities.task.TaskStatus; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; +import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository; +import org.jboss.logging.Logger; + +public class ReorderTaskStatusesUseCase { + private static final Logger LOGGER = Logger.getLogger(ReorderTaskStatusesUseCase.class); + + private final TaskStatusRepository taskStatusRepository; + private final AuthorizeOrganizationMemberUseCase authorizer; + + public ReorderTaskStatusesUseCase(TaskStatusRepository taskStatusRepository, + AuthorizeOrganizationMemberUseCase authorizer) { + this.taskStatusRepository = taskStatusRepository; + this.authorizer = authorizer; + } + + public List execute(String userEmail, Long organizationId, List statusIds) { + authorizer.execute(userEmail, organizationId); + + if (statusIds == null || statusIds.isEmpty()) { + return taskStatusRepository.findByOrganizationId(organizationId).stream() + .map(s -> TaskStatusMapper.toOutput(s, taskStatusRepository.countTasksByStatusId(s.getId()))) + .toList(); + } + + for (int i = 0; i < statusIds.size(); i++) { + Long id = statusIds.get(i); + var opt = taskStatusRepository.findTaskStatusById(id); + if (opt.isPresent()) { + TaskStatus status = opt.get(); + if (status.getOrganization().getId().equals(organizationId)) { + status.setPosition(i); + taskStatusRepository.saveTaskStatus(status); + } + } + } + + LOGGER.infof("Reordered %d task statuses for organization %d by user %s", statusIds.size(), organizationId, userEmail); + + return taskStatusRepository.findByOrganizationId(organizationId).stream() + .map(s -> TaskStatusMapper.toOutput(s, taskStatusRepository.countTasksByStatusId(s.getId()))) + .toList(); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/TaskStatusMapper.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/TaskStatusMapper.java new file mode 100644 index 0000000..d88ad8d --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/TaskStatusMapper.java @@ -0,0 +1,20 @@ +package net.beetechgroup.beetask.usecase.taskstatus; + +import net.beetechgroup.beetask.entities.task.TaskStatus; + +public class TaskStatusMapper { + public static TaskStatusOutput toOutput(TaskStatus status, long taskCount) { + if (status == null) return null; + return new TaskStatusOutput( + status.getId(), + status.getName(), + status.getColor(), + status.getPosition(), + status.isDefault(), + status.isCompleted(), + status.isCanceled(), + status.getOrganization() != null ? status.getOrganization().getId() : null, + taskCount + ); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/TaskStatusOutput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/TaskStatusOutput.java new file mode 100644 index 0000000..4d8bbbd --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/TaskStatusOutput.java @@ -0,0 +1,13 @@ +package net.beetechgroup.beetask.usecase.taskstatus; + +public record TaskStatusOutput( + Long id, + String name, + String color, + Integer position, + boolean isDefault, + boolean isCompleted, + boolean isCanceled, + Long organizationId, + long taskCount) { +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/UpdateTaskStatusInfoUseCase.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/UpdateTaskStatusInfoUseCase.java new file mode 100644 index 0000000..8185cb1 --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/UpdateTaskStatusInfoUseCase.java @@ -0,0 +1,74 @@ +package net.beetechgroup.beetask.usecase.taskstatus; + +import java.util.List; +import net.beetechgroup.beetask.entities.task.TaskStatus; +import net.beetechgroup.beetask.usecase.organization.auth.AuthorizeOrganizationMemberUseCase; +import net.beetechgroup.beetask.usecase.repository.TaskStatusRepository; +import org.jboss.logging.Logger; + +public class UpdateTaskStatusInfoUseCase { + private static final Logger LOGGER = Logger.getLogger(UpdateTaskStatusInfoUseCase.class); + + private final TaskStatusRepository taskStatusRepository; + private final AuthorizeOrganizationMemberUseCase authorizer; + + public UpdateTaskStatusInfoUseCase(TaskStatusRepository taskStatusRepository, + AuthorizeOrganizationMemberUseCase authorizer) { + this.taskStatusRepository = taskStatusRepository; + this.authorizer = authorizer; + } + + public TaskStatusOutput execute(UpdateTaskStatusInput input) { + authorizer.execute(input.userEmail(), input.organizationId()); + + TaskStatus status = taskStatusRepository.findTaskStatusById(input.id()) + .orElseThrow(() -> new IllegalArgumentException("Status não encontrado: " + input.id())); + + if (!status.getOrganization().getId().equals(input.organizationId())) { + throw new SecurityException("Este status não pertence à organização informada."); + } + + String trimmedName = input.name() != null ? input.name().trim() : ""; + if (trimmedName.isBlank()) { + throw new IllegalArgumentException("O nome do status é obrigatório."); + } + + if (taskStatusRepository.existsByNameInOrganization(input.organizationId(), trimmedName, status.getId())) { + throw new IllegalStateException("Já existe um status com o nome '" + trimmedName + "' nesta organização."); + } + + boolean isDefault = Boolean.TRUE.equals(input.isDefault()); + if (isDefault && !status.isDefault()) { + List all = taskStatusRepository.findByOrganizationId(input.organizationId()); + for (TaskStatus s : all) { + if (!s.getId().equals(status.getId()) && s.isDefault()) { + s.setDefault(false); + taskStatusRepository.saveTaskStatus(s); + } + } + } + + status.setName(trimmedName); + if (input.color() != null && !input.color().isBlank()) { + status.setColor(input.color().trim()); + } + if (input.position() != null) { + status.setPosition(input.position()); + } + if (input.isDefault() != null) { + status.setDefault(input.isDefault()); + } + if (input.isCompleted() != null) { + status.setCompleted(input.isCompleted()); + } + if (input.isCanceled() != null) { + status.setCanceled(input.isCanceled()); + } + + status = taskStatusRepository.saveTaskStatus(status); + long count = taskStatusRepository.countTasksByStatusId(status.getId()); + LOGGER.infof("Updated task status %d ('%s') in organization %d", status.getId(), status.getName(), input.organizationId()); + + return TaskStatusMapper.toOutput(status, count); + } +} diff --git a/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/UpdateTaskStatusInput.java b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/UpdateTaskStatusInput.java new file mode 100644 index 0000000..eab06aa --- /dev/null +++ b/backend/src/main/java/net/beetechgroup/beetask/usecase/taskstatus/UpdateTaskStatusInput.java @@ -0,0 +1,13 @@ +package net.beetechgroup.beetask.usecase.taskstatus; + +public record UpdateTaskStatusInput( + String userEmail, + Long organizationId, + Long id, + String name, + String color, + Integer position, + Boolean isDefault, + Boolean isCompleted, + Boolean isCanceled) { +} diff --git a/backend/src/main/resources/db/migration/V18__create_task_statuses_table.sql b/backend/src/main/resources/db/migration/V18__create_task_statuses_table.sql new file mode 100644 index 0000000..72d770d --- /dev/null +++ b/backend/src/main/resources/db/migration/V18__create_task_statuses_table.sql @@ -0,0 +1,63 @@ +-- 1. Create table task_statuses +CREATE TABLE task_statuses ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + name VARCHAR(120) NOT NULL, + color VARCHAR(32) NOT NULL DEFAULT '#71717a', + position INT NOT NULL DEFAULT 0, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + is_completed BOOLEAN NOT NULL DEFAULT FALSE, + is_canceled BOOLEAN NOT NULL DEFAULT FALSE, + organization_id BIGINT NOT NULL, + deleted_at TIMESTAMP, + CONSTRAINT fk_task_statuses_organization + FOREIGN KEY (organization_id) REFERENCES organizations (id) +); + +CREATE INDEX idx_task_statuses_organization_id ON task_statuses (organization_id); +CREATE UNIQUE INDEX uk_task_statuses_org_name ON task_statuses (organization_id, lower(name)) WHERE deleted_at IS NULL; + +-- 2. Populate default 4 statuses for all existing organizations +INSERT INTO task_statuses (name, color, position, is_default, is_completed, is_canceled, organization_id) +SELECT 'A Fazer', '#71717a', 0, TRUE, FALSE, FALSE, id FROM organizations; + +INSERT INTO task_statuses (name, color, position, is_default, is_completed, is_canceled, organization_id) +SELECT 'Em Andamento', '#14b8a6', 1, FALSE, FALSE, FALSE, id FROM organizations; + +INSERT INTO task_statuses (name, color, position, is_default, is_completed, is_canceled, organization_id) +SELECT 'Concluído', '#10b981', 2, FALSE, TRUE, FALSE, id FROM organizations; + +INSERT INTO task_statuses (name, color, position, is_default, is_completed, is_canceled, organization_id) +SELECT 'Cancelado', '#f43f5e', 3, FALSE, FALSE, TRUE, id FROM organizations; + +-- 3. Ensure any tasks without organization_id receive first organization +UPDATE tasks +SET organization_id = (SELECT id FROM organizations ORDER BY id ASC LIMIT 1) +WHERE organization_id IS NULL; + +-- 4. Add status_id to tasks and drop old enum constraint +ALTER TABLE tasks ADD COLUMN status_id BIGINT REFERENCES task_statuses(id); +CREATE INDEX idx_tasks_status_id ON tasks (status_id); + +ALTER TABLE tasks DROP CONSTRAINT IF EXISTS chk_tasks_status; + +-- 5. Backfill status_id and update status column to match name +UPDATE tasks t +SET status_id = s.id, + status = s.name +FROM task_statuses s +WHERE s.organization_id = t.organization_id + AND ( + (t.status = 'NOT_STARTED' AND s.name = 'A Fazer') OR + (t.status = 'IN_PROGRESS' AND s.name = 'Em Andamento') OR + (t.status = 'COMPLETED' AND s.name = 'Concluído') OR + (t.status = 'CANCELED' AND s.name = 'Cancelado') + ); + +-- For any remaining tasks, link to the organization's default status +UPDATE tasks t +SET status_id = s.id, + status = s.name +FROM task_statuses s +WHERE s.organization_id = t.organization_id + AND s.is_default = TRUE + AND t.status_id IS NULL; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index b3f7a0b..f4850f3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ import { OrganizationsPage } from './pages/OrganizationsPage' import { TasksPage } from './pages/TasksPage' import { ProjectsPage } from './pages/ProjectsPage' import { CategoriesPage } from './pages/CategoriesPage' +import { StatusesPage } from './pages/StatusesPage' import { MembersPage } from './pages/MembersPage' import { OrganizationSettingsPage } from './pages/OrganizationSettingsPage' import { AnalyticsPage } from './pages/AnalyticsPage' @@ -45,6 +46,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/components/layout/Sidebar.tsx b/frontend/src/components/layout/Sidebar.tsx index 683af5b..459c15c 100644 --- a/frontend/src/components/layout/Sidebar.tsx +++ b/frontend/src/components/layout/Sidebar.tsx @@ -21,6 +21,7 @@ import { LogOut, FileText, Lock, + CircleDot, } from 'lucide-react' import { Chip, ChipLabel } from '@heroui/react' import { useAuth } from '../../contexts/AuthContext' @@ -95,6 +96,7 @@ export const Sidebar = ({ isMobileOpen, onCloseMobile }: SidebarProps) => { links: [ { to: '/projects', label: 'Projetos', icon: }, { to: '/categories', label: 'Categorias', icon: }, + { to: '/statuses', label: 'Status', icon: }, ], }, { diff --git a/frontend/src/components/tasks/CreateTaskModal.tsx b/frontend/src/components/tasks/CreateTaskModal.tsx index 20e4197..6d6c9c8 100644 --- a/frontend/src/components/tasks/CreateTaskModal.tsx +++ b/frontend/src/components/tasks/CreateTaskModal.tsx @@ -23,6 +23,7 @@ import { projectService } from '../../services/projectService' import { categoryService } from '../../services/categoryService' import { organizationService } from '../../services/organizationService' import { taskService } from '../../services/taskService' +import { taskStatusService } from '../../services/taskStatusService' import { CustomSelect } from '../ui/Select' import { useAuth } from '../../contexts/AuthContext' import { canDeleteTask } from '../../lib/taskPermissions' @@ -42,7 +43,8 @@ interface CreateTaskModalProps { onSubmit: (taskData: { title: string description: string - status: TaskStatus + status: string + statusId?: number projectId?: number categoryId?: number assigneeUserId?: number @@ -51,10 +53,11 @@ interface CreateTaskModalProps { }) => Promise onDelete?: (task: Task) => void organizationId: number - initialStatus?: TaskStatus + initialStatus?: string initialTask?: Task | null initialTab?: 'details' | 'history' initialDraft?: Partial | null + taskStatuses?: TaskStatus[] } export const CreateTaskModal: React.FC = ({ @@ -63,10 +66,11 @@ export const CreateTaskModal: React.FC = ({ onSubmit, onDelete, organizationId, - initialStatus = 'NOT_STARTED', + initialStatus = 'A Fazer', initialTask = null, initialTab = 'details', initialDraft = null, + taskStatuses: propTaskStatuses, }) => { const { user, activeOrg } = useAuth() const [activeTab, setActiveTab] = useState<'details' | 'history'>('details') @@ -74,7 +78,7 @@ export const CreateTaskModal: React.FC = ({ // Task details const [title, setTitle] = useState('') const [description, setDescription] = useState('') - const [status, setStatus] = useState(initialStatus) + const [status, setStatus] = useState(initialStatus) const [projectId, setProjectId] = useState(undefined) const [categoryId, setCategoryId] = useState(undefined) const [assigneeUserId, setAssigneeUserId] = useState(undefined) @@ -95,6 +99,7 @@ export const CreateTaskModal: React.FC = ({ const [projects, setProjects] = useState([]) const [categories, setCategories] = useState([]) + const [taskStatuses, setTaskStatuses] = useState(propTaskStatuses || []) const [members, setMembers] = useState([]) const [loading, setLoading] = useState(false) @@ -214,14 +219,18 @@ export const CreateTaskModal: React.FC = ({ const loadMetadata = async () => { setLoading(true) try { - const [projRes, catRes, memRes] = await Promise.allSettled([ + const [projRes, catRes, memRes, statRes] = await Promise.allSettled([ projectService.listProjects(organizationId), categoryService.listCategories(organizationId), organizationService.listMembers(organizationId), + propTaskStatuses && propTaskStatuses.length > 0 + ? Promise.resolve(propTaskStatuses) + : taskStatusService.listStatuses(organizationId), ]) if (projRes.status === 'fulfilled') setProjects(projRes.value || []) if (catRes.status === 'fulfilled') setCategories(catRes.value || []) + if (statRes.status === 'fulfilled') setTaskStatuses(statRes.value || []) if (memRes.status === 'fulfilled') { const memberList = memRes.value || [] setMembers(memberList) @@ -427,10 +436,13 @@ export const CreateTaskModal: React.FC = ({ effectiveAssigneeUserId = undefined } + const matchedStatus = taskStatuses.find((s) => s.name === status) + await onSubmit({ title: title.trim(), description: description.trim(), status, + statusId: matchedStatus?.id, projectId, categoryId, assigneeUserId: effectiveAssigneeUserId, @@ -769,13 +781,23 @@ export const CreateTaskModal: React.FC = ({ setStatus(val as TaskStatus)} - options={[ - { value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' }, - { value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' }, - { value: 'COMPLETED', label: 'Concluído', color: '#10b981' }, - { value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' }, - ]} + onChange={(val) => setStatus(val)} + options={ + taskStatuses.length > 0 + ? [...taskStatuses] + .sort((a, b) => a.position - b.position) + .map((s) => ({ + value: s.name, + label: s.name, + color: s.color || '#71717a', + })) + : [ + { value: 'A Fazer', label: 'A Fazer', color: '#71717a' }, + { value: 'Em Andamento', label: 'Em Andamento', color: '#14b8a6' }, + { value: 'Concluído', label: 'Concluído', color: '#10b981' }, + { value: 'Cancelado', label: 'Cancelado', color: '#f43f5e' }, + ] + } /> diff --git a/frontend/src/components/tasks/TaskKanbanView.tsx b/frontend/src/components/tasks/TaskKanbanView.tsx index 7b98819..3c1ab14 100644 --- a/frontend/src/components/tasks/TaskKanbanView.tsx +++ b/frontend/src/components/tasks/TaskKanbanView.tsx @@ -1,63 +1,74 @@ import React, { useState } from 'react' -import { Plus, CheckCircle2, Clock, CircleDot, AlertCircle, Ban, Eye, EyeOff, MoreVertical } from 'lucide-react' +import { Plus, CheckCircle2, Clock, CircleDot, AlertCircle, Ban, Eye, EyeOff, MoreVertical, Edit2, Trash2 } from 'lucide-react' import type { Task, TaskStatus } from '../../types/task' import { TaskCard } from './TaskCard' import { calculateTaskTotalSeconds, formatSecondsToHuman } from '../../lib/timeUtils' interface TaskKanbanViewProps { tasks: Task[] + taskStatuses?: TaskStatus[] onToggleTimer: (task: Task) => void - onStatusChange: (task: Task, newStatus: TaskStatus) => void + onStatusChange: (task: Task, newStatus: string, newStatusId?: number) => void onEditTask: (task: Task) => void onOpenHistory?: (task: Task) => void onDeleteTask?: (task: Task) => void - onAddTaskToStatus: (status: TaskStatus) => void + onAddTaskToStatus: (status: string, statusId?: number) => void + onOpenCreateStatus?: () => void + onEditStatus?: (status: TaskStatus) => void + onDeleteStatus?: (status: TaskStatus) => void activeTaskId?: number | null activeTaskSeconds?: number - hiddenColumns?: TaskStatus[] + hiddenColumns?: string[] showAllColumns?: boolean - onHideColumn?: (status: TaskStatus) => void - onUnhideColumn?: (status: TaskStatus) => void + onHideColumn?: (status: string) => void + onUnhideColumn?: (status: string) => void } export interface ColumnDef { - status: TaskStatus + id?: number + status: string label: string icon: React.ElementType - colorClass: string - bgLightClass: string - borderColor: string + color: string + colorClass?: string + bgLightClass?: string + borderColor?: string + rawStatus?: TaskStatus } -export const COLUMNS: ColumnDef[] = [ +export const DEFAULT_COLUMNS: ColumnDef[] = [ { - status: 'NOT_STARTED', + status: 'A Fazer', label: 'A Fazer', icon: CircleDot, + color: '#71717a', colorClass: 'text-muted', bgLightClass: 'bg-surface-sunken', borderColor: 'border-border-soft', }, { - status: 'IN_PROGRESS', + status: 'Em Andamento', label: 'Em Andamento', icon: Clock, + color: '#14b8a6', colorClass: 'text-teal-700 dark:text-teal-400', bgLightClass: 'bg-teal-500/10 dark:bg-teal-500/15', borderColor: 'border-teal-600/25 dark:border-teal-500/20', }, { - status: 'COMPLETED', + status: 'Concluído', label: 'Concluído', icon: CheckCircle2, + color: '#10b981', colorClass: 'text-emerald-700 dark:text-emerald-400', bgLightClass: 'bg-emerald-500/10 dark:bg-emerald-500/15', borderColor: 'border-emerald-600/25 dark:border-emerald-500/20', }, { - status: 'CANCELED', + status: 'Cancelado', label: 'Cancelado', icon: Ban, + color: '#f43f5e', colorClass: 'text-rose-700 dark:text-rose-400', bgLightClass: 'bg-rose-500/10 dark:bg-rose-500/15', borderColor: 'border-rose-600/25 dark:border-rose-500/20', @@ -66,12 +77,16 @@ export const COLUMNS: ColumnDef[] = [ export const TaskKanbanView: React.FC = ({ tasks, + taskStatuses = [], onToggleTimer, onStatusChange, onEditTask, onOpenHistory, onDeleteTask, onAddTaskToStatus, + onOpenCreateStatus, + onEditStatus, + onDeleteStatus, activeTaskId, activeTaskSeconds, hiddenColumns = [], @@ -79,20 +94,44 @@ export const TaskKanbanView: React.FC = ({ onHideColumn, onUnhideColumn, }) => { - const [dragOverColumn, setDragOverColumn] = useState(null) - const [columnMenuOpen, setColumnMenuOpen] = useState(null) + const [dragOverColumn, setDragOverColumn] = useState(null) + const [columnMenuOpen, setColumnMenuOpen] = useState(null) + + // Build columns dynamically from taskStatuses if available, else use default 4 + const columns: ColumnDef[] = React.useMemo(() => { + if (taskStatuses && taskStatuses.length > 0) { + return [...taskStatuses] + .sort((a, b) => a.position - b.position) + .map((s) => { + let icon = Clock + if (s.isCompleted) icon = CheckCircle2 + else if (s.isCanceled) icon = Ban + else if (s.isDefault) icon = CircleDot + + return { + id: s.id, + status: s.name, + label: s.name, + icon, + color: s.color || '#71717a', + rawStatus: s, + } + }) + } + return DEFAULT_COLUMNS + }, [taskStatuses]) // Visible columns based on hidden state unless showAllColumns is active - const visibleColumns = COLUMNS.filter((col) => { + const visibleColumns = columns.filter((col) => { if (showAllColumns) return true - return !hiddenColumns.includes(col.status) + return !hiddenColumns.includes(col.status) && (!col.id || !hiddenColumns.includes(String(col.id))) }) - const handleDragOver = (e: React.DragEvent, status: TaskStatus) => { + const handleDragOver = (e: React.DragEvent, statusKey: string) => { e.preventDefault() e.dataTransfer.dropEffect = 'move' - if (dragOverColumn !== status) { - setDragOverColumn(status) + if (dragOverColumn !== statusKey) { + setDragOverColumn(statusKey) } } @@ -101,7 +140,7 @@ export const TaskKanbanView: React.FC = ({ setDragOverColumn(null) } - const handleDrop = (e: React.DragEvent, targetStatus: TaskStatus) => { + const handleDrop = (e: React.DragEvent, targetCol: ColumnDef) => { e.preventDefault() setDragOverColumn(null) const taskIdStr = e.dataTransfer.getData('text/plain') @@ -109,16 +148,15 @@ export const TaskKanbanView: React.FC = ({ const taskId = Number(taskIdStr) const targetTask = tasks.find((t) => t.id === taskId) - if (targetTask && targetTask.status !== targetStatus) { - onStatusChange(targetTask, targetStatus) - } - } + if (!targetTask) return - const getGridColsClass = (count: number) => { - if (count <= 1) return 'grid-cols-1' - if (count === 2) return 'grid-cols-1 md:grid-cols-2' - if (count === 3) return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3' - return 'grid-cols-1 md:grid-cols-2 lg:grid-cols-4' + const isSameStatus = + (targetCol.id && targetTask.taskStatus?.id === targetCol.id) || + targetTask.status === targetCol.status + + if (!isSameStatus) { + onStatusChange(targetTask, targetCol.status, targetCol.id) + } } if (visibleColumns.length === 0) { @@ -133,11 +171,42 @@ export const TaskKanbanView: React.FC = ({ ) } + const isWideBoard = visibleColumns.length > 4 + return ( -
+
{visibleColumns.map((col) => { - const columnTasks = tasks.filter((t) => t.status === col.status) - const isHidden = hiddenColumns.includes(col.status) + const columnTasks = tasks.filter((t) => { + if (col.id && t.taskStatus?.id) { + return t.taskStatus.id === col.id + } + if (col.id && t.status === String(col.id)) { + return true + } + const tStatus = t.status || '' + if (tStatus === col.status || tStatus === col.label) return true + if ((col.status === 'A Fazer' || col.status === 'NOT_STARTED') && (tStatus === 'NOT_STARTED' || tStatus === 'A Fazer' || t.taskStatus?.isDefault)) { + return true + } + if ((col.status === 'Em Andamento' || col.status === 'IN_PROGRESS') && (tStatus === 'IN_PROGRESS' || tStatus === 'Em Andamento')) { + return true + } + if ((col.status === 'Concluído' || col.status === 'COMPLETED') && (tStatus === 'COMPLETED' || tStatus === 'Concluído' || t.taskStatus?.isCompleted)) { + return true + } + if ((col.status === 'Cancelado' || col.status === 'CANCELED') && (tStatus === 'CANCELED' || tStatus === 'Cancelado' || t.taskStatus?.isCanceled)) { + return true + } + return false + }) + + const isHidden = hiddenColumns.includes(col.status) || (col.id ? hiddenColumns.includes(String(col.id)) : false) const colTotalSeconds = columnTasks.reduce( (acc, t) => acc + @@ -152,23 +221,28 @@ export const TaskKanbanView: React.FC = ({ return (
handleDragOver(e, col.status)} onDragLeave={handleDragLeave} - onDrop={(e) => handleDrop(e, col.status)} + onDrop={(e) => handleDrop(e, col)} + style={{ + borderColor: isOver ? col.color : `${col.color}35`, + }} className={`flex flex-col rounded-3xl border transition-all duration-200 h-[680px] lg:h-[calc(100vh-270px)] lg:min-h-[520px] overflow-hidden bg-surface/60 backdrop-blur-md shadow-xs ${ - isHidden ? 'opacity-70 ring-1 ring-amber-500/30' : '' - } ${ - isOver - ? 'border-brand ring-2 ring-brand/30 bg-brand/5' - : col.borderColor + isWideBoard ? 'w-80 shrink-0' : 'w-full' + } ${isHidden ? 'opacity-70 ring-1 ring-amber-500/30' : ''} ${ + isOver ? 'ring-2 ring-brand/30 bg-brand/5' : '' }`} > {/* Column Header */}
@@ -195,14 +269,14 @@ export const TaskKanbanView: React.FC = ({
- {/* Column options menu (hide/unhide) */} + {/* Column options menu */}
+ )} + {isHidden ? ( )} + + {col.rawStatus && onDeleteStatus && ( + + )}
)} @@ -281,7 +385,7 @@ export const TaskKanbanView: React.FC = ({

Nenhuma tarefa nesta coluna

) })} + + {/* Add new column button */} + {onOpenCreateStatus && ( + + )}
) } diff --git a/frontend/src/components/tasks/TaskListView.tsx b/frontend/src/components/tasks/TaskListView.tsx index 1495d71..af3233d 100644 --- a/frontend/src/components/tasks/TaskListView.tsx +++ b/frontend/src/components/tasks/TaskListView.tsx @@ -8,8 +8,9 @@ import { canDeleteTask } from '../../lib/taskPermissions' interface TaskListViewProps { tasks: Task[] + taskStatuses?: TaskStatus[] onToggleTimer: (task: Task) => void - onStatusChange: (task: Task, newStatus: TaskStatus) => void + onStatusChange: (task: Task, newStatus: string, newStatusId?: number) => void onEditTask: (task: Task) => void onOpenHistory?: (task: Task) => void onDeleteTask?: (task: Task) => void @@ -20,6 +21,7 @@ interface TaskListViewProps { export const TaskListView: React.FC = ({ tasks, + taskStatuses = [], onToggleTimer, onStatusChange, onEditTask, @@ -29,6 +31,24 @@ export const TaskListView: React.FC = ({ activeTaskSeconds, }) => { const { user, activeOrg } = useAuth() + + const statusOptions = React.useMemo(() => { + if (taskStatuses && taskStatuses.length > 0) { + return [...taskStatuses] + .sort((a, b) => a.position - b.position) + .map((s) => ({ + value: s.name, + label: s.name, + color: s.color || '#71717a', + })) + } + return [ + { value: 'A Fazer', label: 'A Fazer', color: '#71717a' }, + { value: 'Em Andamento', label: 'Em Andamento', color: '#14b8a6' }, + { value: 'Concluído', label: 'Concluído', color: '#10b981' }, + { value: 'Cancelado', label: 'Cancelado', color: '#f43f5e' }, + ] + }, [taskStatuses]) const [activeMenuId, setActiveMenuId] = React.useState(null) return ( @@ -151,14 +171,12 @@ export const TaskListView: React.FC = ({
onStatusChange(task, newStatus as TaskStatus)} - options={[ - { value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' }, - { value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' }, - { value: 'COMPLETED', label: 'Concluído', color: '#10b981' }, - { value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' }, - ]} + value={task.taskStatus?.name || task.status} + onChange={(newStatus) => { + const matched = taskStatuses.find((s) => s.name === newStatus) + onStatusChange(task, newStatus, matched?.id) + }} + options={statusOptions} />
diff --git a/frontend/src/lib/userPreferences.ts b/frontend/src/lib/userPreferences.ts index 74dbf34..cd98e7f 100644 --- a/frontend/src/lib/userPreferences.ts +++ b/frontend/src/lib/userPreferences.ts @@ -1,5 +1,4 @@ import { authService } from '../services/authService' -import type { TaskStatus } from '../types/task' export type DashboardWidgetId = | 'kpis' @@ -110,7 +109,7 @@ export interface UserPreferences { targetHours: number } kanban?: { - hiddenColumns?: TaskStatus[] + hiddenColumns?: string[] } } diff --git a/frontend/src/pages/DashboardPage.tsx b/frontend/src/pages/DashboardPage.tsx index 2dc7dda..8afeb03 100644 --- a/frontend/src/pages/DashboardPage.tsx +++ b/frontend/src/pages/DashboardPage.tsx @@ -333,7 +333,8 @@ export const DashboardPage: React.FC = () => { const handleModalSubmit = async (taskData: { title: string description: string - status: 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELED' + status: string + statusId?: number projectId?: number categoryId?: number assigneeUserId?: number @@ -345,6 +346,7 @@ export const DashboardPage: React.FC = () => { title: taskData.title, description: taskData.description, status: taskData.status, + statusId: taskData.statusId, projectId: taskData.projectId, categoryId: taskData.categoryId, assigneeUserId: taskData.assigneeUserId, diff --git a/frontend/src/pages/StatusesPage.tsx b/frontend/src/pages/StatusesPage.tsx new file mode 100644 index 0000000..30aad0c --- /dev/null +++ b/frontend/src/pages/StatusesPage.tsx @@ -0,0 +1,625 @@ +import React, { useState, useEffect, useCallback, useMemo } from 'react' +import { useNavigate } from 'react-router-dom' +import { + Plus, + Search, + RefreshCw, + Clock, + ListTodo, + ArrowRight, + Edit2, + X, + Loader2, + AlertCircle, + CheckCircle2, + CircleDot, + Ban, + Trash2, + Check, + MoveUp, + MoveDown, +} from 'lucide-react' +import { useAuth } from '../contexts/AuthContext' +import { taskStatusService } from '../services/taskStatusService' +import { taskService } from '../services/taskService' +import type { TaskStatus, Task } from '../types/task' +import { calculateTaskTotalSeconds, formatSecondsToHuman } from '../lib/timeUtils' + +const COLOR_PRESETS = [ + '#71717a', // Zinc + '#0f766e', // Teal / Brand + '#10b981', // Emerald + '#3b82f6', // Blue + '#6366f1', // Indigo + '#8b5cf6', // Violet + '#ec4899', // Pink + '#f43f5e', // Rose + '#f59e0b', // Amber + '#64748b', // Slate +] + +export const StatusesPage: React.FC = () => { + const { activeOrg } = useAuth() + const navigate = useNavigate() + + const [statuses, setStatuses] = useState([]) + const [tasks, setTasks] = useState([]) + const [loading, setLoading] = useState(true) + const [refreshing, setRefreshing] = useState(false) + const [searchQuery, setSearchQuery] = useState('') + const [error, setError] = useState(null) + + // Create / Edit Modal + const [isModalOpen, setIsModalOpen] = useState(false) + const [editingStatus, setEditingStatus] = useState(null) + const [name, setName] = useState('') + const [color, setColor] = useState(COLOR_PRESETS[0]) + const [isDefault, setIsDefault] = useState(false) + const [isCompleted, setIsCompleted] = useState(false) + const [isCanceled, setIsCanceled] = useState(false) + const [submitting, setSubmitting] = useState(false) + + // Delete Modal + const [deletingStatus, setDeletingStatus] = useState(null) + const [moveToStatusId, setMoveToStatusId] = useState(undefined) + const [deleting, setDeleting] = useState(false) + + const activeOrgId = activeOrg?.id + + const loadData = useCallback(async () => { + if (!activeOrgId) return + try { + setRefreshing(true) + const [statusesRes, tasksRes] = await Promise.all([ + taskStatusService.listStatuses(activeOrgId), + taskService.listTasks({ organizationId: activeOrgId }).catch(() => []), + ]) + setStatuses(statusesRes || []) + setTasks(tasksRes || []) + } catch (err: unknown) { + console.error(err) + setError('Falha ao carregar status.') + } finally { + setLoading(false) + setRefreshing(false) + } + }, [activeOrgId]) + + useEffect(() => { + loadData() + }, [loadData]) + + // Aggregate stats per status + const statusStats = useMemo(() => { + const statsMap = new Map() + + tasks.forEach((task) => { + let matchedStatusId: number | undefined = task.taskStatus?.id + + if (!matchedStatusId) { + const found = statuses.find( + (s) => + s.name === task.status || + (s.name === 'A Fazer' && (task.status === 'NOT_STARTED' || task.taskStatus?.isDefault)) || + (s.name === 'Em Andamento' && task.status === 'IN_PROGRESS') || + (s.name === 'Concluído' && (task.status === 'COMPLETED' || task.taskStatus?.isCompleted)) || + (s.name === 'Cancelado' && (task.status === 'CANCELED' || task.taskStatus?.isCanceled)) + ) + matchedStatusId = found?.id + } + + if (matchedStatusId) { + const current = statsMap.get(matchedStatusId) || { totalTasks: 0, totalSeconds: 0 } + current.totalTasks += 1 + current.totalSeconds += calculateTaskTotalSeconds(task) + statsMap.set(matchedStatusId, current) + } + }) + + return statsMap + }, [tasks, statuses]) + + const openCreateModal = () => { + setEditingStatus(null) + setName('') + setColor(COLOR_PRESETS[0]) + setIsDefault(false) + setIsCompleted(false) + setIsCanceled(false) + setIsModalOpen(true) + } + + const openEditModal = (st: TaskStatus) => { + setEditingStatus(st) + setName(st.name) + setColor(st.color || COLOR_PRESETS[0]) + setIsDefault(Boolean(st.isDefault)) + setIsCompleted(Boolean(st.isCompleted)) + setIsCanceled(Boolean(st.isCanceled)) + setIsModalOpen(true) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + if (!activeOrgId || !name.trim()) return + + setSubmitting(true) + setError(null) + try { + if (editingStatus) { + await taskStatusService.updateStatus(activeOrgId, editingStatus.id, { + name: name.trim(), + color, + isDefault, + isCompleted, + isCanceled, + }) + } else { + await taskStatusService.createStatus(activeOrgId, { + name: name.trim(), + color, + isDefault, + isCompleted, + isCanceled, + }) + } + setIsModalOpen(false) + loadData() + } catch (err: unknown) { + console.error(err) + setError(err instanceof Error ? err.message : 'Falha ao salvar status.') + } finally { + setSubmitting(false) + } + } + + const handleOpenDeleteModal = (st: TaskStatus) => { + const otherStatuses = statuses.filter((s) => s.id !== st.id) + setMoveToStatusId(otherStatuses.length > 0 ? otherStatuses[0].id : undefined) + setDeletingStatus(st) + } + + const handleConfirmDelete = async () => { + if (!activeOrgId || !deletingStatus) return + setDeleting(true) + setError(null) + try { + await taskStatusService.deleteStatus(activeOrgId, deletingStatus.id, moveToStatusId) + setDeletingStatus(null) + loadData() + } catch (err: unknown) { + console.error(err) + setError(err instanceof Error ? err.message : 'Falha ao excluir status.') + } finally { + setDeleting(false) + } + } + + const handleMoveOrder = async (index: number, direction: 'up' | 'down') => { + if (!activeOrgId) return + const targetIndex = direction === 'up' ? index - 1 : index + 1 + if (targetIndex < 0 || targetIndex >= statuses.length) return + + const reordered = [...statuses] + const temp = reordered[index] + reordered[index] = reordered[targetIndex] + reordered[targetIndex] = temp + + setStatuses(reordered) + try { + await taskStatusService.reorderStatuses( + activeOrgId, + reordered.map((s) => s.id) + ) + } catch (err) { + console.error('Failed to reorder statuses', err) + loadData() + } + } + + const filteredStatuses = useMemo(() => { + if (!searchQuery.trim()) return statuses + const q = searchQuery.toLowerCase() + return statuses.filter((s) => s.name.toLowerCase().includes(q)) + }, [statuses, searchQuery]) + + return ( +
+ {/* Page Header */} +
+
+

+ Estados das Tarefas + + {statuses.length} + +

+

+ Personalize as colunas e os fluxos de trabalho da organização {activeOrg?.name}. +

+
+ +
+ + + +
+
+ + {error && ( +
+ + {error} + +
+ )} + + {/* Search & Actions Bar */} +
+
+ + setSearchQuery(e.target.value)} + className="w-full pl-9 pr-3 py-1.5 rounded-xl border border-border-soft bg-surface text-foreground text-xs focus:outline-hidden focus:border-brand transition-colors" + /> +
+
+ + {/* Grid of Statuses */} + {loading ? ( +
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+ ) : filteredStatuses.length === 0 ? ( +
+ +

Nenhum status encontrado

+

+ {searchQuery + ? 'Nenhum status corresponde à busca atual.' + : 'Crie seu primeiro status para gerenciar o fluxo das tarefas da sua organização.'} +

+ +
+ ) : ( +
+ {filteredStatuses.map((st, index) => { + const stats = statusStats.get(st.id) || { totalTasks: 0, totalSeconds: 0 } + let IconComponent = Clock + if (st.isCompleted) IconComponent = CheckCircle2 + else if (st.isCanceled) IconComponent = Ban + else if (st.isDefault) IconComponent = CircleDot + + return ( +
+
+
+
+
+ +
+
+

{st.name}

+
+ {st.isDefault && ( + + Padrão + + )} + {st.isCompleted && ( + + Conclusão + + )} + {st.isCanceled && ( + + Cancelamento + + )} +
+
+
+ + {/* Order buttons & Edit */} +
+ + + + {statuses.length > 1 && ( + + )} +
+
+ + {/* Statistics */} +
+
+ + + {stats.totalTasks} tarefas + +
+ +
+ + + {formatSecondsToHuman(stats.totalSeconds)} + +
+
+
+ +
+ Posição no Kanban: {st.position + 1} + +
+
+ ) + })} +
+ )} + + {/* Modal Criar/Editar */} + {isModalOpen && ( +
+
+
+

+ + {editingStatus ? 'Editar Status' : 'Novo Status'} +

+ +
+ +
+
+ + setName(e.target.value)} + className="w-full px-3 py-2 rounded-xl border border-border bg-surface-raised text-foreground text-xs focus:outline-hidden focus:border-brand" + /> +
+ +
+ +
+ {COLOR_PRESETS.map((preset) => ( + + ))} +
+
+ + {/* Behavior Flags */} +
+ + + + + + + +
+ +
+ + +
+
+
+
+ )} + + {/* Modal Excluir com Reatribuição */} + {deletingStatus && ( +
+
+
+
+ +
+
+

Excluir Status "{deletingStatus.name}"?

+

Esta ação remove o status e a coluna correspondente no Kanban.

+
+
+ + {(() => { + const taskCount = statusStats.get(deletingStatus.id)?.totalTasks || 0 + const otherStatuses = statuses.filter((s) => s.id !== deletingStatus.id) + + if (taskCount > 0) { + return ( +
+
+ Existem {taskCount} tarefas neste status. Escolha para qual status elas devem ser + movidas: +
+ +
+ + +
+
+ ) + } + return ( +

Não há tarefas vinculadas a este status. Ele pode ser excluído com segurança.

+ ) + })()} + +
+ + +
+
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/TasksPage.tsx b/frontend/src/pages/TasksPage.tsx index a37b6a6..ad712eb 100644 --- a/frontend/src/pages/TasksPage.tsx +++ b/frontend/src/pages/TasksPage.tsx @@ -9,23 +9,25 @@ import { RefreshCw, Folder, Users, - User, - CheckCircle2, - Clock, Sparkles, AlertCircle, CircleDot, - Ban, Eye, EyeOff, SlidersHorizontal, + Trash2, + Check, + X, + Loader2, + User as UserIcon, } from 'lucide-react' import { useAuth } from '../contexts/AuthContext' import { useTimer } from '../contexts/TimerContext' import { taskService } from '../services/taskService' import { projectService } from '../services/projectService' +import { taskStatusService } from '../services/taskStatusService' import type { Task, TaskStatus, Project, ParsedAiTask } from '../types/task' -import { TaskKanbanView, COLUMNS } from '../components/tasks/TaskKanbanView' +import { TaskKanbanView, DEFAULT_COLUMNS } from '../components/tasks/TaskKanbanView' import { TaskListView } from '../components/tasks/TaskListView' import { CreateTaskModal } from '../components/tasks/CreateTaskModal' import { CreateWithAiModal } from '../components/tasks/CreateWithAiModal' @@ -36,6 +38,19 @@ import { savePreferencesToBackend, } from '../lib/userPreferences' +const COLOR_PRESETS = [ + '#71717a', + '#0f766e', + '#10b981', + '#3b82f6', + '#6366f1', + '#8b5cf6', + '#ec4899', + '#f43f5e', + '#f59e0b', + '#64748b', +] + export const TasksPage: React.FC = () => { const { activeOrg, user } = useAuth() const { activeTask, activeSeconds, startTimer, stopTimer, syncWithTasks } = useTimer() @@ -47,18 +62,19 @@ export const TasksPage: React.FC = () => { const [tasks, setTasks] = useState([]) const [projects, setProjects] = useState([]) + const [taskStatuses, setTaskStatuses] = useState([]) const [loading, setLoading] = useState(true) const [refreshing, setRefreshing] = useState(false) const [viewMode, setViewMode] = useState<'kanban' | 'list'>('kanban') // Kanban column visibility preferences - const [hiddenColumns, setHiddenColumns] = useState([]) + const [hiddenColumns, setHiddenColumns] = useState([]) const [showAllColumns, setShowAllColumns] = useState(false) const [showColumnSettings, setShowColumnSettings] = useState(false) // Filters const [searchQuery, setSearchQuery] = useState('') - const [selectedStatus, setSelectedStatus] = useState('ALL') + const [selectedStatus, setSelectedStatus] = useState('ALL') const [selectedProjectId, setSelectedProjectId] = useState('ALL') // Scope Toggle State: 'mine' (Individual) vs 'team' (Equipe) @@ -86,13 +102,28 @@ export const TasksPage: React.FC = () => { // Modal state const [isModalOpen, setIsModalOpen] = useState(false) - const [modalInitialStatus, setModalInitialStatus] = useState('NOT_STARTED') + const [modalInitialStatus, setModalInitialStatus] = useState('A Fazer') const [modalInitialTab, setModalInitialTab] = useState<'details' | 'history'>('details') const [editingTask, setEditingTask] = useState(null) const [isAiModalOpen, setIsAiModalOpen] = useState(false) const [aiDraftTask, setAiDraftTask] = useState(null) const [actionError, setActionError] = useState(null) + // Status Quick Create / Edit Modal State + const [isStatusModalOpen, setIsStatusModalOpen] = useState(false) + const [editingStatus, setEditingStatus] = useState(null) + const [statusName, setStatusName] = useState('') + const [statusColor, setStatusColor] = useState(COLOR_PRESETS[0]) + const [statusIsDefault, setStatusIsDefault] = useState(false) + const [statusIsCompleted, setStatusIsCompleted] = useState(false) + const [statusIsCanceled, setStatusIsCanceled] = useState(false) + const [statusSubmitting, setStatusSubmitting] = useState(false) + + // Status Delete Modal State + const [deletingStatus, setDeletingStatus] = useState(null) + const [moveToStatusId, setMoveToStatusId] = useState(undefined) + const [deletingStatusLoading, setDeletingStatusLoading] = useState(false) + // Load user preferences for hidden columns from backend useEffect(() => { if (!user?.email) return @@ -112,7 +143,7 @@ export const TasksPage: React.FC = () => { // Save hidden columns to backend const updateHiddenColumns = useCallback( - (newHidden: TaskStatus[]) => { + (newHidden: string[]) => { setHiddenColumns(newHidden) if (!user?.email) return const current = getLocalPreferences(user.email) @@ -131,7 +162,7 @@ export const TasksPage: React.FC = () => { ) const handleHideColumn = useCallback( - (status: TaskStatus) => { + (status: string) => { if (!hiddenColumns.includes(status)) { updateHiddenColumns([...hiddenColumns, status]) } @@ -140,7 +171,7 @@ export const TasksPage: React.FC = () => { ) const handleUnhideColumn = useCallback( - (status: TaskStatus) => { + (status: string) => { updateHiddenColumns(hiddenColumns.filter((s) => s !== status)) }, [hiddenColumns, updateHiddenColumns] @@ -152,18 +183,20 @@ export const TasksPage: React.FC = () => { const activeOrgId = activeOrg?.id - // Load tasks & projects for current organization + // Load tasks, projects & statuses for current organization const loadData = useCallback(async () => { if (!activeOrgId) return try { setRefreshing(true) - const [tasksRes, projsRes] = await Promise.all([ + const [tasksRes, projsRes, statusesRes] = await Promise.all([ taskService.listTasks({ organizationId: activeOrgId }), projectService.listProjects(activeOrgId).catch(() => []), + taskStatusService.listStatuses(activeOrgId).catch(() => []), ]) setTasks(tasksRes || []) setProjects(projsRes || []) + setTaskStatuses(statusesRes || []) syncWithTasksRef.current(tasksRes || []) } catch (err) { console.error('Failed to load tasks', err) @@ -192,12 +225,38 @@ export const TasksPage: React.FC = () => { // Scope tasks based on viewScope const scopeTasks = viewScope === 'mine' ? myTasks : tasks + // Effective columns for preferences & board + const effectiveColumns = useMemo(() => { + if (taskStatuses && taskStatuses.length > 0) { + return [...taskStatuses] + .sort((a, b) => a.position - b.position) + .map((s) => ({ + id: s.id, + status: s.name, + label: s.name, + color: s.color || '#71717a', + rawStatus: s, + })) + } + return DEFAULT_COLUMNS + }, [taskStatuses]) + // Filter tasks client-side for ultra-responsive search const filteredTasks = useMemo(() => { return scopeTasks.filter((task) => { // Status filter - if (selectedStatus !== 'ALL' && task.status !== selectedStatus) { - return false + if (selectedStatus !== 'ALL') { + const tStatus = task.taskStatus?.name || task.status + const matchesStatus = + tStatus === selectedStatus || + task.status === selectedStatus || + (selectedStatus === 'A Fazer' && (task.status === 'NOT_STARTED' || task.taskStatus?.isDefault)) || + (selectedStatus === 'Em Andamento' && task.status === 'IN_PROGRESS') || + (selectedStatus === 'Concluído' && (task.status === 'COMPLETED' || task.taskStatus?.isCompleted)) || + (selectedStatus === 'Cancelado' && (task.status === 'CANCELED' || task.taskStatus?.isCanceled)) + if (!matchesStatus) { + return false + } } // Project filter if (selectedProjectId !== 'ALL' && task.project?.id !== selectedProjectId) { @@ -241,14 +300,24 @@ export const TasksPage: React.FC = () => { } // Status change handler - const handleStatusChange = async (task: Task, newStatus: TaskStatus) => { + const handleStatusChange = async (task: Task, newStatus: string, newStatusId?: number) => { setActionError(null) + const matchedStatus = taskStatuses.find((s) => s.id === newStatusId || s.name === newStatus) + try { // Optimistic update setTasks((prev) => - prev.map((t) => (t.id === task.id ? { ...t, status: newStatus } : t)) + prev.map((t) => + t.id === task.id + ? { + ...t, + status: newStatus, + taskStatus: matchedStatus || t.taskStatus, + } + : t + ) ) - const updated = await taskService.updateStatus(task.id, newStatus) + const updated = await taskService.updateStatus(task.id, newStatus, newStatusId) setTasks((prev) => prev.map((t) => (t.id === updated.id ? updated : t))) } catch (err: unknown) { console.error('Failed to update status', err) @@ -261,7 +330,8 @@ export const TasksPage: React.FC = () => { const handleModalSubmit = async (taskData: { title: string description: string - status: TaskStatus + status: string + statusId?: number projectId?: number categoryId?: number assigneeUserId?: number @@ -276,6 +346,7 @@ export const TasksPage: React.FC = () => { title: taskData.title, description: taskData.description, status: taskData.status, + statusId: taskData.statusId, projectId: taskData.projectId, categoryId: taskData.categoryId, assigneeUserId: taskData.assigneeUserId, @@ -291,6 +362,7 @@ export const TasksPage: React.FC = () => { title: taskData.title, description: taskData.description, status: taskData.status, + statusId: taskData.statusId, projectId: taskData.projectId, categoryId: taskData.categoryId, assigneeUserId: taskData.assigneeUserId, @@ -301,7 +373,7 @@ export const TasksPage: React.FC = () => { } } - const handleOpenCreateModal = (initialStatus: TaskStatus = 'NOT_STARTED') => { + const handleOpenCreateModal = (initialStatus: string = 'A Fazer') => { setEditingTask(null) setModalInitialStatus(initialStatus) setModalInitialTab('details') @@ -343,11 +415,80 @@ export const TasksPage: React.FC = () => { [activeTask, stopTimer, editingTask] ) - // Summary counts based on active scope - const notStartedCount = scopeTasks.filter((t) => t.status === 'NOT_STARTED').length - const inProgressCount = scopeTasks.filter((t) => t.status === 'IN_PROGRESS').length - const completedCount = scopeTasks.filter((t) => t.status === 'COMPLETED').length - const canceledCount = scopeTasks.filter((t) => t.status === 'CANCELED').length + // Status modal handlers + const handleOpenStatusModal = (st: TaskStatus | null) => { + if (st) { + setEditingStatus(st) + setStatusName(st.name) + setStatusColor(st.color || COLOR_PRESETS[0]) + setStatusIsDefault(Boolean(st.isDefault)) + setStatusIsCompleted(Boolean(st.isCompleted)) + setStatusIsCanceled(Boolean(st.isCanceled)) + } else { + setEditingStatus(null) + setStatusName('') + setStatusColor(COLOR_PRESETS[0]) + setStatusIsDefault(false) + setStatusIsCompleted(false) + setStatusIsCanceled(false) + } + setIsStatusModalOpen(true) + } + + const handleSaveStatus = async (e: React.FormEvent) => { + e.preventDefault() + if (!activeOrgId || !statusName.trim()) return + + setStatusSubmitting(true) + try { + if (editingStatus) { + await taskStatusService.updateStatus(activeOrgId, editingStatus.id, { + name: statusName.trim(), + color: statusColor, + isDefault: statusIsDefault, + isCompleted: statusIsCompleted, + isCanceled: statusIsCanceled, + }) + } else { + await taskStatusService.createStatus(activeOrgId, { + name: statusName.trim(), + color: statusColor, + isDefault: statusIsDefault, + isCompleted: statusIsCompleted, + isCanceled: statusIsCanceled, + }) + } + setIsStatusModalOpen(false) + const updatedStatuses = await taskStatusService.listStatuses(activeOrgId) + setTaskStatuses(updatedStatuses || []) + } catch (err: unknown) { + console.error(err) + setActionError(err instanceof Error ? err.message : 'Falha ao salvar status.') + } finally { + setStatusSubmitting(false) + } + } + + const handleOpenDeleteStatusModal = (st: TaskStatus) => { + const others = taskStatuses.filter((s) => s.id !== st.id) + setMoveToStatusId(others.length > 0 ? others[0].id : undefined) + setDeletingStatus(st) + } + + const handleConfirmDeleteStatus = async () => { + if (!activeOrgId || !deletingStatus) return + setDeletingStatusLoading(true) + try { + await taskStatusService.deleteStatus(activeOrgId, deletingStatus.id, moveToStatusId) + setDeletingStatus(null) + loadData() + } catch (err: unknown) { + console.error(err) + setActionError(err instanceof Error ? err.message : 'Falha ao excluir status.') + } finally { + setDeletingStatusLoading(false) + } + } return (
@@ -358,124 +499,122 @@ export const TasksPage: React.FC = () => {

Quadro de Tarefas

- - + {activeOrg?.name || 'Workspace'}
-

- Planeje, execute e rastreie o tempo investido em cada entrega da equipe. +

+ Monitore seu fluxo ágil, lance tempos de dedicação e entregue valor contínuo.

- {/* View Switcher & New Task Button */} -
- {/* Refresh */} + {/* View Mode & Actions */} +
+ {/* Refresh button */} - {/* Kanban / List Toggle */} -
+ {/* View toggle (Kanban vs List) */} +
- {/* Kanban Columns Visibility Control */} + {/* Kanban Columns Settings (Only in Kanban Mode) */} {viewMode === 'kanban' && (
+ {/* Column Settings Dropdown */} {showColumnSettings && ( <>
setShowColumnSettings(false)} /> -
-
- Visibilidade de Colunas - {hiddenColumns.length > 0 && ( - - )} +
+
+
+ +

Colunas do Quadro

+
+ + {effectiveColumns.length - hiddenColumns.length} de {effectiveColumns.length} ativas +
- {/* Toggle show all columns temporarily */} -
-
- {showAllColumns ? ( - - ) : ( - - )} - Mostrar ocultas -
- setShowAllColumns(e.target.checked)} - className="w-4 h-4 rounded text-brand border-border focus:ring-brand" - /> + {/* Master toggle */} +
+ Exibir todas as colunas: +
{/* Individual columns list */} -
- {COLUMNS.map((col) => { +
+ {effectiveColumns.map((col) => { const isHidden = hiddenColumns.includes(col.status) return ( +
)} @@ -536,7 +688,8 @@ export const TasksPage: React.FC = () => { type="button" onClick={() => { setAiDraftTask(null) - handleOpenCreateModal('NOT_STARTED') + const defaultCol = taskStatuses.find((s) => s.isDefault)?.name || 'A Fazer' + handleOpenCreateModal(defaultCol) }} className="flex items-center gap-1.5 px-4 py-2.5 rounded-xl bg-brand text-white font-semibold text-xs hover:bg-brand-hover shadow-md shadow-brand/25 transition-all hover:-translate-y-0.5 active:translate-y-0" > @@ -579,8 +732,8 @@ export const TasksPage: React.FC = () => { : 'text-muted hover:text-foreground' }`} > - - Individual (Minhas Tarefas) + + Minhas Tarefas { {myTasks.length} +
- {/* Status Statistics (Icon + Number) */} + {/* Status Statistics (Dynamic Pills) */}
{/* Total / All */} - {/* A Fazer (NOT_STARTED) */} - + {/* Dynamic Status Pills */} + {effectiveColumns.map((col) => { + const count = scopeTasks.filter((t) => { + if (col.id && t.taskStatus?.id) return t.taskStatus.id === col.id + const tStatus = t.status || '' + if (tStatus === col.status || tStatus === col.label) return true + if ((col.status === 'A Fazer' || col.status === 'NOT_STARTED') && (tStatus === 'NOT_STARTED' || tStatus === 'A Fazer' || t.taskStatus?.isDefault)) return true + if ((col.status === 'Em Andamento' || col.status === 'IN_PROGRESS') && (tStatus === 'IN_PROGRESS' || tStatus === 'Em Andamento')) return true + if ((col.status === 'Concluído' || col.status === 'COMPLETED') && (tStatus === 'COMPLETED' || tStatus === 'Concluído' || t.taskStatus?.isCompleted)) return true + if ((col.status === 'Cancelado' || col.status === 'CANCELED') && (tStatus === 'CANCELED' || tStatus === 'Cancelado' || t.taskStatus?.isCanceled)) return true + return false + }).length - {/* Em Andamento (IN_PROGRESS) */} - + const isSelected = selectedStatus === col.status - {/* Concluído (COMPLETED) */} - - - {/* Cancelado (CANCELED) */} - + return ( + + ) + })}
@@ -750,13 +890,14 @@ export const TasksPage: React.FC = () => {
setSelectedStatus(val as TaskStatus | 'ALL')} + onChange={(val) => setSelectedStatus(val as string | 'ALL')} options={[ { value: 'ALL', label: 'Todos os Status' }, - { value: 'NOT_STARTED', label: 'A Fazer', color: '#71717a' }, - { value: 'IN_PROGRESS', label: 'Em Andamento', color: '#14b8a6' }, - { value: 'COMPLETED', label: 'Concluído', color: '#10b981' }, - { value: 'CANCELED', label: 'Cancelado', color: '#f43f5e' }, + ...effectiveColumns.map((c) => ({ + value: c.status, + label: c.label, + color: c.color, + })), ]} />
@@ -792,7 +933,7 @@ export const TasksPage: React.FC = () => {

+
+ +
+
+ + setStatusName(e.target.value)} + className="w-full px-3 py-2 rounded-xl border border-border bg-surface-raised text-foreground text-xs focus:outline-hidden focus:border-brand" + /> +
+ +
+ +
+ {COLOR_PRESETS.map((preset) => ( + + ))} +
+
+ + {/* Behavior Flags */} +
+ + + + + + + +
+ +
+ + +
+
+
+
+ )} + + {/* Modal Excluir Status com Reatribuição */} + {deletingStatus && ( +
+
+
+
+ +
+
+

Excluir Status "{deletingStatus.name}"?

+

Esta ação remove o status e a coluna correspondente no Kanban.

+
+
+ + {(() => { + const otherStatuses = taskStatuses.filter((s) => s.id !== deletingStatus.id) + return ( +
+
+ Se houver tarefas vinculadas a este status, selecione para qual coluna elas serão movidas: +
+ +
+ + +
+
+ ) + })()} + +
+ + +
+
+
+ )}
) } diff --git a/frontend/src/services/taskService.ts b/frontend/src/services/taskService.ts index 89bf631..110b8fe 100644 --- a/frontend/src/services/taskService.ts +++ b/frontend/src/services/taskService.ts @@ -1,5 +1,5 @@ import { apiFetch } from '../lib/api' -import type { Task, TaskStatus, CreateTaskPayload, ListTasksFilter, ParsedAiTask } from '../types/task' +import type { Task, CreateTaskPayload, ListTasksFilter, ParsedAiTask } from '../types/task' function buildQueryParams(filter?: ListTasksFilter): string { if (!filter) return '' @@ -57,9 +57,14 @@ export const taskService = { }) }, - async updateStatus(id: number, status: TaskStatus): Promise { + async updateStatus(id: number, status: string, statusId?: number): Promise { + if (statusId) { + return apiFetch(`/tasks/${id}/status/${statusId}`, { + method: 'PUT', + }) + } return apiFetch(`/tasks/${id}/status`, { - method: 'PATCH', + method: 'PUT', body: status, }) }, diff --git a/frontend/src/services/taskStatusService.ts b/frontend/src/services/taskStatusService.ts new file mode 100644 index 0000000..17cdd71 --- /dev/null +++ b/frontend/src/services/taskStatusService.ts @@ -0,0 +1,56 @@ +import { apiFetch } from '../lib/api' +import type { TaskStatus } from '../types/task' + +export interface CreateTaskStatusData { + name: string + color?: string + position?: number + isDefault?: boolean + isCompleted?: boolean + isCanceled?: boolean +} + +export interface UpdateTaskStatusData { + name: string + color?: string + position?: number + isDefault?: boolean + isCompleted?: boolean + isCanceled?: boolean +} + +export const taskStatusService = { + async listStatuses(orgId: number): Promise { + return apiFetch(`/organizations/${orgId}/task-statuses`) + }, + + async createStatus(orgId: number, data: CreateTaskStatusData): Promise { + return apiFetch(`/organizations/${orgId}/task-statuses`, { + method: 'POST', + body: data, + }) + }, + + async updateStatus(orgId: number, id: number, data: UpdateTaskStatusData): Promise { + return apiFetch(`/organizations/${orgId}/task-statuses/${id}`, { + method: 'PUT', + body: data, + }) + }, + + async deleteStatus(orgId: number, id: number, moveToStatusId?: number): Promise { + const url = moveToStatusId + ? `/organizations/${orgId}/task-statuses/${id}?moveToStatusId=${moveToStatusId}` + : `/organizations/${orgId}/task-statuses/${id}` + return apiFetch(url, { + method: 'DELETE', + }) + }, + + async reorderStatuses(orgId: number, statusIds: number[]): Promise { + return apiFetch(`/organizations/${orgId}/task-statuses/reorder`, { + method: 'PUT', + body: statusIds, + }) + }, +} diff --git a/frontend/src/types/task.ts b/frontend/src/types/task.ts index 54be3ac..9aa83f8 100644 --- a/frontend/src/types/task.ts +++ b/frontend/src/types/task.ts @@ -1,4 +1,16 @@ -export type TaskStatus = 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELED'; +export type LegacyTaskStatus = 'NOT_STARTED' | 'IN_PROGRESS' | 'COMPLETED' | 'CANCELED'; + +export interface TaskStatus { + id: number; + name: string; + color: string; + position: number; + isDefault?: boolean; + isCompleted?: boolean; + isCanceled?: boolean; + organizationId?: number; + taskCount?: number; +} export interface TaskHistoryItem { id: number; @@ -32,7 +44,8 @@ export interface Task { id: number; title: string; description?: string; - status: TaskStatus; + status: string; + taskStatus?: TaskStatus; project?: Project; category?: Category; user?: TaskUser; @@ -51,7 +64,8 @@ export interface TaskHistoryItemInput { export interface CreateTaskPayload { title: string; description?: string; - status?: TaskStatus; + status?: string; + statusId?: number; organizationId: number; projectId?: number; categoryId?: number; @@ -67,13 +81,13 @@ export interface ParsedAiTask { categoryId?: number; assigneeUserId?: number; externalUrl?: string; - status?: TaskStatus; + status?: string; } export interface ListTasksFilter { organizationId?: number; text?: string; - status?: TaskStatus[]; + status?: string[]; projectId?: number[]; categoryId?: number[]; userId?: number[];